initial commit: fork

This commit is contained in:
2023-10-27 10:31:20 +02:00
commit a52dbbc103
195 changed files with 17484 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { env } from '../../env';
@ApplyOptions<Command.Options>({
name: 'amongus',
description: 'Replies with a random Among Us gif!',
preconditions: ['isCommandDisabled']
})
export class AmongUsCommand extends Command {
public override registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand(builder =>
builder.setName(this.name).setDescription(this.description)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
try {
const response = await fetch(
`https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=amongus&limit=1&random=true`
);
const json = await response.json();
if (!json.results)
return await interaction.reply({
content: 'Something went wrong! Please try again later.'
});
return await interaction.reply({ content: json.results[0].url });
} catch (e) {
return await interaction.reply({
content: 'Something went wrong! Please try again later.'
});
}
}
}

View File

@@ -0,0 +1,37 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { env } from '../../env';
@ApplyOptions<Command.Options>({
name: 'anime',
description: 'Replies with a random anime gif!',
preconditions: ['isCommandDisabled']
})
export class AnimeCommand extends Command {
public override registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand(builder =>
builder.setName(this.name).setDescription(this.description)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
try {
const response = await fetch(
`https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=anime&limit=1&random=true`
);
const json = await response.json();
if (!json.results)
return await interaction.reply({
content: 'Something went wrong! Please try again later.'
});
return await interaction.reply({ content: json.results[0].url });
} catch (e) {
return await interaction.reply({
content: 'Something went wrong! Please try again later.'
});
}
}
}

View File

@@ -0,0 +1,37 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { env } from '../../env';
@ApplyOptions<Command.Options>({
name: 'baka',
description: 'Replies with a random baka gif!',
preconditions: ['isCommandDisabled']
})
export class BakaCommand extends Command {
public override registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand(builder =>
builder.setName(this.name).setDescription(this.description)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
try {
const response = await fetch(
`https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=baka&limit=1&random=true`
);
const json = await response.json();
if (!json.results)
return await interaction.reply({
content: 'Something went wrong! Please try again later.'
});
return await interaction.reply({ content: json.results[0].url });
} catch (e) {
return await interaction.reply({
content: 'Something went wrong! Please try again later.'
});
}
}
}

View File

@@ -0,0 +1,37 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { env } from '../../env';
@ApplyOptions<Command.Options>({
name: 'cat',
description: 'Replies with a random cat gif!',
preconditions: ['isCommandDisabled']
})
export class CatCommand extends Command {
public override registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand(builder =>
builder.setName(this.name).setDescription(this.description)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
try {
const response = await fetch(
`https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=cat&limit=1&random=true`
);
const json = await response.json();
if (!json.results)
return await interaction.reply({
content: 'Something went wrong! Please try again later.'
});
return await interaction.reply({ content: json.results[0].url });
} catch (e) {
return await interaction.reply({
content: 'Something went wrong! Please try again later.'
});
}
}
}

View File

@@ -0,0 +1,37 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { env } from '../../env';
@ApplyOptions<Command.Options>({
name: 'doggo',
description: 'Replies with a random doggo gif!',
preconditions: ['isCommandDisabled']
})
export class DoggoCommand extends Command {
public override registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand(builder =>
builder.setName(this.name).setDescription(this.description)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
try {
const response = await fetch(
`https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=doggo&limit=1&random=true`
);
const json = await response.json();
if (!json.results)
return await interaction.reply({
content: 'Something went wrong! Please try again later.'
});
return await interaction.reply({ content: json.results[0].url });
} catch (e) {
return await interaction.reply({
content: 'Something went wrong! Please try again later.'
});
}
}
}

View File

@@ -0,0 +1,37 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { env } from '../../env';
@ApplyOptions<Command.Options>({
name: 'gif',
description: 'Replies with a random gif gif!',
preconditions: ['isCommandDisabled']
})
export class GifCommand extends Command {
public override registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand(builder =>
builder.setName(this.name).setDescription(this.description)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
try {
const response = await fetch(
`https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=gif&limit=1&random=true`
);
const json = await response.json();
if (!json.results)
return await interaction.reply({
content: 'Something went wrong! Please try again later.'
});
return await interaction.reply({ content: json.results[0].url });
} catch (e) {
return await interaction.reply({
content: 'Something went wrong! Please try again later.'
});
}
}
}

View File

@@ -0,0 +1,37 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { env } from '../../env';
@ApplyOptions<Command.Options>({
name: 'gintama',
description: 'Replies with a random gintama gif!',
preconditions: ['isCommandDisabled']
})
export class GintamaCommand extends Command {
public override registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand(builder =>
builder.setName(this.name).setDescription(this.description)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
try {
const response = await fetch(
`https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=gintama&limit=1&random=true`
);
const json = await response.json();
if (!json.results)
return await interaction.reply({
content: 'Something went wrong! Please try again later.'
});
return await interaction.reply({ content: json.results[0].url });
} catch (e) {
return await interaction.reply({
content: 'Something went wrong! Please try again later.'
});
}
}
}

View File

@@ -0,0 +1,37 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { env } from '../../env';
@ApplyOptions<Command.Options>({
name: 'hug',
description: 'Replies with a random hug gif!',
preconditions: ['isCommandDisabled']
})
export class HugCommand extends Command {
public override registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand(builder =>
builder.setName(this.name).setDescription(this.description)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
try {
const response = await fetch(
`https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=hug&limit=1&random=true`
);
const json = await response.json();
if (!json.results)
return await interaction.reply({
content: 'Something went wrong! Please try again later.'
});
return await interaction.reply({ content: json.results[0].url });
} catch (e) {
return await interaction.reply({
content: 'Something went wrong! Please try again later.'
});
}
}
}

View File

@@ -0,0 +1,37 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { env } from '../../env';
@ApplyOptions<Command.Options>({
name: 'jojo',
description: 'Replies with a random jojo gif!',
preconditions: ['isCommandDisabled']
})
export class JojoCommand extends Command {
public override registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand(builder =>
builder.setName(this.name).setDescription(this.description)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
try {
const response = await fetch(
`https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=jojo&limit=1&random=true`
);
const json = await response.json();
if (!json.results)
return await interaction.reply({
content: 'Something went wrong! Please try again later.'
});
return await interaction.reply({ content: json.results[0].url });
} catch (e) {
return await interaction.reply({
content: 'Something went wrong! Please try again later.'
});
}
}
}

View File

@@ -0,0 +1,37 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { env } from '../../env';
@ApplyOptions<Command.Options>({
name: 'slap',
description: 'Replies with a random slap gif!',
preconditions: ['isCommandDisabled']
})
export class SlapCommand extends Command {
public override registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand(builder =>
builder.setName(this.name).setDescription(this.description)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
try {
const response = await fetch(
`https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=slap&limit=1&random=true`
);
const json = await response.json();
if (!json.results)
return await interaction.reply({
content: 'Something went wrong! Please try again later.'
});
return await interaction.reply({ content: json.results[0].url });
} catch (e) {
return await interaction.reply({
content: 'Something went wrong! Please try again later.'
});
}
}
}

View File

@@ -0,0 +1,37 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { env } from '../../env';
@ApplyOptions<Command.Options>({
name: 'waifu',
description: 'Replies with a random waifu gif!',
preconditions: ['isCommandDisabled']
})
export class WaifuCommand extends Command {
public override registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand(builder =>
builder.setName(this.name).setDescription(this.description)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
try {
const response = await fetch(
`https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=waifu&limit=1&random=true`
);
const json = await response.json();
if (!json.results)
return await interaction.reply({
content: 'Something went wrong! Please try again later.'
});
return await interaction.reply({ content: json.results[0].url });
} catch (e) {
return await interaction.reply({
content: 'Something went wrong! Please try again later.'
});
}
}
}

View File

@@ -0,0 +1,52 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { container } from '@sapphire/framework';
import type { Node, Player } from 'lavaclient';
@ApplyOptions<CommandOptions>({
name: 'bassboost',
description: 'Boost the bass of the playing track',
preconditions: [
'GuildOnly',
'isCommandDisabled',
'inVoiceChannel',
'playerIsPlaying',
'inPlayerVoiceChannel'
]
})
export class BassboostCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand({
name: this.name,
description: this.description
});
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const { client } = container;
const player = client.music.players.get(
interaction.guild!.id
) as Player<Node>;
player.filters.equalizer = (player.bassboost = !player.bassboost)
? [
{ band: 0, gain: 0.55 },
{ band: 1, gain: 0.45 },
{ band: 2, gain: 0.4 },
{ band: 3, gain: 0.3 },
{ band: 4, gain: 0.15 },
{ band: 5, gain: 0 },
{ band: 6, gain: 0 }
]
: undefined;
await player.setFilters();
return await interaction.reply(
`Bassboost ${player.bassboost ? 'enabled' : 'disabled'}`
);
}
}

View File

@@ -0,0 +1,63 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { trpcNode } from '../../trpc';
@ApplyOptions<CommandOptions>({
name: 'create-playlist',
description: 'Create a custom playlist that you can play anytime',
preconditions: [
'GuildOnly',
'isCommandDisabled',
'userInDB',
'playlistNotDuplicate'
]
})
export class CreatePlaylistCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand(builder =>
builder
.setName(this.name)
.setDescription(this.description)
.addStringOption(option =>
option
.setName('playlist-name')
.setDescription(
'What is the name of the playlist you want to create?'
)
.setRequired(true)
)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const playlistName = interaction.options.getString('playlist-name', true);
const interactionMember = interaction.member?.user;
if (!interactionMember) {
return await interaction.reply({
content: ':x: Something went wrong! Please try again later'
});
}
try {
const playlist = await trpcNode.playlist.create.mutate({
name: playlistName,
userId: interactionMember.id
});
if (!playlist) throw new Error();
} catch (error) {
await interaction.reply({
content: `:x: You already have a playlist named **${playlistName}**`
});
return;
}
await interaction.reply(`Created a playlist named **${playlistName}**`);
return;
}
}

View File

@@ -0,0 +1,65 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { trpcNode } from '../../trpc';
import Logger from '../../lib/logger';
@ApplyOptions<CommandOptions>({
name: 'delete-playlist',
description: 'Delete a playlist from your saved playlists',
preconditions: [
'GuildOnly',
'isCommandDisabled',
'userInDB',
'playlistExists'
]
})
export class DeletePlaylistCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand(builder =>
builder
.setName(this.name)
.setDescription(this.description)
.addStringOption(option =>
option
.setName('playlist-name')
.setDescription(
'What is the name of the playlist you want to delete?'
)
.setRequired(true)
)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const playlistName = interaction.options.getString('playlist-name', true);
const interactionMember = interaction.member?.user;
if (!interactionMember) {
return await interaction.reply(
':x: Something went wrong! Please try again later'
);
}
try {
const playlist = await trpcNode.playlist.delete.mutate({
name: playlistName,
userId: interactionMember.id
});
if (!playlist) throw new Error();
} catch (error) {
console.log(error);
Logger.error(error);
return await interaction.reply(
':x: Something went wrong! Please try again later'
);
}
return await interaction.reply(`:wastebasket: Deleted **${playlistName}**`);
}
}

View File

@@ -0,0 +1,78 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { EmbedBuilder } from 'discord.js';
import { PaginatedFieldMessageEmbed } from '@sapphire/discord.js-utilities';
import { trpcNode } from '../../trpc';
@ApplyOptions<CommandOptions>({
name: 'display-playlist',
description: 'Display a saved playlist',
preconditions: [
'GuildOnly',
'isCommandDisabled',
'userInDB',
'playlistExists'
]
})
export class DisplayPlaylistCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand(builder =>
builder
.setName(this.name)
.setDescription(this.description)
.addStringOption(option =>
option
.setName('playlist-name')
.setDescription(
'What is the name of the playlist you want to display?'
)
.setRequired(true)
)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const playlistName = interaction.options.getString('playlist-name', true);
const interactionMember = interaction.member?.user;
if (!interactionMember) {
return await interaction.reply({
content: ':x: Something went wrong! Please try again later'
});
}
const playlistQuery = await trpcNode.playlist.getPlaylist.query({
name: playlistName,
userId: interactionMember.id
});
const { playlist } = playlistQuery;
if (!playlist) {
return await interaction.reply(
':x: Something went wrong! Please try again soon'
);
}
const baseEmbed = new EmbedBuilder().setColor('Purple').setAuthor({
name: interactionMember.username,
iconURL: interactionMember.avatar || undefined
});
new PaginatedFieldMessageEmbed()
.setTitleField(`${playlistName} - Songs`)
.setTemplate(baseEmbed)
.setItems(playlist.songs)
.formatItems((item: any) => `[${item.title}](${item.uri})`)
.setItemsPerPage(5)
.make()
.run(interaction);
return;
}
}

View File

@@ -0,0 +1,50 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { container } from '@sapphire/framework';
import type { Node, Player } from 'lavaclient';
@ApplyOptions<CommandOptions>({
name: 'karaoke',
description: 'Turn the playing track to karaoke',
preconditions: [
'GuildOnly',
'isCommandDisabled',
'inVoiceChannel',
'playerIsPlaying',
'inPlayerVoiceChannel'
]
})
export class KaraokeCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand({
name: this.name,
description: this.description
});
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const { client } = container;
const player = client.music.players.get(
interaction.guild!.id
) as Player<Node>;
player.filters.karaoke = (player.karaoke = !player.karaoke)
? {
level: 1,
monoLevel: 1,
filterBand: 220,
filterWidth: 100
}
: undefined;
await player.setFilters();
return await interaction.reply(
`Karaoke ${player.karaoke ? 'enabled' : 'disabled'}`
);
}
}

View File

@@ -0,0 +1,37 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { container } from '@sapphire/framework';
@ApplyOptions<CommandOptions>({
name: 'leave',
description: 'Make the bot leave its voice channel and stop playing music',
preconditions: [
'GuildOnly',
'isCommandDisabled',
'inVoiceChannel',
'playerIsPlaying',
'inPlayerVoiceChannel'
]
})
export class LeaveCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand({
name: this.name,
description: this.description
});
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const { client } = container;
const queue = client.music.queues.get(interaction.guildId!);
await queue.leave();
await interaction.reply({ content: 'Left the voice channel.' });
}
}

View File

@@ -0,0 +1,83 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { EmbedBuilder } from 'discord.js';
import { container } from '@sapphire/framework';
import { GeniusLyrics } from 'genius-discord-lyrics';
import { PaginatedMessage } from '@sapphire/discord.js-utilities';
import Logger from '../../lib/logger';
const genius = new GeniusLyrics(process.env.GENIUS_API || '');
@ApplyOptions<CommandOptions>({
name: 'lyrics',
description:
'Get the lyrics of any song or the lyrics of the currently playing song!',
preconditions: ['GuildOnly', 'isCommandDisabled']
})
export class LyricsCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand(builder =>
builder
.setName(this.name)
.setDescription(this.description)
.addStringOption(option =>
option
.setName('title')
.setDescription(':mag: What song lyrics would you like to get?')
.setRequired(true)
)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const { client } = container;
let title = interaction.options.getString('title');
const player = client.music.players.get(interaction.guild!.id);
await interaction.deferReply();
if (!title) {
if (!player) {
return await interaction.followUp(
'Please provide a valid song name or start playing one and try again!'
);
}
//title = player.queue.current?.title as string;
title = 'hi';
}
try {
const lyrics = (await genius.fetchLyrics(title)) as string;
const lyricsIndex = Math.round(lyrics.length / 4096) + 1;
const paginatedLyrics = new PaginatedMessage({
template: new EmbedBuilder().setColor('Red').setTitle(title).setFooter({
text: 'Provided by genius.com',
iconURL:
'https://assets.genius.com/images/apple-touch-icon.png?1652977688' // Genius Lyrics Icon
})
});
for (let i = 1; i <= lyricsIndex; ++i) {
let b = i - 1;
if (lyrics.trim().slice(b * 4096, i * 4096).length !== 0) {
paginatedLyrics.addPageEmbed(embed => {
return embed.setDescription(lyrics.slice(b * 4096, i * 4096));
});
}
}
await interaction.followUp('Lyrics generated');
return paginatedLyrics.run(interaction);
} catch (e) {
Logger.error(e);
return interaction.followUp(
'Something when wrong when trying to fetch lyrics :('
);
}
}
}

View File

@@ -0,0 +1,70 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { container } from '@sapphire/framework';
@ApplyOptions<CommandOptions>({
name: 'move',
description: 'Move a track to a different position in queue',
preconditions: [
'GuildOnly',
'isCommandDisabled',
'inVoiceChannel',
'playerIsPlaying',
'inPlayerVoiceChannel'
]
})
export class MoveCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand(builder =>
builder
.setName(this.name)
.setDescription(this.description)
.addIntegerOption(option =>
option
.setName('current-position')
.setDescription(
'What is the position of the song you want to move?'
)
.setRequired(true)
)
.addIntegerOption(option =>
option
.setName('new-position')
.setDescription(
'What is the position you want to move the song to?'
)
.setRequired(true)
)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const { client } = container;
const currentPosition = interaction.options.getInteger(
'current-position',
true
);
const newPosition = interaction.options.getInteger('new-position', true);
const queue = client.music.queues.get(interaction.guildId!);
const length = await queue.count();
if (
currentPosition < 1 ||
currentPosition > length ||
newPosition < 1 ||
newPosition > length ||
currentPosition == newPosition
) {
return await interaction.reply(
':x: Please enter valid position numbers!'
);
}
await queue.moveTracks(currentPosition - 1, newPosition - 1);
return;
}
}

View File

@@ -0,0 +1,62 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { PaginatedFieldMessageEmbed } from '@sapphire/discord.js-utilities';
import { EmbedBuilder } from 'discord.js';
import { trpcNode } from '../../trpc';
@ApplyOptions<CommandOptions>({
name: 'my-playlists',
description: "Display your custom playlists' names",
preconditions: [
'GuildOnly',
'isCommandDisabled',
'inVoiceChannel',
'userInDB'
]
})
export class MyPlaylistsCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand({
name: this.name,
description: this.description
});
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const interactionMember = interaction.member?.user;
if (!interactionMember) {
return await interaction.reply({
content: ':x: Something went wrong! Please try again later'
});
}
const baseEmbed = new EmbedBuilder().setColor('Purple').setAuthor({
name: `${interactionMember.username}`,
iconURL: interactionMember.avatar || undefined
});
const playlistsQuery = await trpcNode.playlist.getAll.query({
userId: interactionMember.id
});
if (!playlistsQuery || !playlistsQuery.playlists.length) {
return await interaction.reply(':x: You have no custom playlists');
}
new PaginatedFieldMessageEmbed()
.setTitleField('Custom Playlists')
.setTemplate(baseEmbed)
.setItems(playlistsQuery.playlists)
.formatItems((playlist: any) => playlist.name)
.setItemsPerPage(5)
.make()
.run(interaction);
return;
}
}

View File

@@ -0,0 +1,45 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { container } from '@sapphire/framework';
import type { Node, Player } from 'lavaclient';
@ApplyOptions<CommandOptions>({
name: 'nightcore',
description: 'Enable/Disable Nightcore filter',
preconditions: [
'GuildOnly',
'isCommandDisabled',
'inVoiceChannel',
'playerIsPlaying',
'inPlayerVoiceChannel'
]
})
export class NightcoreCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand({
name: this.name,
description: this.description
});
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const { client } = container;
const player = client.music.players.get(
interaction.guild!.id
) as Player<Node>;
player.filters.timescale = (player.nightcore = !player.nightcore)
? { speed: 1.125, pitch: 1.125, rate: 1 }
: undefined;
await player.setFilters();
return await interaction.reply(
`Nightcore ${player.nightcore ? 'enabled' : 'disabled'}`
);
}
}

View File

@@ -0,0 +1,35 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { container } from '@sapphire/framework';
@ApplyOptions<CommandOptions>({
name: 'pause',
description: 'Pause the music',
preconditions: [
'GuildOnly',
'isCommandDisabled',
'inVoiceChannel',
'playerIsPlaying',
'inPlayerVoiceChannel'
]
})
export class PauseCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand({
name: this.name,
description: this.description
});
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const { client } = container;
const queue = client.music.queues.get(interaction.guildId!);
await queue.pause(interaction);
}
}

View File

@@ -0,0 +1,157 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { container } from '@sapphire/framework';
import searchSong from '../../lib/music/searchSong';
import type { Song } from '../../lib/music/classes/Song';
import { trpcNode } from '../../trpc';
import { GuildMember } from 'discord.js';
@ApplyOptions<CommandOptions>({
name: 'play',
description: 'Play any song or playlist from YouTube, Spotify and more!',
preconditions: [
'GuildOnly',
'isCommandDisabled',
'inVoiceChannel',
'inPlayerVoiceChannel'
]
})
export class PlayCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand(builder =>
builder
.setName(this.name)
.setDescription(this.description)
.addStringOption(option =>
option
.setName('query')
.setDescription(
'What song or playlist would you like to listen to?'
)
.setRequired(true)
)
.addStringOption(option =>
option
.setName('is-custom-playlist')
.setDescription('Is it a custom playlist?')
.addChoices(
{
name: 'Yes',
value: 'Yes'
},
{
name: 'No',
value: 'No'
}
)
)
.addStringOption(option =>
option
.setName('shuffle-playlist')
.setDescription('Would you like to shuffle the playlist?')
.addChoices(
{
name: 'Yes',
value: 'Yes'
},
{
name: 'No',
value: 'No'
}
)
)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
await interaction.deferReply();
const { client } = container;
const query = interaction.options.getString('query', true);
const isCustomPlaylist =
interaction.options.getString('is-custom-playlist');
const shufflePlaylist = interaction.options.getString('shuffle-playlist');
const interactionMember = interaction.member?.user;
if (!interactionMember) {
return await interaction.followUp(
':x: Something went wrong! Please try again later'
);
}
const { music } = client;
const voiceChannel = (interaction.member as GuildMember).voice.channel;
// edge case - someome initiated the command but left the voice channel
if (!voiceChannel) {
return interaction.followUp({
content: ':x: You need to be in a voice channel to use this command!'
});
}
let queue = music.queues.get(interaction.guildId!);
await queue.setTextChannelID(interaction.channel!.id);
if (!queue.player) {
const player = queue.createPlayer();
await player.connect(voiceChannel.id, { deafened: true });
}
let tracks: Song[] = [];
let message: string = '';
if (isCustomPlaylist == 'Yes') {
const data = await trpcNode.playlist.getPlaylist.query({
userId: interactionMember.id,
name: query
});
const { playlist } = data;
if (!playlist) {
return await interaction.followUp(`:x: You have no such playlist!`);
}
if (!playlist.songs.length) {
return await interaction.followUp(`:x: **${query}** is empty!`);
}
const { songs } = playlist;
tracks.push(...songs);
message = `Added songs from **${playlist}** to the queue!`;
} else {
const trackTuple = await searchSong(query, interaction.user);
if (!trackTuple[1].length) {
return await interaction.followUp({ content: trackTuple[0] as string }); // error
}
message = trackTuple[0];
tracks.push(...trackTuple[1]);
}
await queue.add(tracks);
if (shufflePlaylist == 'Yes') {
await queue.shuffleTracks();
}
const current = await queue.getCurrentTrack();
if (current) {
client.emit(
'musicSongPlayMessage',
interaction.channel,
await queue.getCurrentTrack()
);
return;
}
queue.start();
return await interaction.followUp({ content: message });
}
}

View File

@@ -0,0 +1,50 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { EmbedBuilder } from 'discord.js';
import { container } from '@sapphire/framework';
import { PaginatedFieldMessageEmbed } from '@sapphire/discord.js-utilities';
@ApplyOptions<CommandOptions>({
name: 'queue',
description: 'Get a List of the Music Queue',
preconditions: [
'GuildOnly',
'isCommandDisabled',
'inVoiceChannel',
'playerIsPlaying',
'inPlayerVoiceChannel'
]
})
export class QueueCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand({
name: this.name,
description: this.description
});
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const { client } = container;
const queue = client.music.queues.get(interaction.guildId!);
const baseEmbed = new EmbedBuilder().setColor('Red').setAuthor({
name: `${interaction.user.username}`,
iconURL: interaction.user.displayAvatarURL()
});
let index = 1;
new PaginatedFieldMessageEmbed()
.setTitleField('Queue')
.setTemplate(baseEmbed)
.setItems(await queue.tracks())
.formatItems(
(queueList: any) =>
`${index++}) ***[${queueList.title}](${queueList.uri})***`
)
.setItemsPerPage(10)
.make()
.run(interaction);
}
}

View File

@@ -0,0 +1,94 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { trpcNode } from '../../trpc';
@ApplyOptions<CommandOptions>({
name: 'remove-from-playlist',
description: 'Remove a song from a saved playlist',
preconditions: [
'GuildOnly',
'isCommandDisabled',
'userInDB',
'playlistExists'
]
})
export class RemoveFromPlaylistCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand(builder =>
builder
.setName(this.name)
.setDescription(this.description)
.addStringOption(option =>
option
.setName('playlist-name')
.setDescription(
'What is the name of the playlist you want to remove from?'
)
.setRequired(true)
)
.addIntegerOption(option =>
option
.setName('location')
.setDescription(
'What is the index of the video you would like to delete from your saved playlist?'
)
.setRequired(true)
)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
await interaction.deferReply();
const playlistName = interaction.options.getString('playlist-name', true);
const location = interaction.options.getInteger('location', true);
const interactionMember = interaction.member?.user;
if (!interactionMember) {
return await interaction.followUp(
':x: Something went wrong! Please try again later'
);
}
let playlist;
try {
const playlistQuery = await trpcNode.playlist.getPlaylist.query({
name: playlistName,
userId: interactionMember.id
});
playlist = playlistQuery.playlist;
} catch (error) {
return await interaction.followUp(':x: Something went wrong!');
}
const songs = playlist?.songs;
if (!songs?.length) {
return await interaction.followUp(`:x: **${playlistName}** is empty!`);
}
if (location > songs.length || location < 0) {
return await interaction.followUp(':x: Please enter a valid index!');
}
const id = songs[location - 1].id;
const song = await trpcNode.song.delete.mutate({
id
});
if (!song) {
return await interaction.followUp(':x: Something went wrong!');
}
await interaction.followUp(
`:wastebasket: Deleted **${song.song.title}** from **${playlistName}**`
);
return;
}
}

View File

@@ -0,0 +1,52 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { container } from '@sapphire/framework';
@ApplyOptions<CommandOptions>({
name: 'remove',
description: 'Remove a track from the queue',
preconditions: [
'GuildOnly',
'isCommandDisabled',
'inVoiceChannel',
'playerIsPlaying',
'inPlayerVoiceChannel'
]
})
export class RemoveCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand(builder =>
builder
.setName(this.name)
.setDescription(this.description)
.addIntegerOption(option =>
option
.setName('position')
.setDescription(
'What is the position of the song you want to remove from the queue?'
)
.setRequired(true)
)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const { client } = container;
const position = interaction.options.getInteger('position', true);
const queue = client.music.queues.get(interaction.guildId!);
const length = await queue.count();
if (position < 1 || position > length) {
return interaction.reply(':x: Please enter a valid position number!');
}
await queue.removeAt(position - 1);
return await interaction.reply({
content: `Removed track at position ${position}`
});
}
}

View File

@@ -0,0 +1,35 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { container } from '@sapphire/framework';
@ApplyOptions<CommandOptions>({
name: 'resume',
description: 'Resume the music',
preconditions: [
'GuildOnly',
'isCommandDisabled',
'inVoiceChannel',
'playerIsPlaying',
'inPlayerVoiceChannel'
]
})
export class ResumeCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand({
name: this.name,
description: this.description
});
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const { client } = container;
const queue = client.music.queues.get(interaction.guildId!);
await queue.resume(interaction);
}
}

View File

@@ -0,0 +1,96 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import searchSong from '../../lib/music/searchSong';
import { trpcNode } from '../../trpc';
import Logger from '../../lib/logger';
@ApplyOptions<CommandOptions>({
name: 'save-to-playlist',
description: 'Save a song or a playlist to a custom playlist',
preconditions: [
'GuildOnly',
'isCommandDisabled',
'userInDB',
'playlistExists'
]
})
export class SaveToPlaylistCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand(builder =>
builder
.setName(this.name)
.setDescription(this.description)
.addStringOption(option =>
option
.setName('playlist-name')
.setDescription(
'What is the name of the playlist you want to save to?'
)
.setRequired(true)
)
.addStringOption(option =>
option
.setName('url')
.setDescription('What do you want to save to the custom playlist?')
.setRequired(true)
)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
await interaction.deferReply();
const playlistName = interaction.options.getString('playlist-name', true);
const url = interaction.options.getString('url', true);
const interactionMember = interaction.member?.user;
if (!interactionMember) {
return await interaction.followUp(
':x: Something went wrong! Please try again later'
);
}
const playlistQuery = await trpcNode.playlist.getPlaylist.query({
name: playlistName,
userId: interactionMember.id
});
if (!playlistQuery.playlist) {
return await interaction.followUp('Playlist does not exist');
}
const playlistId = playlistQuery.playlist.id;
const songTuple = await searchSong(url, interaction.user);
if (!songTuple[1].length) {
return await interaction.followUp(songTuple[0]);
}
const songArray = songTuple[1];
const songsToAdd: any[] = [];
for (let i = 0; i < songArray.length; i++) {
const song = songArray[i];
delete song['requester'];
songsToAdd.push({
...song,
playlistId: +playlistId
});
}
try {
await trpcNode.song.createMany.mutate({
songs: songsToAdd
});
return await interaction.followUp(`Added tracks to **${playlistName}**`);
} catch (error) {
Logger.error(error);
return await interaction.followUp(':x: Something went wrong!');
}
}
}

View File

@@ -0,0 +1,58 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { container } from '@sapphire/framework';
@ApplyOptions<CommandOptions>({
name: 'seek',
description: 'Seek to a desired point in a track',
preconditions: [
'GuildOnly',
'isCommandDisabled',
'inVoiceChannel',
'playerIsPlaying',
'inPlayerVoiceChannel'
]
})
export class SeekCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand(builder =>
builder
.setName(this.name)
.setDescription(this.description)
.addIntegerOption(option =>
option
.setName('seconds')
.setDescription(
'To what point in the track do you want to seek? (in seconds)'
)
.setRequired(true)
)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const { client } = container;
const seconds = interaction.options.getInteger('seconds', true);
const milliseconds = seconds * 1000;
const queue = client.music.queues.get(interaction.guildId!);
const track = await queue.getCurrentTrack();
if (!track)
return await interaction.reply(':x: There is no track playing!'); // should never happen
if (!track.isSeekable)
return await interaction.reply(':x: This track is not seekable!');
if (milliseconds > track.length || milliseconds < 0) {
return await interaction.reply(':x: Please enter a valid number!');
}
const player = queue.player;
await player.seek(milliseconds);
return await interaction.reply(`Seeked to ${seconds} seconds`);
}
}

View File

@@ -0,0 +1,41 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { container } from '@sapphire/framework';
@ApplyOptions<CommandOptions>({
name: 'shuffle',
description: 'Shuffle the music queue',
preconditions: [
'GuildOnly',
'isCommandDisabled',
'inVoiceChannel',
'playerIsPlaying',
'inPlayerVoiceChannel'
]
})
export class LeaveCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand({
name: this.name,
description: this.description
});
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const { client } = container;
const queue = client.music.queues.get(interaction.guildId!);
if (!(await queue.count())) {
return await interaction.reply(':x: There are no songs in queue!');
}
await queue.shuffleTracks();
return await interaction.reply(':white_check_mark: Shuffled queue!');
}
}

View File

@@ -0,0 +1,40 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { container } from '@sapphire/framework';
@ApplyOptions<CommandOptions>({
name: 'skip',
description: 'Skip the current song playing',
preconditions: [
'GuildOnly',
'isCommandDisabled',
'inVoiceChannel',
'playerIsPlaying',
'inPlayerVoiceChannel'
]
})
export class SkipCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand({
name: this.name,
description: this.description
});
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const { client } = container;
const { music } = client;
const queue = music.queues.get(interaction.guildId!);
const track = await queue.getCurrentTrack();
await queue.next({ skipped: true });
client.emit('musicSongSkipNotify', interaction, track);
return;
}
}

View File

@@ -0,0 +1,57 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { container } from '@sapphire/framework';
@ApplyOptions<CommandOptions>({
name: 'skipto',
description: 'Skip to a track in queue',
preconditions: [
'GuildOnly',
'isCommandDisabled',
'inVoiceChannel',
'playerIsPlaying',
'inPlayerVoiceChannel'
]
})
export class SkipToCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand(builder =>
builder
.setName(this.name)
.setDescription(this.description)
.addIntegerOption(option =>
option
.setName('position')
.setDescription(
'What is the position of the song you want to skip to in queue?'
)
.setRequired(true)
)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const { client } = container;
const position = interaction.options.getInteger('position', true);
const queue = client.music.queues.get(interaction.guildId!);
const length = await queue.count();
if (position > length || position < 1) {
return await interaction.reply(
':x: Please enter a valid track position.'
);
}
await queue.skipTo(position);
await interaction.reply(
`:white_check_mark: Skipped to track number ${position}!`
);
return;
}
}

View File

@@ -0,0 +1,58 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { container } from '@sapphire/framework';
import type { Node, Player } from 'lavaclient';
@ApplyOptions<CommandOptions>({
name: 'vaporwave',
description: 'Apply vaporwave on the playing track!',
preconditions: [
'GuildOnly',
'isCommandDisabled',
'inVoiceChannel',
'playerIsPlaying',
'inPlayerVoiceChannel'
]
})
export class VaporWaveCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand({
name: this.name,
description: this.description
});
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const { client } = container;
const player = client.music.players.get(
interaction.guild!.id
) as Player<Node>;
player.filters = (player.vaporwave = !player.vaporwave)
? {
...player.filters,
equalizer: [
{ band: 1, gain: 0.7 },
{ band: 0, gain: 0.6 }
],
timescale: { pitch: 0.7, speed: 1, rate: 1 },
tremolo: { depth: 0.6, frequency: 14 }
}
: {
...player.filters,
equalizer: undefined,
timescale: undefined,
tremolo: undefined
};
await player.setFilters();
return await interaction.reply(
`Vaporwave ${player.vaporwave ? 'enabled' : 'disabled'}`
);
}
}

View File

@@ -0,0 +1,51 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { container } from '@sapphire/framework';
@ApplyOptions<CommandOptions>({
name: 'volume',
description: 'Set the Volume',
preconditions: [
'GuildOnly',
'isCommandDisabled',
'inVoiceChannel',
'playerIsPlaying',
'inPlayerVoiceChannel'
]
})
export class VolumeCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand(builder =>
builder
.setName(this.name)
.setDescription(this.description)
.addNumberOption(option =>
option
.setName('setting')
.setDescription('What Volume? (0 to 200)')
.setRequired(true)
)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const { client } = container;
const query = interaction.options.getNumber('setting', true);
const queue = client.music.queues.get(interaction.guildId!);
if (query > 200 || query < 0) {
return await interaction.reply(':x: Volume must be between 0 and 200!');
}
await queue.setVolume(query);
return await interaction.reply(
`:white_check_mark: Volume set to ${query}!`
);
}
}

View File

@@ -0,0 +1,72 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { EmbedBuilder } from 'discord.js';
@ApplyOptions<Command.Options>({
name: '8ball',
description: 'Get the answer to anything!',
preconditions: ['isCommandDisabled']
})
export class EightBallCommand extends Command {
public override registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand(builder =>
builder //
.setName(this.name)
.setDescription(this.description)
.addStringOption(option =>
option
.setName('question')
.setDescription('The question you want to ask the 8ball')
.setRequired(true)
)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const question = interaction.options.getString('question', true);
if (question.length > 255) {
return await interaction.reply({
content:
'Your question is too long! Please keep it under 255 characters.'
});
}
const randomAnswer = answers[Math.floor(Math.random() * answers.length)];
const embed = new EmbedBuilder()
.setTitle(question)
.setAuthor({
name: 'Magic 8ball',
iconURL: 'https://i.imgur.com/HbwMhWM.png'
})
.setDescription(randomAnswer)
.setColor('DarkButNotBlack')
.setTimestamp();
return interaction.reply({ embeds: [embed] });
}
}
const answers = [
'Yes.',
'No.',
'My sources say yes!',
'Most likely.',
"I don't know.",
'Maybe, sometimes.',
'Outlook is good.',
'Signs point to yes.',
'Definitely!',
'Absolutely!',
'Nope.',
"No thanks, I won't be able to make it.",
'No Way!',
"It's certain.",
"It's decidedly so.",
'Without a doubt.',
'Yes - definitely.',
'You can rely on it.',
'As I see it, yes.'
];

View File

@@ -0,0 +1,31 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { EmbedBuilder } from 'discord.js';
@ApplyOptions<Command.Options>({
name: 'about',
description: 'Display info about the bot!',
preconditions: ['isCommandDisabled']
})
export class AboutCommand extends Command {
public override registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand(builder =>
builder //
.setName(this.name)
.setDescription(this.description)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const embed = new EmbedBuilder()
.setTitle('About')
.setDescription(
'A Discord bot with slash commands, playlist support, Spotify, music quiz, saved playlists, lyrics, gifs and more.\n\n :white_small_square: [Commands](https://github.com/galnir/Master-Bot#commands)\n :white_small_square: [Contributors](https://github.com/galnir/Master-Bot#contributors-%EF%B8%8F)'
)
.setColor('Aqua');
return interaction.reply({ embeds: [embed] });
}
}

View File

@@ -0,0 +1,74 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { GuildMember, VoiceChannel } from 'discord.js';
@ApplyOptions<Command.Options>({
name: 'activity',
description: "Generate an invite link to your voice channel's activity",
preconditions: ['isCommandDisabled', 'GuildOnly', 'inVoiceChannel']
})
export class ActivityCommand extends Command {
public override registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand(builder =>
builder //
.setName(this.name)
.setDescription(this.description)
.addChannelOption(option =>
option
.setName('channel')
.setDescription('Channel to invite to')
.setRequired(true)
)
.addStringOption(option =>
option
.setName('activity')
.setDescription('Activity to invite to')
.setRequired(true)
)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const channel = interaction.options.getChannel('channel', true);
const activity = interaction.options.getString('activity', true);
if (
channel.type.toString() !== 'GUILD_VOICE' ||
channel.type.toString() === 'GUILD_CATEGORY'
) {
return interaction.reply({
content: 'You can only invite to voice channels!'
});
}
const member = interaction.member as GuildMember;
if (!member) {
return interaction.reply({
content: 'You must be in a voice channel to use this command!'
});
}
if (member.voice.channelId !== channel.id) {
return interaction.reply({
content: 'You must be in the same voice channel to use this command!'
});
}
try {
const invite = await (channel as VoiceChannel).createInvite({
reason: 'Activity invite'
});
return interaction.reply({
content: `[Click to join ${activity} in ${channel.name}](${invite.url})`
});
} catch {
return interaction.reply({
content: 'Something went wrong!'
});
}
}
}

View File

@@ -0,0 +1,48 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { EmbedBuilder } from 'discord.js';
@ApplyOptions<Command.Options>({
name: 'advice',
description: 'Get some advice!',
preconditions: ['isCommandDisabled']
})
export class AdviceCommand extends Command {
public override registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand(builder =>
builder.setName(this.name).setDescription(this.description)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
try {
const response = await fetch('https://api.adviceslip.com/advice');
const data = await response.json();
const advice = data.slip?.advice;
if (!advice) {
return interaction.reply({ content: 'Something went wrong!' });
}
const embed = new EmbedBuilder()
.setColor('NotQuiteBlack')
.setAuthor({
name: 'Advice Slip',
url: 'https://adviceslip.com/',
iconURL: 'https://i.imgur.com/8pIvnmD.png'
})
.setDescription(advice)
.setTimestamp()
.setFooter({
text: `Powered by adviceslip.com`
});
return interaction.reply({ embeds: [embed] });
} catch {
return interaction.reply({ content: 'Something went wrong!' });
}
}
}

View File

@@ -0,0 +1,36 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { EmbedBuilder } from 'discord.js';
@ApplyOptions<Command.Options>({
name: 'avatar',
description: "Responds with a user's avatar",
preconditions: ['isCommandDisabled', 'GuildOnly']
})
export class AvatarCommand extends Command {
public override registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand(builder =>
builder
.setName(this.name)
.setDescription(this.description)
.addUserOption(option =>
option
.setName('user')
.setDescription('The user to get the avatar of')
.setRequired(true)
)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const user = interaction.options.getUser('user', true);
const embed = new EmbedBuilder()
.setTitle(user.username)
.setImage(user.displayAvatarURL({ size: 4096 }))
.setColor('Aqua');
return interaction.reply({ embeds: [embed] });
}
}

View File

@@ -0,0 +1,51 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { EmbedBuilder } from 'discord.js';
@ApplyOptions<Command.Options>({
name: 'chucknorris',
description: 'Get a satirical fact about Chuck Norris!',
preconditions: ['isCommandDisabled']
})
export class ChuckNorrisCommand extends Command {
public override registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand(builder =>
builder.setName(this.name).setDescription(this.description)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
try {
const response = await fetch('https://api.chucknorris.io/jokes/random');
const data = await response.json();
const joke = data;
if (!joke) {
return interaction.reply({
content: ':x: An error occured, Chuck is investigating this!'
});
}
const embed = new EmbedBuilder()
.setColor('Orange')
.setAuthor({
name: 'Chuck Norris',
url: 'https://chucknorris.io',
iconURL: joke.icon_url
})
.setDescription(joke.value)
.setTimestamp()
.setFooter({
text: 'Powered by chucknorris.io'
});
return interaction.reply({ embeds: [embed] });
} catch {
return interaction.reply({
content: ':x: An error occured, Chuck is investigating this!'
});
}
}
}

View File

@@ -0,0 +1,51 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { EmbedBuilder } from 'discord.js';
@ApplyOptions<Command.Options>({
name: 'fortune',
description: 'Replies with a fortune cookie tip!',
preconditions: ['isCommandDisabled']
})
export class FortuneCommand extends Command {
public override registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand(builder =>
builder.setName(this.name).setDescription(this.description)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
try {
const response = await fetch('http://yerkee.com/api/fortune');
const data = await response.json();
const tip = data.fortune;
if (!tip) {
return interaction.reply({
content: 'Something went wrong!'
});
}
const embed = new EmbedBuilder()
.setColor('Orange')
.setAuthor({
name: 'Fortune Cookie',
url: 'https://yerkee.com',
iconURL: 'https://i.imgur.com/58wIjK0.png'
})
.setDescription(tip)
.setTimestamp()
.setFooter({
text: 'Powered by yerkee.com'
});
return interaction.reply({ embeds: [embed] });
} catch {
return interaction.reply({
content: 'Something went wrong!'
});
}
}
}

View File

@@ -0,0 +1,234 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { PaginatedMessage } from '@sapphire/discord.js-utilities';
import { env } from '../../env';
import axios from 'axios';
@ApplyOptions<Command.Options>({
name: 'game-search',
description: 'Search for video game information',
preconditions: ['isCommandDisabled']
})
export class ChuckNorrisCommand extends Command {
public override registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand(builder =>
builder
.setName(this.name)
.setDescription(this.description)
.addStringOption(option =>
option
.setName('game')
.setDescription('The game you want to look up?')
.setRequired(true)
)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
if (!env.RAWG_API) {
return interaction.reply({
content: 'This command is disabled because the RAWG API key is not set.'
});
}
const title = interaction.options.getString('game', true);
const filteredTitle = this.filterTitle(title);
const game = await this.getGameDetails(filteredTitle);
if (!game) {
return interaction.reply({
content: 'No game found with that name'
});
}
const PaginatedEmbed = new PaginatedMessage();
const firstPageTuple: string[] = []; // releaseDate, esrbRating, userRating
if (game.tba) {
firstPageTuple.push('TBA');
} else if (!game.released) {
firstPageTuple.push('None Listed');
} else {
firstPageTuple.push(game.released);
}
if (!game.esrb_rating) {
firstPageTuple.push('None Listed');
} else {
firstPageTuple.push(game.esrb_rating.name);
}
if (!game.rating) {
firstPageTuple.push('None Listed');
} else {
firstPageTuple.push(game.rating + '/5');
}
PaginatedEmbed.addPageEmbed(embed =>
embed
.setTitle(`Game Info: ${game.name}`)
.setDescription(
'>>> ' +
'**Game Description**\n' +
game.description_raw.slice(0, 2000) +
'...'
)
.setColor('Grey')
.setThumbnail(game.background_image)
.addFields(
{ name: 'Released', value: '> ' + firstPageTuple[0], inline: true },
{
name: 'ESRB Rating',
value: '> ' + firstPageTuple[1],
inline: true
},
{ name: 'Score', value: '> ' + firstPageTuple[2], inline: true }
)
.setTimestamp()
);
const developerArray: string[] = [];
if (game.developers.length) {
for (let i = 0; i < game.developers.length; ++i) {
developerArray.push(game.developers[i].name);
}
} else {
developerArray.push('None Listed');
}
const publisherArray: string[] = [];
if (game.publishers.length) {
for (let i = 0; i < game.publishers.length; ++i) {
publisherArray.push(game.publishers[i].name);
}
} else {
publisherArray.push('None Listed');
}
const platformArray: string[] = [];
if (game.platforms.length) {
for (let i = 0; i < game.platforms.length; ++i) {
platformArray.push(game.platforms[i].platform.name);
}
} else {
platformArray.push('None Listed');
}
const genreArray: string[] = [];
if (game.genres.length) {
for (let i = 0; i < game.genres.length; ++i) {
genreArray.push(game.genres[i].name);
}
} else {
genreArray.push('None Listed');
}
const retailerArray: string[] = [];
if (game.stores.length) {
for (let i = 0; i < game.stores.length; ++i) {
retailerArray.push(
`[${game.stores[i].store.name}](${game.stores[i].url})`
);
}
} else {
retailerArray.push('None Listed');
}
PaginatedEmbed.addPageEmbed(embed =>
embed
.setTitle(`Game Info: ${game.name}`)
.setColor('Grey')
.setThumbnail(game.background_image_additional ?? game.background_image)
// Row 1
.addFields(
{
name: developerArray.length == 1 ? 'Developer' : 'Developers',
value: '> ' + developerArray.toString().replace(/,/g, ', '),
inline: true
},
{
name: publisherArray.length == 1 ? 'Publisher' : 'Publishers',
value: '> ' + publisherArray.toString().replace(/,/g, ', '),
inline: true
},
{
name: platformArray.length == 1 ? 'Platform' : 'Platforms',
value: '> ' + platformArray.toString().replace(/,/g, ', '),
inline: true
}
)
// Row 2
.addFields(
{
name: genreArray.length == 1 ? 'Genre' : 'Genres',
value: '> ' + genreArray.toString().replace(/,/g, ', '),
inline: true
},
{
name: retailerArray.length == 1 ? 'Retailer' : 'Retailers',
value:
'> ' +
retailerArray.toString().replace(/,/g, ', ').replace(/`/g, '')
}
)
.setTimestamp()
);
if (PaginatedEmbed.actions.size > 0)
PaginatedEmbed.actions.delete('@sapphire/paginated-messages.goToPage');
return PaginatedEmbed.run(interaction);
}
private filterTitle(title: string) {
return title.replace(/ /g, '-').replace(/' /g, '').toLowerCase();
}
private getGameDetails(query: string): Promise<any> {
return new Promise(async function (resolve, reject) {
const url = `https://api.rawg.io/api/games/${encodeURIComponent(
query
)}?key=${env.RAWG_API}`;
try {
const response = await axios.get(url);
if (response.status === 429) {
reject(':x: Rate Limit exceeded. Please try again in a few minutes.');
}
if (response.status === 503) {
reject(
':x: The service is currently unavailable. Please try again later.'
);
}
if (response.status === 404) {
reject(`:x: Error: ${query} was not found`);
}
if (response.status !== 200) {
reject(
':x: There was a problem getting game from the API, make sure you entered a valid game tittle'
);
}
let body = response.data;
if (body.redirect) {
const redirect = await axios.get(
`https://api.rawg.io/api/games/${body.slug}?key=${env.RAWG_API}`
);
body = redirect.data;
}
// 'id' is the only value that must be present to all valid queries
if (!body.id) {
reject(
':x: There was a problem getting data from the API, make sure you entered a valid game title'
);
}
resolve(body);
} catch (e) {
reject(
'There was a problem getting data from the API, make sure you entered a valid game title'
);
}
});
}
}

View File

@@ -0,0 +1,161 @@
import {
PaginatedMessage,
PaginatedFieldMessageEmbed
} from '@sapphire/discord.js-utilities';
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions, container } from '@sapphire/framework';
import {
ApplicationCommandOption,
AutocompleteInteraction,
EmbedBuilder
} from 'discord.js';
@ApplyOptions<CommandOptions>({
name: 'help',
description: 'Get the Command List or add a command-name to get more info.',
preconditions: ['isCommandDisabled']
})
export class HelpCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand(builder =>
builder
.setName(this.name)
.setDescription(this.description)
.addStringOption(option =>
option
.setName('command-name')
.setDescription('Which command would you like to know about?')
.setRequired(false)
)
);
}
public override autocompleteRun(interaction: AutocompleteInteraction) {
const commands = interaction.client.application?.commands.cache;
const focusedOption = interaction.options.getFocused(true);
const result = commands
?.sorted((a, b) => a.name.localeCompare(b.name))
.filter(choice => choice.name.startsWith(focusedOption.value.toString()))
.map(choice => ({ name: choice.name, value: choice.name }))
.slice(0, 10);
interaction;
return interaction.respond(result!);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const { client } = container;
const query = interaction.options.getString('command-name')?.toLowerCase();
const array: CommandInfo[] = [];
const app = client.application;
app?.commands.cache.each(command => {
array.push({
name: command.name,
options: command.options,
details: command.description
});
});
// Sort the array by name
const sortedList = array.sort((a, b) => {
let fa = a.name.toLowerCase(),
fb = b.name.toLowerCase();
if (fa < fb) {
return -1;
}
if (fa > fb) {
return 1;
}
return 0;
});
if (!query) {
let characters = 0;
let page = 0;
let message: string[] = [];
const PaginatedEmbed = new PaginatedMessage();
sortedList.forEach((command, index) => {
characters += command.details.length + command.details.length;
message.push(`> **/${command.name}** - ${command.details}\n`);
if (characters > 1500 || index == sortedList.length - 1) {
page++;
characters = 0;
PaginatedEmbed.addPageEmbed(
new EmbedBuilder()
.setTitle(`Command List - Page ${page}`)
.setThumbnail(app?.iconURL()!)
.setColor('Purple')
.setAuthor({
name: interaction.user.username + ' - Help Command',
iconURL: interaction.user.displayAvatarURL()
})
.setDescription(message.toString().replaceAll(',> **/', '> **/'))
);
message = [];
}
});
return PaginatedEmbed.run(interaction);
} else {
const commandMap = new Map();
sortedList.reduce(
(obj, command) => commandMap.set(command.name, command),
{}
);
if (commandMap.has(query)) {
const command: CommandInfo = commandMap.get(query);
const optionsList: any[] = [];
command.options.forEach(option => {
optionsList.push({
name: option.name,
description: option.description
});
});
const DetailedPagination = new PaginatedFieldMessageEmbed();
const commandDetails = new EmbedBuilder()
.setAuthor({
name: interaction.user.username + ' - Help Command',
iconURL: interaction.user.displayAvatarURL()
})
.setThumbnail(app?.iconURL()!)
.setTitle(
`${
command.name.charAt(0).toUpperCase() +
command.name.slice(1).toLowerCase()
} - Details`
)
.setColor('Purple')
.setDescription(`**Description**\n> ${command.details}`);
if (!command.options.length)
return await interaction.reply({ embeds: [commandDetails] });
DetailedPagination.setTemplate(commandDetails)
.setTitleField('Options')
.setItems(command.options)
.formatItems(
(option: any) => `**${option.name}**\n> ${option.description}`
)
.setItemsPerPage(5)
.make();
return DetailedPagination.run(interaction);
} else
return await interaction.reply(
`:x: Command: **${query}** was not found`
);
}
interface CommandInfo {
name: string;
options: ApplicationCommandOption[];
details: string;
}
}
}

View File

@@ -0,0 +1,52 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { EmbedBuilder } from 'discord.js';
@ApplyOptions<CommandOptions>({
name: 'insult',
description: 'Replies with a mean insult',
preconditions: ['isCommandDisabled']
})
export class InsultCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand({
name: this.name,
description: this.description
});
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
try {
const response = await fetch(
'https://evilinsult.com/generate_insult.php?lang=en&type=json'
);
const data = await response.json();
if (!data.insult)
return interaction.reply({ content: 'Something went wrong!' });
const embed = new EmbedBuilder()
.setColor('Red')
.setAuthor({
name: 'Evil Insult',
url: 'https://evilinsult.com',
iconURL: 'https://i.imgur.com/bOVpNAX.png'
})
.setDescription(data.insult)
.setTimestamp()
.setFooter({
text: 'Powered by evilinsult.com'
});
return interaction.reply({ embeds: [embed] });
} catch {
return interaction.reply({
content: 'Something went wrong!'
});
}
}
}

View File

@@ -0,0 +1,50 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { EmbedBuilder } from 'discord.js';
@ApplyOptions<CommandOptions>({
name: 'kanye',
description: 'Replies with a random Kanye quote',
preconditions: ['isCommandDisabled']
})
export class KanyeCommand extends Command {
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
try {
const response = await fetch('https://api.kanye.rest/?format=json');
const data = await response.json();
if (!data.quote)
return interaction.reply({ content: 'Something went wrong!' });
const embed = new EmbedBuilder()
.setColor('Orange')
.setAuthor({
name: 'Kanye Omari West',
url: 'https://kanye.rest',
iconURL: 'https://i.imgur.com/SsNoHVh.png'
})
.setDescription(data.quote)
.setTimestamp()
.setFooter({
text: 'Powered by kanye.rest'
});
return interaction.reply({ embeds: [embed] });
} catch {
return interaction.reply({
content: 'Something went wrong!'
});
}
}
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand({
name: this.name,
description: this.description
});
}
}

View File

@@ -0,0 +1,51 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { EmbedBuilder } from 'discord.js';
@ApplyOptions<CommandOptions>({
name: 'motivation',
description: 'Replies with a motivational quote!',
preconditions: ['isCommandDisabled']
})
export class MotivationCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand({
name: this.name,
description: this.description
});
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
try {
const response = await fetch('https://type.fit/api/quotes');
const data = await response.json();
if (!data)
return await interaction.reply({ content: 'Something went wrong!' });
const randomQuote = data[Math.floor(Math.random() * data.length)];
const embed = new EmbedBuilder()
.setColor('Yellow')
.setAuthor({
name: 'Motivational Quote',
url: 'https://type.fit',
iconURL: 'https://i.imgur.com/Cnr6cQb.png'
})
.setDescription(`*"${randomQuote.text}*"\n\n-${randomQuote.author}`)
.setTimestamp()
.setFooter({
text: 'Powered by type.fit'
});
return await interaction.reply({ embeds: [embed] });
} catch {
return await interaction.reply({
content: 'Something went wrong!'
});
}
}
}

View File

@@ -0,0 +1,21 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
@ApplyOptions<Command.Options>({
name: 'ping',
description: 'Replies with pong!',
preconditions: ['isCommandDisabled']
})
export class PingCommand extends Command {
public override registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand(builder =>
builder.setName(this.name).setDescription(this.description)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
return interaction.reply({ content: 'Pong!' });
}
}

View File

@@ -0,0 +1,45 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { EmbedBuilder } from 'discord.js';
@ApplyOptions<CommandOptions>({
name: 'random',
description: 'Generate a random number between two inputs!',
preconditions: ['isCommandDisabled']
})
export class RandomCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand(builder =>
builder
.setName(this.name)
.setDescription(this.description)
.addIntegerOption(option =>
option
.setName('min')
.setDescription('What is the minimum number?')
.setRequired(true)
)
.addIntegerOption(option =>
option
.setName('max')
.setDescription('What is the maximum number?')
.setRequired(true)
)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const min = Math.ceil(interaction.options.getInteger('min', true));
const max = Math.floor(interaction.options.getInteger('max', true));
const rngEmbed = new EmbedBuilder().setTitle(
`${Math.floor(Math.random() * (max - min + 1)) + min}`
);
return await interaction.reply({ embeds: [rngEmbed] });
}
}

View File

@@ -0,0 +1,215 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import {
ColorResolvable,
StringSelectMenuBuilder,
ComponentType
} from 'discord.js';
import { PaginatedMessage } from '@sapphire/discord.js-utilities';
import axios from 'axios';
import Logger from '../../lib/logger';
@ApplyOptions<CommandOptions>({
name: 'reddit',
description: 'Get posts from reddit by specifying a subreddit',
preconditions: ['GuildOnly', 'isCommandDisabled']
})
export class RedditCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand(builder =>
builder
.setName(this.name)
.setDescription(this.description)
.addStringOption(option =>
option
.setName('subreddit')
.setDescription('Subreddit name')
.setRequired(true)
)
.addStringOption(option =>
option
.setName('sort')
.setDescription(
'What posts do you want to see? Select from best/hot/top/new/controversial/rising'
)
.setRequired(true)
.addChoices(
{
name: 'Best',
value: 'best'
},
{
name: 'Hot',
value: 'hot'
},
{
name: 'New',
value: 'new'
},
{
name: 'Top',
value: 'top'
},
{
name: 'Controversial',
value: 'controversial'
},
{
name: 'Rising',
value: 'rising'
}
)
)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
await interaction.deferReply();
const channel = interaction.channel;
if (!channel) return await interaction.reply('Something went wrong :('); // type guard
const subreddit = interaction.options.getString('subreddit', true);
const sort = interaction.options.getString('sort', true);
if (['controversial', 'top'].some(val => val === sort)) {
const row = new StringSelectMenuBuilder()
.setCustomId('top_or_controversial')
.setPlaceholder('Please select an option')
.addOptions(optionsArray);
const menu = await channel.send({
content: `:loud_sound: Do you want to get the ${sort} posts from past hour/week/month/year or all?`,
components: [
{
type: ComponentType.ActionRow,
components: [row]
}
]
});
const collector = menu.createMessageComponentCollector({
componentType: ComponentType.StringSelect,
time: 30000 // 30 sec
});
collector.on('end', () => {
if (menu) menu.delete().catch(Logger.error);
});
collector.on('collect', async i => {
if (i.user.id !== interaction.user.id) {
i.reply({
content: 'This element is not for you!',
ephemeral: true
});
return;
} else {
collector.stop();
const timeFilter = i.values[0];
this.fetchFromReddit(interaction, subreddit, sort, timeFilter);
return;
}
});
} else {
this.fetchFromReddit(interaction, subreddit, sort);
return;
}
return;
}
private async fetchFromReddit(
interaction: Command.ChatInputCommandInteraction,
subreddit: string,
sort: string,
timeFilter = 'day'
) {
try {
var data = await this.getData(subreddit, sort, timeFilter);
} catch (error: any) {
return interaction.followUp(error);
}
// interaction.followUp('Fetching data from reddit');
const paginatedEmbed = new PaginatedMessage();
for (let i = 1; i <= data.children.length; i++) {
let color: ColorResolvable = 'Orange';
let redditPost = data.children[i - 1];
if (redditPost.data.title.length > 255) {
redditPost.data.title = redditPost.data.title.substring(0, 252) + '...'; // max title length is 256
}
if (redditPost.data.selftext.length > 1024) {
redditPost.data.selftext =
redditPost.data.selftext.substring(0, 1024) +
`[Read More...](https://www.reddit.com${redditPost.data.permalink})`;
}
if (redditPost.data.over_18) color = 'Red'; // red - nsfw
paginatedEmbed.addPageEmbed(embed =>
embed
.setColor(color)
.setTitle(redditPost.data.title)
.setURL(`https://www.reddit.com${redditPost.data.permalink}`)
.setDescription(
`${
redditPost.data.over_18 ? '' : redditPost.data.selftext + '\n\n'
}Upvotes: ${redditPost.data.score} :thumbsup: `
)
.setAuthor({ name: redditPost.data.author })
);
}
return paginatedEmbed.run(interaction);
}
private getData(
subreddit: string,
sort: string,
timeFilter: string
): Promise<any> {
return new Promise(async function (resolve, reject) {
const response = await axios.get(
`https://www.reddit.com/r/${subreddit}/${sort}/.json?limit=10&t=${
timeFilter ? timeFilter : 'day'
}`
);
const data = response.data.data;
if (!data) {
reject(`**${subreddit}** is a private subreddit!`);
} else if (!data.children.length) {
reject('Please provide a valid subreddit name!');
}
resolve(data);
});
}
}
const optionsArray = [
{
label: 'hour',
value: 'hour'
},
{
label: 'week',
value: 'week'
},
{
label: 'month',
value: 'month'
},
{
label: 'year',
value: 'year'
},
{
label: 'all',
value: 'all'
}
];

View File

@@ -0,0 +1,80 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { Colors, EmbedBuilder } from 'discord.js';
@ApplyOptions<CommandOptions>({
name: 'rockpaperscissors',
description: 'Play rock paper scissors with me!',
preconditions: ['isCommandDisabled']
})
export class RockPaperScissorsCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand(builder =>
builder
.setName(this.name)
.setDescription(this.description)
.addStringOption(option =>
option
.setName('move')
.setDescription('What is your move?')
.setRequired(true)
.addChoices(
{ name: 'Rock', value: 'rock' },
{ name: 'Paper', value: 'paper' },
{ name: 'Scissors', value: 'scissors' }
)
)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const move = interaction.options.getString('move', true) as
| 'rock'
| 'paper'
| 'scissors';
const resultMessage = this.rpsLogic(move);
const embed = new EmbedBuilder()
.setColor(Colors.White)
.setTitle('Rock, Paper, Scissors')
.setDescription(`**${resultMessage[0]}**, I formed ${resultMessage[1]}`);
return await interaction.reply({ embeds: [embed] });
}
private rpsLogic(player_move: string) {
const bot_move = ['rock', 'paper', 'scissors'][
Math.floor(Math.random() * 3)
];
if (player_move === 'rock') {
if (bot_move === 'rock') {
return ['Tie!', 'Rock'];
}
if (bot_move === 'paper') {
return ['I win!', 'Paper'];
}
return ['You win!', 'Scissors'];
} else if (player_move === 'paper') {
if (bot_move === 'rock') {
return ['You win!', 'Rock'];
}
if (bot_move === 'paper') {
return ['Tie!', 'Paper'];
}
return ['I win!', 'Scissors'];
} else {
if (bot_move === 'rock') {
return ['I win!', 'Rock'];
}
if (bot_move === 'paper') {
return ['You win!', 'Paper'];
}
return ['Tie!', 'Scissors'];
}
}
}

View File

@@ -0,0 +1,342 @@
import { ApplyOptions } from '@sapphire/decorators';
import { PaginatedMessage } from '@sapphire/discord.js-utilities';
import { Command, CommandOptions } from '@sapphire/framework';
import axios from 'axios';
import { EmbedBuilder, Colors, ButtonStyle, ComponentType } from 'discord.js';
import Logger from '../../lib/logger';
@ApplyOptions<CommandOptions>({
name: 'speedrun',
description: 'Look for the world record of a game!',
preconditions: ['isCommandDisabled']
})
export class SpeedRunCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand(builder =>
builder
.setName(this.name)
.setDescription(this.description)
.addStringOption(option =>
option
.setName('game')
.setDescription('Video Game Title?')
.setRequired(true)
)
.addStringOption(option =>
option
.setName('category')
.setDescription('speed run Category?')
.setRequired(false)
)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const query = interaction.options.getString('game', true);
let queryCat = interaction.options.getString('category', false);
let initialRaw;
try {
initialRaw = await axios.get(
`https://www.speedrun.com/api/v1/games?name=${query}`
);
} catch {
return interaction.reply({
content: 'Something went wrong, please try again later'
});
}
const initial = initialRaw.data;
if (!initial.data.length) {
return interaction.reply({ content: 'No game was found.' });
}
let gameID: string = initial.data[0].id;
let response;
try {
response = await axios.get(
`https://www.speedrun.com/api/v1/games/${gameID}/records?miscellaneous=no&scope=full-game&top=10&embed=game,category,players,platforms,regions`
);
} catch (error) {
Logger.error(`${this.name} Command - ${JSON.stringify(error)}`);
return interaction.reply({
content: 'Something went wrong, please try again later'
});
}
const body = response.data;
if (!body.data.length) {
const gameNameArr: string[] = [];
initial.data.slice(0, 6).forEach((id: any) => {
gameNameArr.push(id.names.international);
});
let gameName = new EmbedBuilder()
.setColor('Green')
.setTitle(':mag: Search Results')
.setThumbnail(initial.data[0].assets['cover-medium'].uri)
.addFields({
name: ':x: Try searching again with the following suggestions.',
value: initial.data[0].names.international + ` doesn't have any runs.`
})
.setTimestamp()
.setFooter({ text: 'Powered by www.speedrun.com' });
gameNameArr.forEach((game, i) => {
gameName.addFields({
name: `:video_game: Result ${i + 1}`,
value: game
});
});
interaction.reply({ embeds: [gameName] });
} else {
const categories = body.data;
queryCat = !queryCat ? categories[0].category.data.name : queryCat;
for (let i = 0; i <= categories.length; ++i) {
if (
categories[i]?.category.data.name.toLowerCase() ==
queryCat?.toLowerCase()
) {
break;
} else if (i == categories.length)
queryCat = categories[0].category.data.name;
}
return await interaction
.reply({
embeds: [
new EmbedBuilder().setColor('Green').setDescription('Getting Data')
],
fetchReply: true
})
.then(async () => {
SpeedRunCommand.embedGenerator(
categories,
queryCat ?? categories[0].category.data.name
)
.setIdle(30 * 1000)
.setIndex(0)
.run(interaction);
});
}
}
static myButtons(
message: PaginatedMessage,
categories: any,
queryCat: string
) {
categories.forEach((value: null, index: number) => {
message.addAction({
style:
categories[index].category.data.name.toLowerCase() ==
queryCat.toLowerCase()
? ButtonStyle.Success
: ButtonStyle.Primary,
customId: `Category-${index}`,
label: categories[index].category.data.name,
type: ComponentType.Button,
run: async ({ interaction }) => {
// message = new PaginatedMessage();
queryCat = categories[index].category.data.name;
message = SpeedRunCommand.embedGenerator(categories, queryCat);
try {
SpeedRunCommand.myButtons(
message.setIndex(0).setIdle(30 * 1000),
categories,
queryCat
);
} catch (error: any) {
new PaginatedMessage()
.addPageEmbed(
new EmbedBuilder()
.setColor(Colors.Red)
.setTitle('Error')
.setDescription(error.toString())
)
.run(interaction);
}
await interaction
.update({
embeds: [
new EmbedBuilder()
.setColor('Green')
.setDescription('Getting Data')
],
fetchReply: true
})
.then(async () => {
message.run(interaction);
});
}
});
});
return message;
}
static embedGenerator(categories: any, queryCat: string) {
const PaginatedEmbed = new PaginatedMessage();
try {
categories.forEach((category: any) => {
if (
category.category.data.name.toLowerCase() == queryCat?.toLowerCase()
) {
const catRules = new EmbedBuilder()
.setDescription(
category.category.data.rules.toString().length
? `**${category.category.data.name} Rules**:\n` +
category.category.data.rules.toString()
: 'No Data'
)
.setColor('Green')
.setThumbnail(category.game.data.assets['cover-medium'].uri)
.setAuthor({
name:
category.game.data.names.international +
' - ' +
category.category.data.name,
url: 'http://speedrun.com/'
});
PaginatedEmbed.addPageEmbed(catRules);
for (let i = 0; i <= category.players.data.length; ++i) {
const platform: string =
category.platforms.data.length > 0
? category.platforms.data[0].name
: '';
const region: string =
category.regions.data.length > 0
? ' - ' + category.regions.data[0].name
: '';
let emu: string = 'No Data';
let runnerName: string = 'No Data';
let trophyIcon: string = '';
if (category.runs[i]) {
emu = category.runs[i].run.system.emulated ? ' [EMU]' : '';
runnerName =
category.players.data[i].rel === 'user'
? category.players.data[i].names.international
: category.players.data[i].name;
if (i == 0) trophyIcon = '🏆 WR: ';
if (i == 1) trophyIcon = '🥈 2nd: ';
if (i == 2) trophyIcon = '🥉 3rd: ';
if (i >= 3) trophyIcon = `${i + 1}th: `;
}
if (category.runs[i]) {
PaginatedEmbed.addPageEmbed(embeds =>
embeds
.setColor('Green')
.setTitle(
category.runs[i]
? trophyIcon +
SpeedRunCommand.convertTime(
category.runs[i].run.times.primary_t
) +
' by ' +
runnerName
: 'No Data'
)
.setThumbnail(category.game.data.assets['cover-medium'].uri)
.setURL(
category.runs[i]
? category.runs[i].run.weblink
: category.weblink
)
.setAuthor({
name:
category.game.data.names.international +
' - ' +
category.category.data.name,
url: 'http://speedrun.com/'
})
.addFields(
{
name: ':calendar_spiral: Date Played:',
value: category.runs[i]
? category.runs[i].run.date
: 'No Data'
},
{
name: ':video_game: Played On:',
value: platform + region + emu
}
)
.setFooter({
text: 'Powered by www.speedrun.com',
iconURL: 'https://i.imgur.com/PpxR9E1.png'
})
);
}
}
}
});
PaginatedEmbed.setIdle(30 * 1000).setIndex(0);
if (PaginatedEmbed.actions.size > 0)
PaginatedEmbed.actions.delete('@sapphire/paginated-messages.goToPage');
SpeedRunCommand.myButtons(PaginatedEmbed, categories, queryCat);
return PaginatedEmbed;
} catch (error: any) {
Logger.error(`${this.name} Command - ${JSON.stringify(error)}`);
return new PaginatedMessage().addPageEmbed(
new EmbedBuilder()
.setColor(Colors.Red)
.setTitle('Error')
.setDescription(error.toString())
);
}
}
static convertTime(time: number) {
let str, hr, min: any, sec, ms: number | string | undefined;
let parts = time.toString().split('.');
ms = parts.length > 1 ? parseInt((parts[1] + '00').slice(0, 3)) : undefined;
sec = parseInt(parts[0]);
if (sec >= 60) {
min = Math.floor(sec / 60);
sec = sec % 60;
sec = sec < 10 ? '0' + sec : sec;
}
if (min >= 60) {
hr = Math.floor(min / 60);
min = min % 60;
min = min < 10 ? '0' + min : min;
}
if (ms && ms < 10) ms = '00' + ms;
else if (ms && ms < 100) ms = '0' + ms;
if (min == undefined) {
str =
ms == undefined
? sec.toString() + 's'
: sec.toString() + 's ' + ms.toString() + 'ms';
} else if (hr === undefined) {
str =
ms === undefined
? min.toString() + 'm ' + sec.toString() + 's'
: min.toString() +
'm ' +
sec.toString() +
's ' +
ms.toString() +
'ms';
} else {
str =
ms === undefined
? hr.toString() + 'h ' + min.toString() + 'm ' + sec.toString() + 's'
: hr.toString() +
'h ' +
min.toString() +
'm ' +
sec.toString() +
's ' +
ms.toString() +
'ms';
}
return str;
}
}

View File

@@ -0,0 +1,68 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import axios from 'axios';
import { EmbedBuilder } from 'discord.js';
import translate from 'google-translate-api-x';
import Logger from '../../lib/logger';
@ApplyOptions<CommandOptions>({
name: 'translate',
description:
'Translate from any language to any language using Google Translate',
preconditions: ['GuildOnly', 'isCommandDisabled', 'validateLanguageCode']
})
export class TranslateCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand(builder =>
builder
.setName(this.name)
.setDescription(this.description)
.addStringOption(option =>
option
.setName('target')
.setDescription(
'What is the target language?(language you want to translate to)'
)
.setRequired(true)
)
.addStringOption(option =>
option
.setName('text')
.setDescription('What text do you want to translate?')
.setRequired(true)
)
);
}
public override chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const targetLang = interaction.options.getString('target', true);
const text = interaction.options.getString('text', true);
translate(text, {
to: targetLang,
requestFunction: axios
})
.then(async (response: any) => {
const embed = new EmbedBuilder()
.setColor('DarkRed')
.setTitle('Google Translate')
.setURL('https://translate.google.com/')
.setDescription(response.text)
.setFooter({
iconURL: 'https://i.imgur.com/ZgFxIwe.png', // Google Translate Icon
text: 'Powered by Google Translate'
});
return await interaction.reply({ embeds: [embed] });
})
.catch(async error => {
Logger.error(error);
return await interaction.reply(
':x: Something went wrong when trying to translate the text'
);
});
}
}

View File

@@ -0,0 +1,50 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { EmbedBuilder } from 'discord.js';
import axios from 'axios';
import Logger from '../../lib/logger';
@ApplyOptions<CommandOptions>({
name: 'trump',
description: 'Replies with a random Trump quote',
preconditions: ['isCommandDisabled']
})
export class TrumpCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand({
name: this.name,
description: this.description
});
}
public override chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
axios
.get('https://api.tronalddump.io/random/quote')
.then(async response => {
const quote: string = response.data.value;
const embed = new EmbedBuilder()
.setColor('Orange')
.setAuthor({
name: 'Donald Trump',
url: 'https://api.tronalddump.io/random/quote',
iconURL:
'https://www.whitehouse.gov/wp-content/uploads/2021/01/45_donald_trump.jpg'
})
.setDescription(quote)
.setTimestamp(response.data.appeared_at)
.setFooter({
text: 'Powered by api.tronalddump.io'
});
return interaction.reply({ embeds: [embed] });
})
.catch(async error => {
Logger.error(error);
return interaction.reply({
content: 'Something went wrong when fetching a Trump quote :('
});
});
}
}

View File

@@ -0,0 +1,190 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { PaginatedMessage } from '@sapphire/discord.js-utilities';
import axios from 'axios';
import Logger from '../../lib/logger';
@ApplyOptions<CommandOptions>({
name: 'tv-show-search',
description: 'Get TV shows information',
preconditions: ['GuildOnly', 'isCommandDisabled']
})
export class TVShowSearchCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand(builder =>
builder
.setName(this.name)
.setDescription(this.description)
.addStringOption(option =>
option
.setName('query')
.setDescription('What TV show do you want to look up?')
.setRequired(true)
)
);
}
public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const query = interaction.options.getString('query', true);
try {
var data = await this.getData(query);
} catch (error: any) {
return interaction.reply({ content: error });
}
const PaginatedEmbed = new PaginatedMessage();
for (let i = 0; i < data.length; i++) {
const showInfo = this.constructInfoObject(data[i].show);
PaginatedEmbed.addPageEmbed(embed =>
embed
.setTitle(showInfo.name)
.setURL(showInfo.url)
.setColor('DarkAqua')
.setThumbnail(showInfo.thumbnail)
.setDescription(showInfo.summary)
.addFields(
{ name: 'Language', value: showInfo.language, inline: true },
{
name: 'Genre(s)',
value: showInfo.genres,
inline: true
},
{
name: 'Show Type',
value: showInfo.type,
inline: true
},
{
name: 'Premiered',
value: showInfo.premiered,
inline: true
},
{ name: 'Network', value: showInfo.network, inline: true },
{ name: 'Runtime', value: showInfo.runtime, inline: true },
{ name: 'Average Rating', value: showInfo.rating }
)
.setFooter({
text: `(Page ${i}/${data.length}) Powered by tvmaze.com`,
iconURL: 'https://static.tvmaze.com/images/favico/favicon-32x32.png'
})
);
}
await interaction.reply('Show info');
return PaginatedEmbed.run(interaction);
}
private getData(query: string): Promise<ResponseData> {
return new Promise(async function (resolve, reject) {
const url = `http://api.tvmaze.com/search/shows?q=${encodeURI(query)}`;
try {
const response = await axios.get(url);
if (response.status == 429) {
reject(':x: Rate Limit exceeded. Please try again in a few minutes.');
}
if (response.status == 503) {
reject(
':x: The service is currently unavailable. Please try again later.'
);
}
if (response.status !== 200) {
reject(
'There was a problem getting data from the API, make sure you entered a valid TV show name'
);
}
const data = response.data;
if (!data.length) {
reject(
'There was a problem getting data from the API, make sure you entered a valid TV show name'
);
}
resolve(data);
} catch (e) {
Logger.error(e);
reject(
'There was a problem getting data from the API, make sure you entered a valid TV show name'
);
}
});
}
private constructInfoObject(show: any): InfoObject {
return {
name: show.name,
url: show.url,
summary: this.filterSummary(show.summary),
language: this.checkIfNull(show.language),
genres: this.checkGenres(show.genres),
type: this.checkIfNull(show.type),
premiered: this.checkIfNull(show.premiered),
network: this.checkNetwork(show.network),
runtime: show.runtime ? show.runtime + ' Minutes' : 'None Listed',
rating: show.ratings ? show.rating.average : 'None Listed',
thumbnail: show.image
? show.image.original
: 'https://static.tvmaze.com/images/no-img/no-img-portrait-text.png'
};
}
private filterSummary(summary: string) {
return summary
.replace(/<(\/)?b>/g, '**')
.replace(/<(\/)?i>/g, '*')
.replace(/<(\/)?p>/g, '')
.replace(/<br>/g, '\n')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&apos;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&amp;/g, '&')
.replace(/&#39;/g, "'");
}
private checkGenres(genres: Genres) {
if (Array.isArray(genres)) {
if (genres.join(' ').trim().length == 0) return 'None Listed';
return genres.join(' ');
} else if (!genres.length) {
return 'None Listed';
}
return genres;
}
private checkIfNull(value: string) {
if (!value) {
return 'None Listed';
}
return value;
}
private checkNetwork(network: any) {
if (!network) return 'None Listed';
return `(**${network.country.code}**) ${network.name}`;
}
}
type InfoObject = {
name: string;
url: string;
summary: string;
language: string;
genres: string;
type: string;
premiered: string;
network: string;
runtime: string;
rating: string;
thumbnail: string;
};
type Genres = string | Array<string>;
type ResponseData = string | Array<any>;

View File

@@ -0,0 +1,59 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { EmbedBuilder } from 'discord.js';
import axios from 'axios';
import Logger from '../../lib/logger';
@ApplyOptions<CommandOptions>({
name: 'urban',
description: 'Get definitions from urban dictionary',
preconditions: ['GuildOnly', 'isCommandDisabled']
})
export class UrbanCommand extends Command {
public override registerApplicationCommands(
registry: Command.Registry
): void {
registry.registerChatInputCommand(builder =>
builder
.setName(this.name)
.setDescription(this.description)
.addStringOption(option =>
option
.setName('query')
.setDescription('What term do you want to look up?')
.setRequired(true)
)
);
}
public override chatInputRun(
interaction: Command.ChatInputCommandInteraction
) {
const query = interaction.options.getString('query', true);
axios
.get(`https://api.urbandictionary.com/v0/define?term=${query}`)
.then(async response => {
const definition: string = response.data.list[0].definition;
const embed = new EmbedBuilder()
.setColor('DarkOrange')
.setAuthor({
name: 'Urban Dictionary',
url: 'https://urbandictionary.com',
iconURL: 'https://i.imgur.com/vdoosDm.png'
})
.setDescription(definition)
.setURL(response.data.list[0].permalink)
.setTimestamp()
.setFooter({
text: 'Powered by UrbanDictionary'
});
return interaction.reply({ embeds: [embed] });
})
.catch(async error => {
Logger.error(error);
return interaction.reply({
content: 'Failed to deliver definition :sob:'
});
});
}
}

33
apps/bot/src/env.ts Normal file
View File

@@ -0,0 +1,33 @@
import { createEnv } from '@t3-oss/env-core';
import { z } from 'zod';
export const env = createEnv({
/*
* Specify what prefix the client-side variables must have.
* This is enforced both on type-level and at runtime.
*/
clientPrefix: 'PUBLIC_',
server: {
DISCORD_TOKEN: z.string(),
TENOR_API: z.string(),
RAWG_API: z.string().optional(),
// Redis
REDIS_HOST: z.string().optional(),
REDIS_PORT: z.string().optional(),
REDIS_PASSWORD: z.string().optional(),
REDIS_DB: z.string().optional(),
// Lavalink
LAVA_HOST: z.string().optional(),
LAVA_PORT: z.string().optional(),
LAVA_PASS: z.string().optional(),
LAVA_SECURE: z.string().optional(),
SPOTIFY_CLIENT_ID: z.string().optional(),
SPOTIFY_CLIENT_SECRET: z.string().optional()
},
client: {},
/**
* What object holds the environment variables at runtime.
* Often `process.env` or `import.meta.env`
*/
runtimeEnv: process.env
});

76
apps/bot/src/index.ts Normal file
View File

@@ -0,0 +1,76 @@
import { ExtendedClient } from './lib/structures/ExtendedClient';
import { env } from './env';
import { load } from '@lavaclient/spotify';
import {
ApplicationCommandRegistries,
RegisterBehavior
} from '@sapphire/framework';
import { ActivityType } from 'discord.js';
ApplicationCommandRegistries.setDefaultBehaviorWhenNotIdentical(
RegisterBehavior.Overwrite
);
if (env.SPOTIFY_CLIENT_ID && env.SPOTIFY_CLIENT_SECRET) {
load({
client: {
id: env.SPOTIFY_CLIENT_ID,
secret: env.SPOTIFY_CLIENT_SECRET
},
autoResolveYoutubeTracks: true
});
}
const client = new ExtendedClient();
client.on('ready', () => {
client.music.connect(client.user!.id);
client.user?.setActivity('/', {
type: ActivityType.Watching
});
client.user?.setStatus('online');
});
client.on('chatInputCommandError', err => {
console.log('Command Chat Input ' + err);
});
client.on('contextMenuCommandError', err => {
console.log('Command Context Menu ' + err);
});
client.on('commandAutocompleteInteractionError', err => {
console.log('Command Autocomplete ' + err);
});
client.on('commandApplicationCommandRegistryError', err => {
console.log('Command Registry ' + err);
});
client.on('messageCommandError', err => {
console.log('Command ' + err);
});
client.on('interactionHandlerError', err => {
console.log('Interaction ' + err);
});
client.on('interactionHandlerParseError', err => {
console.log('Interaction Parse ' + err);
});
client.on('listenerError', err => {
console.log('Client Listener ' + err);
});
// LavaLink
client.music.on('error', err => {
console.log('LavaLink ' + err);
});
const main = async () => {
try {
await client.login(env.DISCORD_TOKEN);
} catch (error) {
console.log('Bot errored out', error);
client.destroy();
process.exit(1);
}
};
void main();

View File

@@ -0,0 +1,4 @@
import { join } from 'path';
export const rootDir = join(__dirname, '..', '..');
export const srcDir = join(rootDir, 'src');

View File

@@ -0,0 +1,54 @@
import winston from 'winston';
import 'winston-daily-rotate-file';
const levels = {
error: 0,
warn: 1,
info: 2,
http: 3,
debug: 4
};
const level = () => {
const env = process.env.NODE_ENV || 'development';
const isDevelopment = env === 'development';
return isDevelopment ? 'debug' : 'warn';
};
const colors = {
error: 'red',
warn: 'yellow',
info: 'green',
http: 'magenta',
debug: 'white'
};
winston.addColors(colors);
const format = winston.format.combine(
winston.format.timestamp({ format: 'MM-DD-YYYY HH:mm:ss' }),
// winston.format.colorize({ level: true }),
winston.format.printf(
info => `${info.timestamp} ${info.level}: ${info.message}`
)
);
const transports = [
new winston.transports.Console(),
new winston.transports.DailyRotateFile({
dirname: './logs',
filename: 'Master-Bot-%DATE%.log',
datePattern: 'MM-DD-YYYY',
zippedArchive: true,
maxSize: '20m',
maxFiles: '14d'
})
];
const Logger = winston.createLogger({
level: level(),
levels,
format,
transports
});
export default Logger;

View File

@@ -0,0 +1,64 @@
import type { Song } from './classes/Song';
import { container } from '@sapphire/framework';
import type { Queue } from './classes/Queue';
import {
Message,
ActionRowBuilder,
ButtonBuilder,
EmbedBuilder,
ButtonStyle
} from 'discord.js';
import buttonsCollector, { deletePlayerEmbed } from './buttonsCollector';
export async function embedButtons(
embed: EmbedBuilder,
queue: Queue,
song: Song,
message?: string
) {
await deletePlayerEmbed(queue);
const { client } = container;
const tracks = await queue.tracks();
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId('playPause')
.setLabel('Play/Pause')
.setStyle(ButtonStyle.Primary),
new ButtonBuilder()
.setCustomId('stop')
.setLabel('Stop')
.setStyle(ButtonStyle.Danger),
new ButtonBuilder()
.setCustomId('next')
.setLabel('Next')
.setStyle(ButtonStyle.Primary)
.setDisabled(!tracks.length ? true : false),
new ButtonBuilder()
.setCustomId('volumeUp')
.setLabel('Vol+')
.setStyle(ButtonStyle.Primary),
new ButtonBuilder()
.setCustomId('volumeDown')
.setLabel('Vol-')
.setStyle(ButtonStyle.Primary)
);
const channel = await queue.getTextChannel();
if (!channel) return;
return await channel
.send({
embeds: [embed],
components: [row],
content: message
})
.then(async (message: Message) => {
const queue = client.music.queues.get(message.guild!.id);
await queue.setEmbed(message.id);
if (queue.player) {
await buttonsCollector(message, song);
}
});
}

View File

@@ -0,0 +1,131 @@
import { Time } from '@sapphire/time-utilities';
import type { Message, MessageComponentInteraction } from 'discord.js';
import { container } from '@sapphire/framework';
import type { Queue } from './classes/Queue';
import { NowPlayingEmbed } from './nowPlayingEmbed';
import type { Song } from './classes/Song';
import Logger from '../logger';
export default async function buttonsCollector(message: Message, song: Song) {
const { client } = container;
const queue = client.music.queues.get(message.guildId!);
const channel = await queue.getTextChannel();
const collector = message.createMessageComponentCollector();
if (!channel) return;
const maxLimit = Time.Minute * 30;
let timer: NodeJS.Timer;
collector.on('collect', async (i: MessageComponentInteraction) => {
if (!message.member?.voice.channel?.members.has(i.user.id)) {
await i.reply({
content: `:x: Only available to members in ${message.member?.voice.channel} <-- Click To Join`,
ephemeral: true
});
return;
}
if (i.customId === 'playPause') {
if (queue.paused) {
await queue.resume();
clearTimeout(client.leaveTimers[queue.guildID]!);
} else {
client.leaveTimers[queue.guildID] = setTimeout(async () => {
await channel.send(':zzz: Leaving due to inactivity');
await queue.leave();
}, maxLimit);
await queue.pause();
}
const tracks = await queue.tracks();
const NowPlaying = new NowPlayingEmbed(
song,
queue.player.accuratePosition,
queue.player.trackData?.length ?? 0,
queue.player.volume,
tracks,
tracks.at(-1),
queue.player.paused
);
collector.empty();
await i.update({
embeds: [await NowPlaying.NowPlayingEmbed()]
});
return;
}
if (i.customId === 'stop') {
clearTimeout(timer);
await queue.leave();
return;
}
if (i.customId === 'next') {
clearTimeout(timer);
await queue.next({ skipped: true });
return;
}
if (i.customId === 'volumeUp') {
const currentVolume = await queue.getVolume();
const volume = currentVolume + 10 > 200 ? 200 : currentVolume + 10;
await queue.setVolume(volume);
const tracks = await queue.tracks();
const NowPlaying = new NowPlayingEmbed(
song,
queue.player.accuratePosition,
queue.player.trackData?.length ?? 0,
queue.player.volume,
tracks,
tracks.at(-1),
queue.player.paused
);
collector.empty();
await i.update({
embeds: [await NowPlaying.NowPlayingEmbed()]
});
return;
}
if (i.customId === 'volumeDown') {
const currentVolume = await queue.getVolume();
const volume = currentVolume - 10 < 0 ? 0 : currentVolume - 10;
await queue.setVolume(volume);
const tracks = await queue.tracks();
const NowPlaying = new NowPlayingEmbed(
song,
queue.player.accuratePosition,
queue.player.trackData?.length ?? 0,
queue.player.volume,
tracks,
tracks.at(-1),
queue.player.paused
);
collector.empty();
await i.update({ embeds: [await NowPlaying.NowPlayingEmbed()] });
return;
}
});
collector.on('end', async () => {
clearTimeout(timer);
});
return collector;
}
export async function deletePlayerEmbed(queue: Queue) {
try {
const embedID = await queue.getEmbed();
if (embedID) {
const channel = await queue.getTextChannel();
await channel?.messages.fetch(embedID).then(async oldMessage => {
if (oldMessage)
await oldMessage.delete().catch(error => {
Logger.error('Failed to Delete Old Message. ' + error);
});
await queue.deleteEmbed();
});
}
} catch (error) {
Logger.error('Failed to Delete Player Embed. ' + error);
}
}

View File

@@ -0,0 +1,58 @@
import type { Queue } from './classes/Queue';
import { Channel, GuildMember, ChannelType } from 'discord.js';
import Logger from '../logger';
export async function manageStageChannel(
voiceChannel: Channel,
botUser: GuildMember,
instance: Queue
) {
if (voiceChannel.type !== ChannelType.GuildStageVoice) return;
// Stage Channel Permissions From Discord.js Doc's
if (
!botUser?.permissions.has(
('ManageChannels' && 'MuteMembers' && 'MoveMembers') || 'ADMINISTRATOR'
)
)
if (botUser.voice.suppress)
return await instance.getTextChannel().then(
async msg =>
await msg?.send({
content: `:interrobang: Please make promote me to a Speaker in ${voiceChannel.name}, Missing permissions "Administrator" ***OR*** "Manage Channels, Mute Members, and Move Members" for Full Stage Channel Features.`
})
);
const tracks = await instance.tracks();
const title =
instance.player.trackData?.title.length! > 114
? `🎶 ${
instance.player.trackData?.title.slice(0, 114) ??
tracks.at(0)?.title.slice(0, 114)
}...`
: `🎶 ${instance.player.trackData?.title ?? tracks.at(0)?.title ?? ''}`;
if (!voiceChannel.stageInstance) {
await voiceChannel
.createStageInstance({
topic: title,
privacyLevel: 2 // Guild Only
})
.catch(error => {
Logger.error('Failed to Create a Stage Instance. ' + error);
});
}
if (botUser?.voice.suppress)
await botUser?.voice.setSuppressed(false).catch((error: string) => {
Logger.error('Failed to Set Suppressed to False. ' + error);
});
if (
voiceChannel.stageInstance?.topic.startsWith('🎶') &&
voiceChannel.stageInstance?.topic !== title
) {
await voiceChannel.stageInstance?.setTopic(title).catch(error => {
Logger.error('Failed to Set Topic. ' + error);
});
}
return;
}

View File

@@ -0,0 +1,471 @@
// Inspired from skyra's queue(when it had a music feature)
import type {
CommandInteraction,
Guild,
GuildMember,
TextChannel,
VoiceChannel
} from 'discord.js';
import type { Song } from './Song';
import type { Track } from '@lavaclient/types/v3';
import type { DiscordResource, Player, Snowflake } from 'lavaclient';
import { container } from '@sapphire/framework';
import type { QueueStore } from './QueueStore';
import { Time } from '@sapphire/time-utilities';
import { isNullish } from '@sapphire/utilities';
import { deletePlayerEmbed } from '../buttonsCollector';
import { trpcNode } from '../../../trpc';
import Logger from '../../logger';
export enum LoopType {
None,
Queue,
Song
}
const kExpireTime = Time.Day * 2;
export interface QueueEvents {
trackStart: (song: Song) => void;
trackEnd: (song: Song) => void;
finish: () => void;
}
export interface Loop {
type: LoopType;
current: number;
max: number;
}
export interface AddOptions {
requester?: Snowflake | DiscordResource;
userInfo?: GuildMember;
added?: number;
next?: boolean;
}
export type Addable = string | Track | Song;
interface NowPlaying {
song: Song;
position: number;
}
interface QueueKeys {
readonly next: string;
readonly position: string;
readonly current: string;
readonly skips: string;
readonly systemPause: string;
readonly replay: string;
readonly volume: string;
readonly text: string;
readonly embed: string;
}
export class Queue {
public readonly keys: QueueKeys;
private skipped: boolean;
public constructor(
public readonly store: QueueStore,
public readonly guildID: string
) {
this.keys = {
current: `music.${this.guildID}.current`,
next: `music.${this.guildID}.next`,
position: `music.${this.guildID}.position`,
skips: `music.${this.guildID}.skips`,
systemPause: `music.${this.guildID}.systemPause`,
replay: `music.${this.guildID}.replay`,
volume: `music.${this.guildID}.volume`,
text: `music.${this.guildID}.text`,
embed: `music.${this.guildID}.embed`
};
this.skipped = false;
}
public get client() {
return container.client;
}
public get player(): Player {
return this.store.client.players.get(this.guildID)!;
}
public get playing(): boolean {
return this.player.playing;
}
public get paused(): boolean {
return this.player.paused;
}
public get guild(): Guild {
return this.client.guilds.cache.get(this.guildID) as Guild;
}
public get voiceChannel(): VoiceChannel | null {
const id = this.voiceChannelID;
return id
? (this.guild.channels.cache.get(id) as VoiceChannel) ?? null
: null;
}
public get voiceChannelID(): string | null {
if (!this.player) return null;
return this.player.channelId ?? null;
}
public createPlayer(): Player {
let player = this.player;
if (!player) {
player = this.store.client.createPlayer(this.guildID);
player.on('trackEnd', async () => {
if (!this.skipped) {
await this.next();
}
this.skipped = false;
});
}
return player;
}
public destroyPlayer(): void {
if (this.player) {
this.store.client.destroyPlayer(this.guildID);
}
}
// Start the queue
public async start(replaying = false): Promise<boolean> {
const np = await this.nowPlaying();
if (!np) return this.next();
try {
this.player.setVolume(await this.getVolume());
await this.player.play(np.song as Song);
} catch (err) {
Logger.error(err);
await this.leave();
}
this.client.emit(
replaying ? 'musicSongReplay' : 'musicSongPlay',
this,
np.song as Song
);
return true;
}
// Returns whether or not there are songs that can be played
public async canStart(): Promise<boolean> {
return (
(await this.store.redis.exists(this.keys.current, this.keys.next)) > 0
);
}
// add tracks to queue
public async add(
songs: Song | Array<Song>,
options: AddOptions = {}
): Promise<number> {
songs = Array.isArray(songs) ? songs : [songs];
if (!songs.length) return 0;
await this.store.redis.lpush(
this.keys.next,
...songs.map(song => this.stringifySong(song))
);
await this.refresh();
return songs.length;
}
public async pause(interaction?: CommandInteraction) {
await this.player.pause(true);
await this.setSystemPaused(false);
if (interaction) {
this.client.emit('musicSongPause', interaction);
}
}
public async resume(interaction?: CommandInteraction) {
await this.player.pause(false);
await this.setSystemPaused(false);
if (interaction) {
this.client.emit('musicSongResume', interaction);
}
}
public async getSystemPaused(): Promise<boolean> {
return await this.store.redis
.get(this.keys.systemPause)
.then(d => d === '1');
}
public async setSystemPaused(value: boolean): Promise<boolean> {
await this.store.redis.set(this.keys.systemPause, value ? '1' : '0');
await this.refresh();
return value;
}
/**
* Retrieves whether or not the system should repeat the current track.
*/
public async getReplay(): Promise<boolean> {
return await this.store.redis.get(this.keys.replay).then(d => d === '1');
}
public async setReplay(value: boolean): Promise<boolean> {
await this.store.redis.set(this.keys.replay, value ? '1' : '0');
await this.refresh();
this.client.emit('musicReplayUpdate', this, value);
return value;
}
/**
* Retrieves the volume of the track in the queue.
*/
public async getVolume(): Promise<number> {
let data = await this.store.redis.get(this.keys.volume);
if (!data) {
const guildQuery = await trpcNode.guild.getGuild.query({
id: this.guildID
});
if (!guildQuery || !guildQuery.guild)
await this.setVolume(this.player.volume ?? 100); // saves to both
if (guildQuery.guild)
data =
guildQuery.guild.volume.toString() || this.player.volume.toString();
}
return data ? Number(data) : 100;
}
// set the volume of the track in the queue
public async setVolume(
value: number
): Promise<{ previous: number; next: number }> {
await this.player.setVolume(value);
const previous = await this.store.redis.getset(this.keys.volume, value);
await this.refresh();
await trpcNode.guild.updateVolume.mutate({
guildId: this.guildID,
volume: this.player.volume
});
this.client.emit('musicSongVolumeUpdate', this, value);
return {
previous: previous === null ? 100 : Number(previous),
next: value
};
}
public async seek(position: number): Promise<void> {
await this.player.seek(position);
}
// connect to a voice channel
public async connect(channelID: string): Promise<void> {
await this.player.connect(channelID, { deafened: true });
}
// leave the voice channel
public async leave(): Promise<void> {
if (await this.getEmbed()) {
await deletePlayerEmbed(this);
}
if (this.client.leaveTimers[this.guildID]) {
clearTimeout(this.client.leaveTimers[this.player.guildId]);
delete this.client.leaveTimers[this.player.guildId];
}
if (!this.player) return;
await this.player.disconnect();
await this.destroyPlayer();
await this.setTextChannelID(null);
await this.clear();
}
public async getTextChannel(): Promise<TextChannel | null> {
const id = await this.getTextChannelID();
if (id === null) return null;
const channel = this.guild.channels.cache.get(id) ?? null;
if (channel === null) {
await this.setTextChannelID(null);
return null;
}
return channel as TextChannel;
}
public getTextChannelID(): Promise<string | null> {
return this.store.redis.get(this.keys.text);
}
public setTextChannelID(channelID: null): Promise<null>;
public async setTextChannelID(channelID: string): Promise<string>;
public async setTextChannelID(
channelID: string | null
): Promise<string | null> {
if (channelID === null) {
await this.store.redis.del(this.keys.text);
} else {
await this.store.redis.set(this.keys.text, channelID);
await this.refresh();
}
return channelID;
}
public async getCurrentTrack(): Promise<Song | null> {
const value = await this.store.redis.get(this.keys.current);
return value ? this.parseSongString(value) : null;
}
public async getAt(index: number): Promise<Song | undefined> {
const value = await this.store.redis.lindex(this.keys.next, -index - 1);
return value ? this.parseSongString(value) : undefined;
}
public async removeAt(position: number): Promise<void> {
await this.store.redis.lremat(this.keys.next, -position - 1);
await this.refresh();
}
public async next({ skipped = false } = {}): Promise<boolean> {
if (skipped) this.skipped = true;
// Sets the current position to 0.
await this.store.redis.del(this.keys.position);
// Get whether or not the queue is on replay mode.
const replaying = await this.getReplay();
// If not skipped (song ended) and is replaying, replay.
if (!skipped && replaying) {
return await this.start(true);
}
// If it was skipped, set replay back to false.
if (replaying) await this.setReplay(false);
// Removes the next entry from the list and sets it as the current track.
const entry = await this.store.redis.rpopset(
this.keys.next,
this.keys.current
);
// If there was an entry to play, refresh the state and start playing.
if (entry) {
await this.refresh();
return this.start(false);
} else {
// If there was no entry, disconnect from the voice channel.
await this.leave();
this.client.emit('musicFinish', this, true);
return false;
}
}
public count(): Promise<number> {
return this.store.redis.llen(this.keys.next);
}
public async moveTracks(from: number, to: number): Promise<void> {
await this.store.redis.lmove(this.keys.next, -from - 1, -to - 1); // work from the end of the list, since it's reversed
await this.refresh();
}
public async shuffleTracks(): Promise<void> {
await this.store.redis.lshuffle(this.keys.next, Date.now());
await this.refresh();
}
public async stop(): Promise<void> {
await this.player.stop();
}
public async clearTracks(): Promise<void> {
await this.store.redis.del(this.keys.next);
}
public async skipTo(position: number): Promise<void> {
await this.store.redis.ltrim(this.keys.next, 0, position - 1);
await this.next({ skipped: true });
}
public refresh() {
return this.store.redis
.pipeline()
.pexpire(this.keys.next, kExpireTime)
.pexpire(this.keys.position, kExpireTime)
.pexpire(this.keys.current, kExpireTime)
.pexpire(this.keys.skips, kExpireTime)
.pexpire(this.keys.systemPause, kExpireTime)
.pexpire(this.keys.replay, kExpireTime)
.pexpire(this.keys.volume, kExpireTime)
.pexpire(this.keys.text, kExpireTime)
.pexpire(this.keys.embed, kExpireTime)
.exec();
}
public clear(): Promise<number> {
return this.store.redis.del(
this.keys.next,
this.keys.position,
this.keys.current,
this.keys.skips,
this.keys.systemPause,
this.keys.replay,
this.keys.volume,
this.keys.text,
this.keys.embed
);
}
public async nowPlaying(): Promise<NowPlaying | null> {
const [entry, position] = await Promise.all([
this.getCurrentTrack(),
this.store.redis.get(this.keys.position)
]);
if (entry === null) return null;
return {
song: entry,
position: isNullish(position) ? 0 : parseInt(position, 10)
};
}
public async tracks(start = 0, end = -1): Promise<Song[]> {
if (end === Infinity) end = -1;
const tracks = await this.store.redis.lrange(this.keys.next, start, end);
return [...tracks].map(this.parseSongString).reverse();
}
public async setEmbed(id: string): Promise<void> {
await this.store.redis.set(this.keys.embed, id);
}
public async getEmbed(): Promise<string | null> {
return this.store.redis.get(this.keys.embed);
}
public async deleteEmbed(): Promise<void> {
await this.store.redis.del(this.keys.embed);
}
public stringifySong(song: Song): string {
return JSON.stringify(song);
}
public parseSongString(song: string): Song {
return JSON.parse(song);
}
}

View File

@@ -0,0 +1,30 @@
import Redis from 'ioredis';
import type { RedisOptions } from 'ioredis';
import { ConnectionInfo, Node, SendGatewayPayload } from 'lavaclient';
import { QueueStore } from './QueueStore';
export interface QueueClientOptions {
redis: Redis | RedisOptions;
}
export interface ConstructorTypes {
options: QueueClientOptions;
sendGatewayPayload: SendGatewayPayload;
connection: ConnectionInfo;
}
export class QueueClient extends Node {
public readonly queues: QueueStore;
public constructor({
options,
sendGatewayPayload,
connection
}: ConstructorTypes) {
super({ ...options, sendGatewayPayload, connection });
this.queues = new QueueStore(
this,
options.redis instanceof Redis ? options.redis : new Redis(options.redis)
);
}
}

View File

@@ -0,0 +1,107 @@
import { Collection } from 'discord.js';
import { readFileSync } from 'fs';
import type { Redis, RedisKey } from 'ioredis';
import { join, resolve } from 'path';
import { Queue } from './Queue';
import type { QueueClient } from './QueueClient';
import Logger from '../../logger';
interface RedisCommand {
name: string;
keys: number;
}
const commands: RedisCommand[] = [
{
name: 'lmove',
keys: 1
},
{
name: 'lremat',
keys: 1
},
{
name: 'lshuffle',
keys: 1
},
{
name: 'rpopset',
keys: 2
}
];
//@ts-ignore
export interface ExtendedRedis extends Redis {
lmove: (key: RedisKey, from: number, to: number) => Promise<'OK'>;
lremat: (key: RedisKey, index: number) => Promise<'OK'>;
lshuffle: (key: RedisKey, seed: number) => Promise<'OK'>;
rpopset: (source: RedisKey, destination: RedisKey) => Promise<string | null>;
}
export class QueueStore extends Collection<string, Queue> {
public redis: ExtendedRedis;
public constructor(
public readonly client: QueueClient,
redis: Redis
) {
super();
this.redis = redis as any;
// Redis Errors
redis.on('error', err => {
Logger.error('Redis ' + err);
});
for (const command of commands) {
this.redis.defineCommand(command.name, {
numberOfKeys: command.keys,
lua: readFileSync(
resolve(
join(__dirname, '..', '..', '..'),
'audio',
`${command.name}.lua`
)
).toString()
});
}
}
public get(key: string): Queue {
let queue = super.get(key);
if (!queue) {
queue = new Queue(this, key);
this.set(key, queue);
}
return queue;
}
public async start() {
const guilds = await this.getPlayingEntries();
await Promise.all(guilds.map(guild => this.get(guild).start()));
}
private async getPlayingEntries(): Promise<string[]> {
const guilds = new Set<string>();
let cursor = '0';
do {
// `scan` returns a tuple with the next cursor (which must be used for the
// next iteration) and an array of the matching keys. The iterations end when
// cursor becomes '0' again.
const response = await this.redis.scan(
cursor,
'MATCH',
'music.*.position'
);
[cursor] = response;
for (const key of response[1]) {
// Slice 'skyra.a.' from the start, and '.p' from the end:
const id = key.slice(8, -2);
guilds.add(id);
}
} while (cursor !== '0');
return [...guilds];
}
}

View File

@@ -0,0 +1,94 @@
import { decode } from '@lavalink/encoding';
import type { Track, TrackInfo } from '@lavaclient/types/v3';
import * as MetadataFilter from 'metadata-filter';
export class Song implements TrackInfo {
readonly track: string;
requester?: RequesterInfo;
length: number;
identifier: string;
author: string;
isStream: boolean;
position: number;
title: string;
uri: string;
isSeekable: boolean;
sourceName: string;
thumbnail: string;
added: number;
constructor(
track: string | Track,
added?: number,
requester?: RequesterInfo
) {
this.track = typeof track === 'string' ? track : track.track;
this.requester = requester;
this.added = added ?? Date.now();
const filterSet = {
song: [
MetadataFilter.removeVersion,
MetadataFilter.removeRemastered,
MetadataFilter.fixTrackSuffix,
MetadataFilter.removeLive,
MetadataFilter.youtube,
MetadataFilter.normalizeFeature
]
};
const filter = MetadataFilter.createFilter(filterSet);
// TODO: make this less shitty
if (typeof track !== 'string') {
this.length = track.info.length;
this.identifier = track.info.identifier;
this.author = track.info.author;
this.isStream = track.info.isStream;
this.position = track.info.position;
this.title = filter.filterField('song', track.info.title);
this.uri = track.info.uri;
this.isSeekable = track.info.isSeekable;
this.sourceName = track.info.sourceName;
} else {
const decoded = decode(this.track);
this.length = Number(decoded.length);
this.identifier = decoded.identifier;
this.author = decoded.author;
this.isStream = decoded.isStream;
this.position = Number(decoded.position);
this.title = filter.filterField('song', decoded.title);
this.uri = decoded.uri!;
this.isSeekable = !decoded.isStream;
this.sourceName = decoded.source;
}
// Thumbnails
switch (this.sourceName) {
case 'soundcloud': {
this.thumbnail =
'https://a-v2.sndcdn.com/assets/images/sc-icons/fluid-b4e7a64b8b.png'; // SoundCloud Logo
break;
}
case 'youtube': {
this.thumbnail = `https://img.youtube.com/vi/${this.identifier}/hqdefault.jpg`; // Track Thumbnail
break;
}
case 'twitch': {
this.thumbnail = 'https://i.imgur.com/nO3f4jq.png'; // large Twitch Logo
break;
}
default: {
this.thumbnail = 'https://cdn.discordapp.com/embed/avatars/1.png'; // Discord Default Avatar
break;
}
}
}
}
interface RequesterInfo {
avatar?: string | null;
defaultAvatarURL?: string;
id?: string;
name?: string;
}

View File

@@ -0,0 +1,205 @@
// import { container } from '@sapphire/framework';
import { ColorResolvable, EmbedBuilder } from 'discord.js';
import progressbar from 'string-progressbar';
import type { Song } from './classes/Song';
type PositionType = number | undefined;
export class NowPlayingEmbed {
track: Song;
position: PositionType;
length: number;
volume: number;
queue?: Song[];
last?: Song;
paused?: Boolean;
public constructor(
track: Song,
position: PositionType,
length: number,
volume: number,
queue?: Song[],
last?: Song,
paused?: Boolean
) {
this.track = track;
this.position = position;
this.length = length;
this.volume = volume;
this.queue = queue;
this.last = last;
this.paused = paused;
}
public async NowPlayingEmbed(): Promise<EmbedBuilder> {
let trackLength = this.timeString(
this.millisecondsToTimeObject(this.length)
);
const durationText = this.track.isSeekable
? `:stopwatch: ${trackLength}`
: `:red_circle: Live Stream`;
const userAvatar = this.track.requester?.avatar
? `https://cdn.discordapp.com/avatars/${this.track.requester?.id}/${this.track.requester?.avatar}.png`
: this.track.requester?.defaultAvatarURL ??
'https://cdn.discordapp.com/embed/avatars/1.png'; // default Discord Avatar
let embedColor: ColorResolvable;
let sourceTxt: string;
let sourceIcon: string;
let streamData;
switch (this.track.sourceName) {
case 'soundcloud': {
sourceTxt = 'SoundCloud';
sourceIcon =
'https://a-v2.sndcdn.com/assets/images/sc-icons/fluid-b4e7a64b8b.png';
embedColor = '#F26F23';
break;
}
// case 'twitch': {
// sourceTxt = 'Twitch';
// sourceIcon =
// 'https://static.twitchcdn.net/assets/favicon-32-e29e246c157142c94346.png';
// embedColor = 'Purple';
// const twitch = container.client.twitch;
// if (twitch.auth.access_token) {
// try {
// streamData = await twitch.api.getStream({
// login: this.track.author.toLowerCase(),
// token: twitch.auth.access_token
// });
// } catch {
// streamData = undefined;
// }
// }
// break;
// }
case 'youtube': {
sourceTxt = 'YouTube';
sourceIcon =
'https://www.youtube.com/s/desktop/acce624e/img/favicon_32x32.png';
embedColor = '#FF0000';
break;
}
default: {
sourceTxt = 'Somewhere';
sourceIcon = 'https://cdn.discordapp.com/embed/avatars/1.png';
embedColor = 'DarkRed';
break;
}
}
const vol = this.volume;
let volumeIcon: string = ':speaker: ';
if (vol > 50) volumeIcon = ':loud_sound: ';
if (vol <= 50 && vol > 20) volumeIcon = ':sound: ';
const embedFieldData = [
{
name: 'Volume',
value: `${volumeIcon} ${this.volume}%`,
inline: true
},
{ name: 'Duration', value: durationText, inline: true }
];
if (this.queue?.length) {
embedFieldData.push(
{
name: 'Queue',
value: `:notes: ${this.queue.length} ${
this.queue.length == 1 ? 'Song' : 'Songs'
}`,
inline: true
},
{
name: 'Next',
value: `[${this.queue[0].title}](${this.queue[0].uri})`,
inline: false
}
);
}
const baseEmbed = new EmbedBuilder()
.setTitle(
`${this.paused ? ':pause_button: ' : ':arrow_forward: '} ${
this.track.title
}`
)
.setAuthor({
name: sourceTxt,
iconURL: sourceIcon
})
.setURL(this.track.uri)
.setThumbnail(this.track.thumbnail)
.setColor(embedColor)
.addFields(embedFieldData)
.setTimestamp(this.track.added ?? Date.now())
.setFooter({
text: `Requested By ${this.track.requester?.name}`,
iconURL: userAvatar
});
if (!this.track.isSeekable || this.track.isStream) {
if (streamData && this.track.sourceName == 'twitch') {
const game = `[${
streamData.game_name
}](https://www.twitch.tv/directory/game/${encodeURIComponent(
streamData.game_name
)})`;
const upTime = this.timeString(
this.millisecondsToTimeObject(
Date.now() - new Date(streamData.started_at).getTime()
)
);
return baseEmbed
.setDescription(
`**Game**: ${game}\n**Viewers**: ${
streamData.viewer_count
}\n**Uptime**: ${upTime}\n **Started**: <t:${Math.floor(
new Date(streamData.started_at).getTime() / 1000
)}:t>`
)
.setImage(
streamData.thumbnail_url.replace('{width}x{height}', '852x480') +
`?${new Date(streamData.started_at).getTime()}`
);
} else return baseEmbed;
}
// song just started embed
if (this.position == undefined) this.position = 0;
const bar = progressbar.splitBar(this.length, this.position, 22)[0];
baseEmbed.setDescription(
`${this.timeString(
this.millisecondsToTimeObject(this.position)
)} ${bar} ${trackLength}`
);
return baseEmbed;
}
private timeString(timeObject: any) {
if (timeObject[1] === true) return timeObject[0];
return `${timeObject.hours ? timeObject.hours + ':' : ''}${
timeObject.minutes ? timeObject.minutes : '00'
}:${
timeObject.seconds < 10
? '0' + timeObject.seconds
: timeObject.seconds
? timeObject.seconds
: '00'
}`;
}
private millisecondsToTimeObject(milliseconds: number) {
return {
seconds: Math.floor((milliseconds / 1000) % 60),
minutes: Math.floor((milliseconds / (1000 * 60)) % 60),
hours: Math.floor((milliseconds / (1000 * 60 * 60)) % 24)
};
}
}

View File

@@ -0,0 +1,109 @@
import { container } from '@sapphire/framework';
import { SpotifyItemType } from '@lavaclient/spotify';
import { Song } from './classes/Song';
import type { User } from 'discord.js';
export default async function searchSong(
query: string,
user: User
): Promise<[string, Song[]]> {
const { client } = container;
let tracks: Song[] = [];
let response;
let displayMessage = '';
const { avatar, defaultAvatarURL, id, username } = user;
if (client.music.spotify.isSpotifyUrl(query)) {
const item = await client.music.spotify.load(query);
switch (item?.type) {
case SpotifyItemType.Track:
const track = await item.resolveYoutubeTrack();
tracks = [
new Song(track, Date.now(), {
avatar,
defaultAvatarURL,
id,
name: username
})
];
displayMessage = `Queued track [**${item.name}**](${query}).`;
break;
case SpotifyItemType.Artist:
response = await item.resolveYoutubeTracks();
response.forEach(track =>
tracks.push(
new Song(track, Date.now(), {
avatar,
defaultAvatarURL,
id,
name: username
})
)
);
displayMessage = `Queued the **Top ${tracks.length} tracks** for [**${item.name}**](${query}).`;
break;
case SpotifyItemType.Album:
case SpotifyItemType.Playlist:
response = await item.resolveYoutubeTracks();
response.forEach(track =>
tracks.push(
new Song(track, Date.now(), {
avatar,
defaultAvatarURL,
id,
name: username
})
)
);
displayMessage = `Queued **${
tracks.length
} tracks** from ${SpotifyItemType[item.type].toLowerCase()} [**${
item.name
}**](${query}).`;
break;
default:
displayMessage = ":x: Couldn't find what you were looking for :(";
return [displayMessage, tracks];
}
return [displayMessage, tracks];
} else {
const results = await client.music.rest.loadTracks(
/^https?:\/\//.test(query) ? query : `ytsearch:${query}`
);
switch (results.loadType) {
case 'LOAD_FAILED':
case 'NO_MATCHES':
displayMessage = ":x: Couldn't find what you were looking for :(";
return [displayMessage, tracks];
case 'PLAYLIST_LOADED':
results.tracks.forEach((track: any) =>
tracks.push(
new Song(track, Date.now(), {
avatar,
defaultAvatarURL,
id,
name: username
})
)
);
displayMessage = `Queued playlist [**${results.playlistInfo.name}**](${query}), it has a total of **${tracks.length}** tracks.`;
break;
case 'TRACK_LOADED':
case 'SEARCH_RESULT':
const [track] = results.tracks;
tracks = [
new Song(track, Date.now(), {
avatar,
defaultAvatarURL,
id,
name: username
})
];
displayMessage = `Queued [**${track.info.title}**](${track.info.uri})`;
break;
}
return [displayMessage, tracks];
}
}

20
apps/bot/src/lib/setup.ts Normal file
View File

@@ -0,0 +1,20 @@
import {
ApplicationCommandRegistries,
RegisterBehavior
} from '@sapphire/framework';
import '@sapphire/plugin-api/register';
import '@sapphire/plugin-editable-commands/register';
import '@sapphire/plugin-subcommands/register';
import * as colorette from 'colorette';
import { inspect } from 'util';
// Set default behavior to bulk overwrite
ApplicationCommandRegistries.setDefaultBehaviorWhenNotIdentical(
RegisterBehavior.BulkOverwrite
);
// Set default inspection depth
inspect.defaultOptions.depth = 1;
// Enable colorette
colorette.createColors({ useColor: true });

View File

@@ -0,0 +1,79 @@
import { SapphireClient } from '@sapphire/framework';
import '@sapphire/plugin-hmr/register';
import { QueueClient } from '../music/classes/QueueClient';
import Redis from 'ioredis';
import { GatewayDispatchEvents, IntentsBitField } from 'discord.js';
import { deletePlayerEmbed } from '../music/buttonsCollector';
export class ExtendedClient extends SapphireClient {
readonly music: QueueClient;
leaveTimers: { [key: string]: NodeJS.Timer };
public constructor() {
super({
intents: [
IntentsBitField.Flags.Guilds,
IntentsBitField.Flags.GuildMembers,
IntentsBitField.Flags.GuildMessages,
IntentsBitField.Flags.GuildMessageReactions,
IntentsBitField.Flags.GuildVoiceStates
],
logger: { level: 100 },
loadMessageCommandListeners: true,
hmr: {
enabled: process.env.NODE_ENV === 'development'
}
});
this.music = new QueueClient({
sendGatewayPayload: (id, payload) =>
this.guilds.cache.get(id)?.shard?.send(payload),
options: {
redis: new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: Number.parseInt(process.env.REDIS_PORT!) || 6379,
password: process.env.REDIS_PASSWORD || '',
db: Number.parseInt(process.env.REDIS_DB!) || 0
})
},
connection: {
host: process.env.LAVA_HOST || '',
password: process.env.LAVA_PASS || '',
port: process.env.LAVA_PORT ? +process.env.LAVA_PORT : 1339,
secure: process.env.LAVA_SECURE === 'true' ? true : false
}
});
this.ws.on(GatewayDispatchEvents.VoiceServerUpdate, async data => {
await this.music.handleVoiceUpdate(data);
});
this.ws.on(GatewayDispatchEvents.VoiceStateUpdate, async data => {
// handle if a mod right-clicks disconnect on the bot
if (!data.channel_id && data.user_id == this.application?.id) {
const queue = this.music.queues.get(data.guild_id);
await deletePlayerEmbed(queue);
await queue.clear();
queue.destroyPlayer();
}
await this.music.handleVoiceUpdate(data);
});
this.leaveTimers = {};
}
}
declare module '@sapphire/framework' {
interface SapphireClient {
readonly music: QueueClient;
leaveTimers: { [key: string]: NodeJS.Timer };
}
}
declare module 'lavaclient' {
interface Player {
nightcore: boolean;
vaporwave: boolean;
karaoke: boolean;
bassboost: boolean;
}
}

View File

@@ -0,0 +1,24 @@
import { ApplyOptions } from '@sapphire/decorators';
import {
type ChatInputCommandDeniedPayload,
Listener,
type ListenerOptions,
UserError
} from '@sapphire/framework';
@ApplyOptions<ListenerOptions>({
name: 'chatInputCommandDenied'
})
export class CommandDeniedListener extends Listener {
public override async run(
{ context, message: content }: UserError,
{ interaction }: ChatInputCommandDeniedPayload
): Promise<void> {
await interaction.reply({
ephemeral: true,
content: content
});
return;
}
}

View File

@@ -0,0 +1,24 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Listener, type ListenerOptions } from '@sapphire/framework';
import type { Guild } from 'discord.js';
import { trpcNode } from '../../trpc';
@ApplyOptions<ListenerOptions>({
name: 'guildCreate'
})
export class GuildCreateListener extends Listener {
public override async run(guild: Guild): Promise<void> {
const owner = await guild.fetchOwner();
await trpcNode.user.create.mutate({
id: owner.id,
name: owner.user.username
});
await trpcNode.guild.create.mutate({
id: guild.id,
name: guild.name,
ownerId: owner.id
});
}
}

View File

@@ -0,0 +1,15 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Listener, type ListenerOptions } from '@sapphire/framework';
import type { Guild } from 'discord.js';
import { trpcNode } from '../../trpc';
@ApplyOptions<ListenerOptions>({
name: 'guildDelete'
})
export class GuildDeleteListener extends Listener {
public override async run(guild: Guild): Promise<void> {
await trpcNode.guild.delete.mutate({
id: guild.id
});
}
}

View File

@@ -0,0 +1,38 @@
//import type { Guild } from '@prisma/client';
import { ApplyOptions } from '@sapphire/decorators';
import { Listener, type ListenerOptions } from '@sapphire/framework';
import type { GuildMember, TextChannel } from 'discord.js';
import { trpcNode } from '../../trpc';
@ApplyOptions<ListenerOptions>({
name: 'guildMemberAdd'
})
export class GuildMemberListener extends Listener {
public override async run(member: GuildMember): Promise<void> {
const guildQuery = await trpcNode.guild.getGuild.query({
id: member.guild.id
});
if (!guildQuery || !guildQuery.guild) return;
const { welcomeMessage, welcomeMessageEnabled, welcomeMessageChannel } =
guildQuery.guild;
if (
!welcomeMessageEnabled ||
!welcomeMessage ||
!welcomeMessage.length ||
!welcomeMessageChannel
) {
return;
}
const channel = (await member.guild.channels.fetch(
welcomeMessageChannel
)) as TextChannel;
if (channel) {
await channel.send({ content: `@${member.id} ${welcomeMessage}` });
}
}
}

View File

@@ -0,0 +1,25 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Listener, type ListenerOptions, container } from '@sapphire/framework';
import { deletePlayerEmbed } from '../../lib/music/buttonsCollector';
import type { Queue } from '../../lib/music/classes/Queue';
// import { inactivityTime } from '../../lib/music/handleOptions';
@ApplyOptions<ListenerOptions>({
name: 'musicFinish'
})
export class MusicFinishListener extends Listener {
public override async run(
queue: Queue,
skipped: boolean = false
): Promise<void> {
const channel = await queue.getTextChannel();
const { client } = container;
await deletePlayerEmbed(queue);
if (skipped) return;
client.leaveTimers[queue.player.guildId] = setTimeout(async () => {
if (channel) queue.client.emit('musicFinishNotify', channel);
await queue.leave();
// }, inactivityTime());
}, 30000);
}
}

View File

@@ -0,0 +1,12 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Listener, type ListenerOptions } from '@sapphire/framework';
import type { TextChannel } from 'discord.js';
@ApplyOptions<ListenerOptions>({
name: 'musicFinishNotify'
})
export class MusicFinishNotifyListener extends Listener {
public override async run(channel: TextChannel): Promise<void> {
await channel.send({ content: ':zzz: Leaving due to inactivity' });
}
}

View File

@@ -0,0 +1,15 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Listener, type ListenerOptions } from '@sapphire/framework';
import type { ChatInputCommandInteraction } from 'discord.js';
@ApplyOptions<ListenerOptions>({
name: 'musicSongPause'
})
export class MusicSongPauseListener extends Listener {
public override async run(
interaction: ChatInputCommandInteraction
): Promise<void> {
await interaction.reply({ content: `Track paused.` });
return;
}
}

View File

@@ -0,0 +1,26 @@
import { ApplyOptions } from '@sapphire/decorators';
import { container, Listener, type ListenerOptions } from '@sapphire/framework';
import type { Queue } from '../../lib/music/classes/Queue';
import type { Song } from '../../lib/music/classes/Song';
@ApplyOptions<ListenerOptions>({
name: 'musicSongPlay'
})
export class MusicSongPlayListener extends Listener {
public override async run(queue: Queue, track: Song): Promise<void> {
const channel = await queue.getTextChannel();
if (channel) {
const { client } = container;
clearTimeout(client.leaveTimers[queue.player.guildId]);
delete client.leaveTimers[queue.player.guildId];
// Leave Voice Channel when attempting to stream to an empty channel
if (channel.guild.members.me?.voice.channel?.members.size == 1) {
await queue.leave();
return;
}
queue.client.emit('musicSongPlayMessage', channel, track);
}
}
}

View File

@@ -0,0 +1,37 @@
import { ApplyOptions } from '@sapphire/decorators';
import { container, Listener, type ListenerOptions } from '@sapphire/framework';
import type { TextChannel } from 'discord.js';
import { embedButtons } from '../../lib/music/buttonHandler';
import { NowPlayingEmbed } from '../../lib/music/nowPlayingEmbed';
import type { Song } from '../../lib/music/classes/Song';
import { manageStageChannel } from '../../lib/music/channelHandler';
@ApplyOptions<ListenerOptions>({
name: 'musicSongPlayMessage'
})
export class MusicSongPlayMessageListener extends Listener {
public override async run(channel: TextChannel, track: Song): Promise<void> {
const { client } = container;
const queue = client.music.queues.get(channel.guild.id);
const tracks = await queue.tracks();
const NowPlaying = new NowPlayingEmbed(
track,
queue.player.accuratePosition,
track.length ?? 0,
queue.player.volume,
tracks,
tracks.at(-1),
queue.paused
);
try {
await manageStageChannel(
channel.guild.members.me?.voice.channel!,
channel.guild.members.me!,
queue
);
await embedButtons(await NowPlaying.NowPlayingEmbed(), queue, track);
} catch (error) {
console.log(error);
}
}
}

View File

@@ -0,0 +1,14 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Listener, type ListenerOptions } from '@sapphire/framework';
import type { ChatInputCommandInteraction } from 'discord.js';
@ApplyOptions<ListenerOptions>({
name: 'musicSongResume'
})
export class MusicSongResumeListener extends Listener {
public override async run(
interaction: ChatInputCommandInteraction
): Promise<void> {
await interaction.reply({ content: `Track resumed.` });
}
}

View File

@@ -0,0 +1,17 @@
import type { Song } from '../../lib/music/classes/Song';
import { ApplyOptions } from '@sapphire/decorators';
import { Listener, type ListenerOptions } from '@sapphire/framework';
import { ChatInputCommandInteraction } from 'discord.js';
@ApplyOptions<ListenerOptions>({
name: 'musicSongSkipNotify'
})
export class MusicSongSkipNotifyListener extends Listener {
public override async run(
interaction: ChatInputCommandInteraction,
track: Song
): Promise<void> {
if (!track) return;
await interaction.reply({ content: `${track.title} has been skipped.` });
}
}

View File

@@ -0,0 +1,104 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Listener, ListenerOptions } from '@sapphire/framework';
import type { VoiceChannel, VoiceState } from 'discord.js';
import { trpcNode } from '../../trpc';
import { ChannelType } from 'discord.js';
@ApplyOptions<ListenerOptions>({
name: 'voiceStateUpdate'
})
export class VoiceStateUpdateListener extends Listener {
public override async run(
oldState: VoiceState,
newState: VoiceState
): Promise<void> {
const { guild: guildDB } = await trpcNode.guild.getGuild.query({
id: newState.guild.id
});
// now user is in hub channel, create him a new voice channel and move him there
if (newState.channelId) {
if (!newState.member) return; // should not happen but just in case
if (newState.channelId === guildDB?.hubChannel && guildDB.hub) {
const { tempChannel } = await trpcNode.hub.getTempChannel.query({
guildId: newState.guild.id,
ownerId: newState.member.id
});
// user entered hub channel but he already has a temp channel, so move him there
if (tempChannel) {
await newState.setChannel(tempChannel.id);
return;
}
const guild = newState.guild;
const channels = guild.channels;
const channel = await channels.create({
name: `${newState.member.user.username}'s channel`,
type: ChannelType.GuildVoice,
parent: guildDB?.hub,
permissionOverwrites: [
{
id: newState.member.id,
allow: [
'MoveMembers',
'MuteMembers',
'DeafenMembers',
'ManageChannels',
'Stream'
]
}
]
});
await trpcNode.hub.createTempChannel.mutate({
guildId: newState.guild.id,
ownerId: newState.member.id,
channelId: channel.id
});
await newState.member.voice.setChannel(channel);
} else {
const { tempChannel } = await trpcNode.hub.getTempChannel.query({
guildId: newState.guild.id,
ownerId: newState.member.id
});
if (!tempChannel) return;
if (tempChannel.id === newState.channelId) return;
const channel = (await newState.guild.channels.fetch(
tempChannel.id
)) as VoiceChannel;
if (!channel) return;
Promise.all([
channel.delete(),
trpcNode.hub.deleteTempChannel.mutate({
channelId: tempChannel.id
})
]);
}
} else if (!newState.channelId) {
// user left hub channel, delete his temp channel
deleteChannel(oldState);
}
}
}
async function deleteChannel(state: VoiceState) {
const { tempChannel } = await trpcNode.hub.getTempChannel.query({
guildId: state.guild.id,
ownerId: state.member!.id
});
if (tempChannel) {
Promise.all([
state.channel?.delete(),
trpcNode.hub.deleteTempChannel.mutate({
channelId: tempChannel.id
})
]);
}
}

View File

@@ -0,0 +1,43 @@
import { ApplyOptions } from '@sapphire/decorators';
import {
Precondition,
type PreconditionOptions,
type PreconditionResult
} from '@sapphire/framework';
import type {
ChatInputCommandInteraction,
GuildMember,
VoiceBasedChannel
} from 'discord.js';
import { container } from '@sapphire/framework';
@ApplyOptions<PreconditionOptions>({
name: 'inPlayerVoiceChannel'
})
export class inPlayerVoiceChannel extends Precondition {
public override chatInputRun(
interaction: ChatInputCommandInteraction
): PreconditionResult {
const member = interaction.member as GuildMember;
// this precondition comes after a precondition that makes sure the user is in a voice channel
const voiceChannel = member.voice!.channel as VoiceBasedChannel;
const { client } = container;
const queue = client.music.queues.get(interaction.guildId!);
const queueVoiceChannel = queue.voiceChannel;
if (queueVoiceChannel && queueVoiceChannel.id !== voiceChannel.id) {
return this.error({
message: `You're in the wrong channel! Join <#${queueVoiceChannel?.id}>`
});
}
return this.ok();
}
}
declare module '@sapphire/framework' {
export interface Preconditions {
inPlayerVoiceChannel: never;
}
}

View File

@@ -0,0 +1,32 @@
import { ApplyOptions } from '@sapphire/decorators';
import {
Precondition,
PreconditionOptions,
PreconditionResult
} from '@sapphire/framework';
import type { ChatInputCommandInteraction, GuildMember } from 'discord.js';
@ApplyOptions<PreconditionOptions>({
name: 'inVoiceChannel'
})
export class inVoiceChannel extends Precondition {
public override chatInputRun(
interaction: ChatInputCommandInteraction
): PreconditionResult {
const member = interaction.member as GuildMember;
const voiceChannel = member!.voice!.channel;
if (!voiceChannel) {
return this.error({
message: 'You must be in a voice channel in order to use this command!'
});
}
return this.ok();
}
}
declare module '@sapphire/framework' {
export interface Preconditions {
inVoiceChannel: never;
}
}

View File

@@ -0,0 +1,41 @@
import { ApplyOptions } from '@sapphire/decorators';
import {
AsyncPreconditionResult,
Precondition,
PreconditionOptions
} from '@sapphire/framework';
import { ChatInputCommandInteraction } from 'discord.js';
import { trpcNode } from '../trpc';
@ApplyOptions<PreconditionOptions>({
name: 'isCommandDisabled'
})
export class IsCommandDisabledPrecondition extends Precondition {
public override async chatInputRun(
interaction: ChatInputCommandInteraction
): AsyncPreconditionResult {
const commandID = interaction.commandId;
const guildID = interaction.guildId as string;
// Most likly a DM
if (!interaction.guildId && interaction.user.id) {
return this.ok();
}
const data = await trpcNode.command.getDisabledCommands.query({
guildId: guildID
});
if (data.disabledCommands.includes(commandID)) {
return this.error({
message: 'This command is disabled'
});
}
return this.ok();
}
}
declare module '@sapphire/framework' {
export interface Preconditions {
isCommandDisabled: never;
}
}

View File

@@ -0,0 +1,31 @@
import { ApplyOptions } from '@sapphire/decorators';
import {
Precondition,
PreconditionOptions,
PreconditionResult
} from '@sapphire/framework';
import { container } from '@sapphire/framework';
import { ChatInputCommandInteraction } from 'discord.js';
@ApplyOptions<PreconditionOptions>({
name: 'playerIsPlaying'
})
export class PlayerIsPlaying extends Precondition {
public override chatInputRun(
interaction: ChatInputCommandInteraction
): PreconditionResult {
const { client } = container;
const player = client.music.players.get(interaction.guildId as string);
if (!player) {
return this.error({ message: 'There is nothing playing at the moment!' });
}
return this.ok();
}
}
declare module '@sapphire/framework' {
export interface Preconditions {
playerIsPlaying: never;
}
}

View File

@@ -0,0 +1,38 @@
import { ApplyOptions } from '@sapphire/decorators';
import {
AsyncPreconditionResult,
Precondition,
PreconditionOptions
} from '@sapphire/framework';
import type { ChatInputCommandInteraction, GuildMember } from 'discord.js';
import { trpcNode } from '../trpc';
@ApplyOptions<PreconditionOptions>({
name: 'playlistExists'
})
export class PlaylistExists extends Precondition {
public override async chatInputRun(
interaction: ChatInputCommandInteraction
): AsyncPreconditionResult {
const playlistName = interaction.options.getString('playlist-name', true);
const guildMember = interaction.member as GuildMember;
const playlist = await trpcNode.playlist.getPlaylist.query({
name: playlistName,
userId: guildMember.id
});
return playlist
? this.ok()
: this.error({
message: `You have no playlist named **${playlistName}**`
});
}
}
declare module '@sapphire/framework' {
export interface Preconditions {
playlistExists: never;
}
}

View File

@@ -0,0 +1,42 @@
import { ApplyOptions } from '@sapphire/decorators';
import {
AsyncPreconditionResult,
Precondition,
PreconditionOptions
} from '@sapphire/framework';
import type { ChatInputCommandInteraction, GuildMember } from 'discord.js';
import { trpcNode } from '../trpc';
@ApplyOptions<PreconditionOptions>({
name: 'playlistNotDuplicate'
})
export class PlaylistNotDuplicate extends Precondition {
public override async chatInputRun(
interaction: ChatInputCommandInteraction
): AsyncPreconditionResult {
const playlistName = interaction.options.getString('playlist-name', true);
const guildMember = interaction.member as GuildMember;
try {
const playlist = await trpcNode.playlist.getPlaylist.query({
name: playlistName,
userId: guildMember.id
});
if (playlist) throw new Error();
} catch {
return this.error({
message: `There is already a playlist named **${playlistName}** in your saved playlists!`
});
}
return this.ok();
}
}
declare module '@sapphire/framework' {
export interface Preconditions {
playlistNotDuplicate: never;
}
}

View File

@@ -0,0 +1,40 @@
import { ApplyOptions } from '@sapphire/decorators';
import {
AsyncPreconditionResult,
Precondition,
PreconditionOptions
} from '@sapphire/framework';
import type { ChatInputCommandInteraction, GuildMember } from 'discord.js';
import { trpcNode } from '../trpc';
import Logger from '../lib/logger';
@ApplyOptions<PreconditionOptions>({
name: 'userInDB'
})
export class UserInDB extends Precondition {
public override async chatInputRun(
interaction: ChatInputCommandInteraction
): AsyncPreconditionResult {
const guildMember = interaction.member as GuildMember;
try {
const user = await trpcNode.user.create.mutate({
id: guildMember.id,
name: guildMember.user.username
});
if (!user) throw new Error();
} catch (error) {
Logger.error(error);
return this.error({ message: 'Something went wrong!' });
}
return this.ok();
}
}
declare module '@sapphire/framework' {
export interface Preconditions {
userInDB: never;
}
}

View File

@@ -0,0 +1,31 @@
import { ApplyOptions } from '@sapphire/decorators';
import {
Precondition,
PreconditionOptions,
PreconditionResult
} from '@sapphire/framework';
import { ChatInputCommandInteraction } from 'discord.js';
import ISO6391 from 'iso-639-1';
@ApplyOptions<PreconditionOptions>({
name: 'validateLanguageCode'
})
export class ValidLanguageCode extends Precondition {
public override chatInputRun(
interaction: ChatInputCommandInteraction
): PreconditionResult {
const targetLang = interaction.options.getString('target', true);
const languageCode = ISO6391.getCode(targetLang);
if (!languageCode) {
return this.error({ message: ':x: Please enter a valid language!' });
}
return this.ok();
}
}
declare module '@sapphire/framework' {
export interface Preconditions {
validateLanguageCode: never;
}
}

25
apps/bot/src/trpc.ts Normal file
View File

@@ -0,0 +1,25 @@
import type { AppRouter } from '@master-bot/api/index';
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import superjson from 'superjson';
// @ts-ignore
import * as trpcServer from '@trpc/server';
// @ts-ignore
import * as PrismaClient from '@prisma/client';
const _importDynamic = new Function('modulePath', 'return import(modulePath)');
const fetch = async function (...args: any) {
const { default: fetch } = await _importDynamic('node-fetch');
return fetch(...args);
};
const globalAny = global as any;
globalAny.fetch = fetch;
export const trpcNode = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/api/trpc'
})
],
transformer: superjson
});