import * as logger from "./logger.js"; import fs from "fs"; import format from "format-duration"; import { Shoukaku, Connectors } from "shoukaku"; import { setTimeout } from "timers/promises"; export const players = new Map(); export const queues = new Map(); export const skipVotes = new Map(); export let manager; export let nodes = JSON.parse(fs.readFileSync(new URL("../config/servers.json", import.meta.url), { encoding: "utf8" })).lava; export let connected = false; export function connect(client) { manager = new Shoukaku(new Connectors.OceanicJS(client), nodes, { moveOnDisconnect: true, resume: true, reconnectInterval: 500, reconnectTries: 1 }); manager.on("error", (node, error) => { logger.error(`An error occurred on Lavalink node ${node}: ${error}`); }); manager.on("debug", (node, info) => { logger.debug(`Debug event from Lavalink node ${node}: ${info}`); }); manager.once("ready", () => { logger.log(`Successfully connected to ${manager.nodes.size} Lavalink node(s).`); connected = true; }); } export async function reload(client) { if (!manager) connect(client); const activeNodes = manager.nodes; const json = await fs.promises.readFile(new URL("../config/servers.json", import.meta.url), { encoding: "utf8" }); nodes = JSON.parse(json).lava; const names = nodes.map((a) => a.name); for (const name in activeNodes) { if (!names.includes(name)) { manager.removeNode(name); } } for (const node of nodes) { if (!activeNodes.has(node.name)) { manager.addNode(node); } } if (!manager.nodes.size) connected = false; return manager.nodes.size; } export async function play(client, sound, options, music = false) { if (!connected) return { content: "I'm not connected to any audio servers!", flags: 64 }; if (!manager) return { content: "The sound commands are still starting up!", flags: 64 }; if (!options.channel.guild) return { content: "This command only works in servers!", flags: 64 }; if (!options.member.voiceState) return { content: "You need to be in a voice channel first!", flags: 64 }; if (!options.channel.guild.permissionsOf(client.user.id.toString()).has("CONNECT")) return { content: "I can't join this voice channel!", flags: 64 }; const voiceChannel = options.channel.guild.channels.get(options.member.voiceState.channelID) ?? await client.rest.channels.get(options.member.voiceState.channelID); if (!voiceChannel.permissionsOf(client.user.id.toString()).has("CONNECT")) return { content: "I don't have permission to join this voice channel!", flags: 64 }; if (!music && manager.players.has(options.channel.guildID)) return { content: "I can't play a sound effect while other audio is playing!", flags: 64 }; const node = manager.getNode(); if (!music && !nodes.filter(obj => obj.name === node.name)[0].local) { sound = sound.replace(/\.\//, "https://raw.githubusercontent.com/esmBot/esmBot/master/"); } let response; try { response = await node.rest.resolve(sound); if (!response) return { content: "🔊 I couldn't get a response from the audio server.", flags: 64 }; if (response.loadType === "NO_MATCHES" || response.loadType === "LOAD_FAILED") return { content: "I couldn't find that song!", flags: 64 }; } catch (e) { logger.error(e); return { content: "🔊 Hmmm, seems that all of the audio servers are down. Try again in a bit.", flags: 64 }; } const oldQueue = queues.get(voiceChannel.guildID); if (!response.tracks || response.tracks.length === 0) return { content: "I couldn't find that song!", flags: 64 }; if (process.env.YT_DISABLED === "true" && response.tracks[0].info.sourceName === "youtube") return "YouTube playback is disabled on this instance."; if (music) { const sortedTracks = response.tracks.map((val) => { return val.track; }); const playlistTracks = response.playlistInfo.selectedTrack ? sortedTracks : [sortedTracks[0]]; queues.set(voiceChannel.guildID, oldQueue ? [...oldQueue, ...playlistTracks] : playlistTracks); } const playerMeta = players.get(options.channel.guildID); let player; if (node.players.has(voiceChannel.guildID)) { player = node.players.get(voiceChannel.guildID); } else if (playerMeta?.player) { const storedState = playerMeta?.player?.connection.state; if (storedState && storedState === 1) { player = playerMeta?.player; } } const connection = player ?? await node.joinChannel({ guildId: voiceChannel.guildID, channelId: voiceChannel.id, shardId: voiceChannel.guild.shard.id, deaf: true }); if (oldQueue?.length && music) { return `Your ${response.playlistInfo.name ? "playlist" : "tune"} \`${response.playlistInfo.name ? response.playlistInfo.name.trim() : (response.tracks[0].info.title !== "" ? response.tracks[0].info.title.trim() : "(blank)")}\` has been added to the queue!`; } else { nextSong(client, options, connection, response.tracks[0].track, response.tracks[0].info, music, voiceChannel, playerMeta?.host ?? options.member.id, playerMeta?.loop ?? false, playerMeta?.shuffle ?? false); return; } } export async function nextSong(client, options, connection, track, info, music, voiceChannel, host, loop = false, shuffle = false, lastTrack = null) { skipVotes.delete(voiceChannel.guildID); const parts = Math.floor((0 / info.length) * 10); let playingMessage; if (music && lastTrack === track && players.has(voiceChannel.guildID)) { playingMessage = players.get(voiceChannel.guildID).playMessage; } else { try { const content = !music ? { content: "🔊 Playing sound..." } : { embeds: [{ color: 16711680, author: { name: "Now Playing", iconURL: client.user.avatarURL() }, fields: [{ name: "ℹī¸ Title", value: info.title?.trim() !== "" ? info.title : "(blank)" }, { name: "🎤 Artist", value: info.author?.trim() !== "" ? info.author : "(blank)" }, { name: "đŸ’Ŧ Channel", value: voiceChannel.name }, { name: "🌐 Node", value: connection.node?.name ?? "Unknown" }, { name: `${"â–Ŧ".repeat(parts)}🔘${"â–Ŧ".repeat(10 - parts)}`, value: `0:00/${info.isStream ? "∞" : format(info.length)}` }] }] }; if (options.type === "classic") { playingMessage = await client.rest.channels.createMessage(options.channel.id, content); } else { if ((Date.now() - options.interaction.createdAt) >= 900000) { // discord interactions are only valid for 15 minutes playingMessage = await client.rest.channels.createMessage(options.channel.id, content); } else if (lastTrack && lastTrack !== track) { playingMessage = await options.interaction.createFollowup(content); } else { playingMessage = await options.interaction[options.interaction.acknowledged ? "editOriginal" : "createMessage"](content); if (!playingMessage) playingMessage = await options.interaction.getOriginal(); } } } catch { // no-op } } connection.removeAllListeners("exception"); connection.removeAllListeners("stuck"); connection.removeAllListeners("end"); connection.setVolume(0.70); connection.playTrack({ track }); players.set(voiceChannel.guildID, { player: connection, type: music ? "music" : "sound", host, voiceChannel, originalChannel: options.channel, loop, shuffle, playMessage: playingMessage }); connection.once("exception", (exception) => errHandle(exception, client, connection, playingMessage, voiceChannel, options)); connection.on("stuck", () => { const nodeName = manager.getNode().name; connection.move(nodeName); connection.resume(); }); connection.on("end", async (data) => { if (data.reason === "REPLACED") return; let queue = queues.get(voiceChannel.guildID); const player = players.get(voiceChannel.guildID); if (player && process.env.STAYVC === "true") { player.type = "idle"; players.set(voiceChannel.guildID, player); } let newQueue; if (player?.shuffle) { if (player.loop) { queue.push(queue.shift()); } else { queue = queue.slice(1); } queue.unshift(queue.splice(Math.floor(Math.random() * queue.length), 1)[0]); newQueue = queue; } else if (player?.loop) { queue.push(queue.shift()); newQueue = queue; } else { newQueue = queue ? queue.slice(1) : []; } queues.set(voiceChannel.guildID, newQueue); if (newQueue.length !== 0) { const newTrack = await connection.node.rest.decode(newQueue[0]); nextSong(client, options, connection, newQueue[0], newTrack, music, voiceChannel, host, player.loop, player.shuffle, track); try { if (options.type === "classic") { if (newQueue[0] !== track && playingMessage.channel.messages.has(playingMessage.id)) await playingMessage.delete(); if (newQueue[0] !== track && player.playMessage.channel.messages.has(player.playMessage.id)) await player.playMessage.delete(); } } catch { // no-op } } else if (process.env.STAYVC !== "true") { await setTimeout(400); connection.node.leaveChannel(voiceChannel.guildID); players.delete(voiceChannel.guildID); queues.delete(voiceChannel.guildID); skipVotes.delete(voiceChannel.guildID); try { const content = `🔊 The voice channel session in \`${voiceChannel.name}\` has ended.`; if (options.type === "classic") { await client.rest.channels.createMessage(options.channel.id, { content }); } else { if ((Date.now() - options.interaction.createdAt) >= 900000) { await client.rest.channels.createMessage(options.channel.id, { content }); } else { await options.interaction.createFollowup({ content }); } } } catch { // no-op } } if (options.type === "classic") { try { if (playingMessage.channel.messages.has(playingMessage.id)) await playingMessage.delete(); if (player?.playMessage.channel.messages.has(player.playMessage.id)) await player.playMessage.delete(); } catch { // no-op } } }); } export async function errHandle(exception, client, connection, playingMessage, voiceChannel, options, closed) { try { if (playingMessage.channel.messages.has(playingMessage.id)) await playingMessage.delete(); const playMessage = players.get(voiceChannel.guildID).playMessage; if (playMessage.channel.messages.has(playMessage.id)) await playMessage.delete(); } catch { // no-op } players.delete(voiceChannel.guildID); queues.delete(voiceChannel.guildID); skipVotes.delete(voiceChannel.guildID); logger.error(exception); try { connection.node.leaveChannel(voiceChannel.guildID); } catch { // no-op } connection.removeAllListeners("exception"); connection.removeAllListeners("stuck"); connection.removeAllListeners("end"); try { const content = closed ? `🔊 I got disconnected by Discord and tried to reconnect; however, I got this error instead:\n\`\`\`${exception}\`\`\`` : `🔊 Looks like there was an error regarding sound playback:\n\`\`\`${exception.type}: ${exception.error}\`\`\``; if (options.type === "classic") { await client.rest.channels.createMessage(playingMessage.channel.id, { content }); } else { if ((Date.now() - options.interaction.createdAt) >= 900000) { await client.rest.channels.createMessage(options.channel.id, { content }); } else { await options.interaction.createFollowup({ content }); } } } catch { // no-op } }