Apollo Initial Files

This commit is contained in:
2024-10-26 20:33:18 +08:00
commit 2d302551f9
82 changed files with 10643 additions and 0 deletions
+114
View File
@@ -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);
}
}
});
}
}
+356
View File
@@ -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);
}
});
}
}
+40
View File
@@ -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);
}
}
+80
View File
@@ -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 });
}
}