Apollo Initial Files
This commit is contained in:
+114
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
ApplicationCommandDataResolvable,
|
||||
ChatInputCommandInteraction,
|
||||
Client,
|
||||
Collection,
|
||||
Events,
|
||||
Interaction,
|
||||
REST,
|
||||
Routes,
|
||||
Snowflake
|
||||
} from "discord.js";
|
||||
import { readdirSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { Command } from "../interfaces/Command";
|
||||
import { checkPermissions, PermissionResult } from "../utils/checkPermissions";
|
||||
import { config } from "../utils/config";
|
||||
import { i18n } from "../utils/i18n";
|
||||
import { MissingPermissionsException } from "../utils/MissingPermissionsException";
|
||||
import { MusicQueue } from "./MusicQueue";
|
||||
|
||||
export class Bot {
|
||||
public readonly prefix = "/";
|
||||
public commands = new Collection<string, Command>();
|
||||
public slashCommands = new Array<ApplicationCommandDataResolvable>();
|
||||
public slashCommandsMap = new Collection<string, Command>();
|
||||
public cooldowns = new Collection<string, Collection<Snowflake, number>>();
|
||||
public queues = new Collection<Snowflake, MusicQueue>();
|
||||
|
||||
public constructor(public readonly client: Client) {
|
||||
this.client.login(config.TOKEN);
|
||||
|
||||
this.client.on("ready", () => {
|
||||
console.log(`${this.client.user!.username} ready!`);
|
||||
|
||||
this.registerSlashCommands();
|
||||
});
|
||||
|
||||
this.client.on("warn", (info) => console.log(info));
|
||||
this.client.on("error", console.error);
|
||||
|
||||
this.onInteractionCreate();
|
||||
}
|
||||
|
||||
private async registerSlashCommands() {
|
||||
const rest = new REST({ version: "9" }).setToken(config.TOKEN);
|
||||
|
||||
const commandFiles = readdirSync(join(__dirname, "..", "commands")).filter((file) => !file.endsWith(".map"));
|
||||
|
||||
for (const file of commandFiles) {
|
||||
const command = await import(join(__dirname, "..", "commands", `${file}`));
|
||||
|
||||
this.slashCommands.push(command.default.data);
|
||||
this.slashCommandsMap.set(command.default.data.name, command.default);
|
||||
}
|
||||
|
||||
await rest.put(Routes.applicationCommands(this.client.user!.id), { body: this.slashCommands });
|
||||
}
|
||||
|
||||
private async onInteractionCreate() {
|
||||
this.client.on(Events.InteractionCreate, async (interaction: Interaction): Promise<any> => {
|
||||
if (!interaction.isChatInputCommand()) return;
|
||||
|
||||
const command = this.slashCommandsMap.get(interaction.commandName);
|
||||
|
||||
if (!command) return;
|
||||
|
||||
if (!this.cooldowns.has(interaction.commandName)) {
|
||||
this.cooldowns.set(interaction.commandName, new Collection());
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const timestamps = this.cooldowns.get(interaction.commandName)!;
|
||||
const cooldownAmount = (command.cooldown || 1) * 1000;
|
||||
|
||||
const timestamp = timestamps.get(interaction.user.id);
|
||||
|
||||
if (timestamp) {
|
||||
const expirationTime = timestamp + cooldownAmount;
|
||||
|
||||
if (now < expirationTime) {
|
||||
const timeLeft = (expirationTime - now) / 1000;
|
||||
return interaction.reply({
|
||||
content: i18n.__mf("common.cooldownMessage", {
|
||||
time: timeLeft.toFixed(1),
|
||||
name: interaction.commandName
|
||||
}),
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
timestamps.set(interaction.user.id, now);
|
||||
setTimeout(() => timestamps.delete(interaction.user.id), cooldownAmount);
|
||||
|
||||
try {
|
||||
const permissionsCheck: PermissionResult = await checkPermissions(command, interaction);
|
||||
|
||||
if (permissionsCheck.result) {
|
||||
command.execute(interaction as ChatInputCommandInteraction);
|
||||
} else {
|
||||
throw new MissingPermissionsException(permissionsCheck.missing);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
|
||||
if (error.message.includes("permissions")) {
|
||||
interaction.reply({ content: error.toString(), ephemeral: true }).catch(console.error);
|
||||
} else {
|
||||
interaction.reply({ content: i18n.__("common.errorCommand"), ephemeral: true }).catch(console.error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import {
|
||||
AudioPlayer,
|
||||
AudioPlayerPlayingState,
|
||||
AudioPlayerState,
|
||||
AudioPlayerStatus,
|
||||
AudioResource,
|
||||
createAudioPlayer,
|
||||
entersState,
|
||||
NoSubscriberBehavior,
|
||||
VoiceConnection,
|
||||
VoiceConnectionDisconnectReason,
|
||||
VoiceConnectionState,
|
||||
VoiceConnectionStatus
|
||||
} from "@discordjs/voice";
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonInteraction,
|
||||
ButtonStyle,
|
||||
CommandInteraction,
|
||||
GuildMember,
|
||||
Interaction,
|
||||
Message,
|
||||
TextChannel
|
||||
} from "discord.js";
|
||||
import { promisify } from "node:util";
|
||||
import { bot } from "../index";
|
||||
import { QueueOptions } from "../interfaces/QueueOptions";
|
||||
import { config } from "../utils/config";
|
||||
import { i18n } from "../utils/i18n";
|
||||
import { canModifyQueue } from "../utils/queue";
|
||||
import { Song } from "./Song";
|
||||
import { safeReply } from "../utils/safeReply";
|
||||
|
||||
const wait = promisify(setTimeout);
|
||||
|
||||
export class MusicQueue {
|
||||
public readonly interaction: CommandInteraction;
|
||||
public readonly connection: VoiceConnection;
|
||||
public readonly player: AudioPlayer;
|
||||
public readonly textChannel: TextChannel;
|
||||
public readonly bot = bot;
|
||||
|
||||
public resource: AudioResource;
|
||||
public songs: Song[] = [];
|
||||
public volume = config.DEFAULT_VOLUME || 100;
|
||||
public loop = false;
|
||||
public muted = false;
|
||||
public waitTimeout: NodeJS.Timeout | null;
|
||||
private queueLock = false;
|
||||
private readyLock = false;
|
||||
private stopped = false;
|
||||
|
||||
/**
|
||||
* Constructs a new MusicQueue instance, setting up the audio player,
|
||||
* voice connection, and event listeners to manage voice state changes
|
||||
* and audio playback. It also handles network state changes to ensure
|
||||
* a stable connection for audio streaming.
|
||||
* @param options
|
||||
*/
|
||||
public constructor(options: QueueOptions) {
|
||||
Object.assign(this, options);
|
||||
|
||||
this.player = createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } });
|
||||
this.connection.subscribe(this.player);
|
||||
|
||||
const networkStateChangeHandler = (
|
||||
oldNetworkState: VoiceConnectionState,
|
||||
newNetworkState: VoiceConnectionState
|
||||
) => {
|
||||
const newUdp = Reflect.get(newNetworkState, "udp");
|
||||
clearInterval(newUdp?.keepAliveInterval);
|
||||
};
|
||||
|
||||
this.connection.on("stateChange", async (oldState: VoiceConnectionState, newState: VoiceConnectionState) => {
|
||||
Reflect.get(oldState, "networking")?.off("stateChange", networkStateChangeHandler);
|
||||
Reflect.get(newState, "networking")?.on("stateChange", networkStateChangeHandler);
|
||||
|
||||
if (newState.status === VoiceConnectionStatus.Disconnected) {
|
||||
if (newState.reason === VoiceConnectionDisconnectReason.WebSocketClose && newState.closeCode === 4014) {
|
||||
try {
|
||||
this.stop();
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
this.stop();
|
||||
}
|
||||
} else if (this.connection.rejoinAttempts < 5) {
|
||||
await wait((this.connection.rejoinAttempts + 1) * 5_000);
|
||||
this.connection.rejoin();
|
||||
} else {
|
||||
this.connection.destroy();
|
||||
}
|
||||
} else if (
|
||||
!this.readyLock &&
|
||||
(newState.status === VoiceConnectionStatus.Connecting || newState.status === VoiceConnectionStatus.Signalling)
|
||||
) {
|
||||
this.readyLock = true;
|
||||
try {
|
||||
await entersState(this.connection, VoiceConnectionStatus.Ready, 20_000);
|
||||
} catch {
|
||||
if (this.connection.state.status !== VoiceConnectionStatus.Destroyed) {
|
||||
try {
|
||||
this.connection.destroy();
|
||||
} catch {}
|
||||
}
|
||||
} finally {
|
||||
this.readyLock = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.player.on("stateChange", async (oldState: AudioPlayerState, newState: AudioPlayerState) => {
|
||||
if (oldState.status !== AudioPlayerStatus.Idle && newState.status === AudioPlayerStatus.Idle) {
|
||||
if (this.loop && this.songs.length) {
|
||||
this.songs.push(this.songs.shift()!);
|
||||
} else {
|
||||
this.songs.shift();
|
||||
if (!this.songs.length) return this.stop();
|
||||
}
|
||||
|
||||
if (this.songs.length || this.resource.audioPlayer) this.processQueue();
|
||||
} else if (oldState.status === AudioPlayerStatus.Buffering && newState.status === AudioPlayerStatus.Playing) {
|
||||
this.sendPlayingMessage(newState);
|
||||
}
|
||||
});
|
||||
|
||||
this.player.on("error", (error) => {
|
||||
console.error(error);
|
||||
|
||||
if (this.loop && this.songs.length) {
|
||||
this.songs.push(this.songs.shift()!);
|
||||
} else {
|
||||
this.songs.shift();
|
||||
}
|
||||
|
||||
this.processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
public enqueue(...songs: Song[]) {
|
||||
if (this.waitTimeout !== null) clearTimeout(this.waitTimeout);
|
||||
this.waitTimeout = null;
|
||||
this.stopped = false;
|
||||
this.songs = this.songs.concat(songs);
|
||||
this.processQueue();
|
||||
}
|
||||
|
||||
public stop() {
|
||||
if (this.stopped) return;
|
||||
|
||||
this.stopped = true;
|
||||
this.loop = false;
|
||||
this.songs = [];
|
||||
this.player.stop();
|
||||
|
||||
!config.PRUNING && this.textChannel.send(i18n.__("play.queueEnded")).catch(console.error);
|
||||
|
||||
if (this.waitTimeout !== null) return;
|
||||
|
||||
this.waitTimeout = setTimeout(() => {
|
||||
if (this.connection.state.status !== VoiceConnectionStatus.Destroyed) {
|
||||
try {
|
||||
this.connection.destroy();
|
||||
} catch {}
|
||||
}
|
||||
bot.queues.delete(this.interaction.guild!.id);
|
||||
|
||||
!config.PRUNING && this.textChannel.send(i18n.__("play.leaveChannel"));
|
||||
}, config.STAY_TIME * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the song queue for playback. This method checks if the queue is locked or if the player
|
||||
* is busy. If not, it proceeds to play the next song in the queue. This method is also responsible
|
||||
* for handling playback errors and retrying song playback when necessary. It ensures that the queue
|
||||
* continues to play smoothly, handling transitions between songs, including loop and stop behaviors.
|
||||
*/
|
||||
public async processQueue(): Promise<void> {
|
||||
if (this.queueLock || this.player.state.status !== AudioPlayerStatus.Idle) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.songs.length) {
|
||||
return this.stop();
|
||||
}
|
||||
|
||||
this.queueLock = true;
|
||||
|
||||
const next = this.songs[0];
|
||||
|
||||
try {
|
||||
const resource = await next.makeResource();
|
||||
|
||||
this.resource = resource!;
|
||||
this.player.play(this.resource);
|
||||
this.resource.volume?.setVolumeLogarithmic(this.volume / 100);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
return this.processQueue();
|
||||
} finally {
|
||||
this.queueLock = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSkip(interaction: ButtonInteraction): Promise<void> {
|
||||
await this.bot.slashCommandsMap.get("skip")!.execute(interaction);
|
||||
}
|
||||
|
||||
private async handlePlayPause(interaction: ButtonInteraction): Promise<void> {
|
||||
if (this.player.state.status === AudioPlayerStatus.Playing) {
|
||||
await this.bot.slashCommandsMap.get("pause")!.execute(interaction);
|
||||
} else {
|
||||
await this.bot.slashCommandsMap.get("resume")!.execute(interaction);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMute(interaction: ButtonInteraction): Promise<void> {
|
||||
if (!canModifyQueue(interaction.member as GuildMember)) return;
|
||||
|
||||
this.muted = !this.muted;
|
||||
|
||||
if (this.muted) {
|
||||
this.resource.volume?.setVolumeLogarithmic(0);
|
||||
|
||||
safeReply(interaction, i18n.__mf("play.mutedSong", { author: interaction.user })).catch(console.error);
|
||||
} else {
|
||||
this.resource.volume?.setVolumeLogarithmic(this.volume / 100);
|
||||
|
||||
safeReply(interaction, i18n.__mf("play.unmutedSong", { author: interaction.user })).catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleDecreaseVolume(interaction: ButtonInteraction): Promise<void> {
|
||||
if (this.volume == 0) return;
|
||||
|
||||
if (!canModifyQueue(interaction.member as GuildMember)) return;
|
||||
|
||||
this.volume = Math.max(this.volume - 10, 0);
|
||||
|
||||
this.resource.volume?.setVolumeLogarithmic(this.volume / 100);
|
||||
|
||||
safeReply(interaction, i18n.__mf("play.decreasedVolume", { author: interaction.user, volume: this.volume })).catch(
|
||||
console.error
|
||||
);
|
||||
}
|
||||
|
||||
private async handleIncreaseVolume(interaction: ButtonInteraction): Promise<void> {
|
||||
if (this.volume == 100) return;
|
||||
|
||||
if (!canModifyQueue(interaction.member as GuildMember)) return;
|
||||
|
||||
this.volume = Math.min(this.volume + 10, 100);
|
||||
|
||||
this.resource.volume?.setVolumeLogarithmic(this.volume / 100);
|
||||
|
||||
safeReply(interaction, i18n.__mf("play.increasedVolume", { author: interaction.user, volume: this.volume })).catch(
|
||||
console.error
|
||||
);
|
||||
}
|
||||
|
||||
private async handleLoop(interaction: ButtonInteraction): Promise<void> {
|
||||
await this.bot.slashCommandsMap.get("loop")!.execute(interaction);
|
||||
}
|
||||
|
||||
private async handleShuffle(interaction: ButtonInteraction): Promise<void> {
|
||||
await this.bot.slashCommandsMap.get("shuffle")!.execute(interaction);
|
||||
}
|
||||
|
||||
private async handleStop(interaction: ButtonInteraction): Promise<void> {
|
||||
await this.bot.slashCommandsMap.get("stop")!.execute(interaction);
|
||||
}
|
||||
|
||||
private commandHandlers = new Map([
|
||||
["skip", this.handleSkip],
|
||||
["play_pause", this.handlePlayPause],
|
||||
["mute", this.handleMute],
|
||||
["decrease_volume", this.handleDecreaseVolume],
|
||||
["increase_volume", this.handleIncreaseVolume],
|
||||
["loop", this.handleLoop],
|
||||
["shuffle", this.handleShuffle],
|
||||
["stop", this.handleStop]
|
||||
]);
|
||||
|
||||
private createButtonRow() {
|
||||
const firstRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder().setCustomId("skip").setLabel("⏭").setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder().setCustomId("play_pause").setLabel("⏯").setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder().setCustomId("mute").setLabel("🔇").setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder().setCustomId("decrease_volume").setLabel("🔉").setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder().setCustomId("increase_volume").setLabel("🔊").setStyle(ButtonStyle.Secondary)
|
||||
);
|
||||
const secondRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder().setCustomId("loop").setLabel("🔁").setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder().setCustomId("shuffle").setLabel("🔀").setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder().setCustomId("stop").setLabel("⏹").setStyle(ButtonStyle.Secondary)
|
||||
);
|
||||
|
||||
return [firstRow, secondRow];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a message component collector for the playing message to handle
|
||||
* button interactions. This collector listens for button clicks and dispatches
|
||||
* commands based on the custom ID of the clicked button. It supports functionalities
|
||||
* like skip, stop, play/pause, volume control, and more. The collector is also
|
||||
* responsible for stopping itself when the corresponding song is skipped or stopped,
|
||||
* ensuring that interactions are only valid for the current playing song.
|
||||
*/
|
||||
private async sendPlayingMessage(newState: AudioPlayerPlayingState) {
|
||||
const song = (newState.resource as AudioResource<Song>).metadata;
|
||||
|
||||
let playingMessage: Message;
|
||||
|
||||
try {
|
||||
playingMessage = await this.textChannel.send({
|
||||
content: song.startMessage(),
|
||||
components: this.createButtonRow()
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
if (error instanceof Error) this.textChannel.send(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const filter = (i: Interaction) => i.isButton() && i.message.id === playingMessage.id;
|
||||
|
||||
const collector = playingMessage.createMessageComponentCollector({
|
||||
filter,
|
||||
time: song.duration > 0 ? song.duration * 1000 : 60000
|
||||
});
|
||||
|
||||
collector.on("collect", async (interaction) => {
|
||||
if (!interaction.isButton()) return;
|
||||
if (!this.songs) return;
|
||||
|
||||
const handler = this.commandHandlers.get(interaction.customId);
|
||||
|
||||
if (["skip", "stop"].includes(interaction.customId)) collector.stop();
|
||||
|
||||
if (handler) await handler.call(this, interaction);
|
||||
});
|
||||
|
||||
collector.on("end", () => {
|
||||
// Remove the buttons when the song ends
|
||||
playingMessage.edit({ components: [] }).catch(console.error);
|
||||
|
||||
// Delete the message if pruning is enabled
|
||||
if (config.PRUNING) {
|
||||
setTimeout(() => {
|
||||
playingMessage.delete().catch();
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import youtube, { Playlist as YoutubePlaylist } from "youtube-sr";
|
||||
import { config } from "../utils/config";
|
||||
import { Song } from "./Song";
|
||||
|
||||
const pattern = /^.*(youtu.be\/|list=)([^#\&\?]*).*/i;
|
||||
|
||||
export class Playlist {
|
||||
public data: YoutubePlaylist;
|
||||
public videos: Song[];
|
||||
|
||||
public constructor(playlist: YoutubePlaylist) {
|
||||
this.data = playlist;
|
||||
|
||||
this.videos = this.data.videos
|
||||
.filter((video) => video.title != "Private video" && video.title != "Deleted video")
|
||||
.slice(0, config.MAX_PLAYLIST_SIZE - 1)
|
||||
.map((video) => {
|
||||
return new Song({
|
||||
title: video.title!,
|
||||
url: `https://youtube.com/watch?v=${video.id}`,
|
||||
duration: video.duration / 1000
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static async from(url: string = "", search: string = "") {
|
||||
const urlValid = pattern.test(url);
|
||||
let playlist;
|
||||
|
||||
if (urlValid) {
|
||||
playlist = await youtube.getPlaylist(url);
|
||||
} else {
|
||||
const result = await youtube.searchOne(search, "playlist");
|
||||
|
||||
playlist = await youtube.getPlaylist(result.url!);
|
||||
}
|
||||
|
||||
return new this(playlist);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { AudioResource, createAudioResource, StreamType } from "@discordjs/voice";
|
||||
import youtube from "youtube-sr";
|
||||
import { i18n } from "../utils/i18n";
|
||||
import { videoPattern, isURL } from "../utils/patterns";
|
||||
|
||||
const { stream, video_basic_info } = require("play-dl");
|
||||
|
||||
export interface SongData {
|
||||
url: string;
|
||||
title: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export class Song {
|
||||
public readonly url: string;
|
||||
public readonly title: string;
|
||||
public readonly duration: number;
|
||||
|
||||
public constructor({ url, title, duration }: SongData) {
|
||||
this.url = url;
|
||||
this.title = title;
|
||||
this.duration = duration;
|
||||
}
|
||||
|
||||
public static async from(url: string = "", search: string = "") {
|
||||
const isYoutubeUrl = videoPattern.test(url);
|
||||
|
||||
let songInfo;
|
||||
|
||||
if (isYoutubeUrl) {
|
||||
songInfo = await video_basic_info(url);
|
||||
|
||||
return new this({
|
||||
url: songInfo.video_details.url,
|
||||
title: songInfo.video_details.title,
|
||||
duration: parseInt(songInfo.video_details.durationInSec)
|
||||
});
|
||||
} else {
|
||||
const result = await youtube.searchOne(search);
|
||||
|
||||
result ? null : console.log(`No results found for ${search}`);
|
||||
|
||||
if (!result) {
|
||||
let err = new Error(`No search results found for ${search}`);
|
||||
|
||||
err.name = "NoResults";
|
||||
|
||||
if (isURL.test(url)) err.name = "InvalidURL";
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
songInfo = await video_basic_info(`https://youtube.com/watch?v=${result.id}`);
|
||||
|
||||
return new this({
|
||||
url: songInfo.video_details.url,
|
||||
title: songInfo.video_details.title,
|
||||
duration: parseInt(songInfo.video_details.durationInSec)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async makeResource(): Promise<AudioResource<Song> | void> {
|
||||
let playStream;
|
||||
|
||||
const source = this.url.includes("youtube") ? "youtube" : "soundcloud";
|
||||
|
||||
if (source === "youtube") {
|
||||
playStream = await stream(this.url);
|
||||
}
|
||||
|
||||
if (!stream) return;
|
||||
|
||||
return createAudioResource(playStream.stream, { metadata: this, inputType: playStream.type, inlineVolume: true });
|
||||
}
|
||||
|
||||
public startMessage() {
|
||||
return i18n.__mf("play.startedPlaying", { title: this.title, url: this.url });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user