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

15
apps/bot/.sapphirerc.json Normal file
View File

@ -0,0 +1,15 @@
{
"projectLanguage": "ts",
"locations": {
"base": "src",
"arguments": "arguments",
"commands": "commands",
"listeners": "listeners",
"preconditions": "preconditions",
"interaction-handlers": "interaction-handlers"
},
"customFileTemplates": {
"enabled": false,
"location": ""
}
}

73
apps/bot/package.json Normal file
View File

@ -0,0 +1,73 @@
{
"name": "@master-bot/bot",
"version": "1.0.0",
"private": true,
"description": "a discord music bot with guild , gifs and misc commands",
"author": "Nir Gal",
"license": "ISC",
"main": "dist/index.js",
"scripts": {
"build": "pnpm with-env tsc",
"watch": "tsc --watch",
"copy-scripts": "pnpx ncp ./scripts ./dist/",
"dev": "pnpm build && pnpm copy-scripts && run-p watch start",
"start": "pnpm with-env node dist/index.js",
"with-env": "dotenv -e ../../.env --"
},
"engines": {
"node": ">=v18.16.1"
},
"dependencies": {
"@discordjs/collection": "^1.5.2",
"@lavaclient/spotify": "^3.1.0",
"@lavalink/encoding": "^0.1.2",
"@master-bot/api": "^0.1.0",
"@napi-rs/canvas": "^0.1.41",
"@prisma/client": "^5.1.1",
"@sapphire/decorators": "^6.0.2",
"@sapphire/discord.js-utilities": "^7.0.1",
"@sapphire/framework": "^4.5.1",
"@sapphire/plugin-hmr": "^2.0.1",
"@sapphire/time-utilities": "^1.7.10",
"@sapphire/utilities": "^3.13.0",
"@t3-oss/env-core": "^0.6.0",
"@trpc/client": "^10.37.1",
"@trpc/server": "^10.37.1",
"axios": "^1.4.0",
"colorette": "^2.0.20",
"discord.js": "^14.12.1",
"genius-discord-lyrics": "1.0.5",
"google-translate-api-x": "^10.6.7",
"ioredis": "^5.3.2",
"iso-639-1": "^2.1.15",
"lavaclient": "^4.1.1",
"metadata-filter": "^1.3.0",
"ncp": "^2.0.0",
"node-fetch": "^3.3.2",
"npm-run-all": "^4.1.5",
"string-progressbar": "^1.0.4",
"superjson": "1.13.1",
"winston": "^3.10.0",
"winston-daily-rotate-file": "^4.7.1",
"zod": "^3.21.4"
},
"devDependencies": {
"@lavaclient/types": "^2.1.1",
"@sapphire/ts-config": "^4.0.1",
"@types/ioredis": "^4.28.10",
"@types/node": "^20.4.6",
"@typescript-eslint/eslint-plugin": "^6.2.1",
"@typescript-eslint/parser": "^6.2.1",
"dotenv": "^16.3.1",
"dotenv-cli": "^7.2.1",
"prettier": "^3.0.1",
"tslib": "^2.6.1",
"typescript": "^5.1.6"
},
"eslintConfig": {
"root": true,
"extends": [
"@master-bot/eslint-config/base"
]
}
}

View File

@ -0,0 +1,20 @@
local KEY = KEYS[1]
local FROM = tonumber(ARGV[1])
local TO = tonumber(ARGV[2])
if FROM == nil then return redis.redis_error('origin must be a number') end
if TO == nil then return redis.redis_error('destination must be a number') end
local list = redis.call('lrange', KEY, 0, -1)
if FROM == TO then return 'OK' end
if FROM < 0 then FROM = #list + FROM end
if TO < 0 then TO = #list + TO end
-- provided indexes are 0-based
local val = table.remove(list, FROM + 1)
table.insert(list, TO + 1, val)
redis.call('del', KEY)
redis.call('rpush', KEY, unpack(list))
return 'OK'

View File

@ -0,0 +1,20 @@
local KEY = KEYS[1]
local INDEX = tonumber(ARGV[1])
if INDEX == nil then return redis.redis_error('origin must be a number') end
local list = redis.call('lrange', KEY, 0, -1)
if INDEX < 0 then INDEX = #list + INDEX end
-- provided indexes are 0-based
table.remove(list, INDEX + 1)
redis.call('del', KEY)
-- If there is at least one element, call rpush
if (next(list) ~= nil) then
redis.call('rpush', KEY, unpack(list))
end
return 'OK'

View File

@ -0,0 +1,19 @@
math.randomseed(tonumber(ARGV[1]))
local function shuffle(t)
for i = #t, 1, -1 do
local rand = math.random(i)
t[i], t[rand] = t[rand], t[i]
end
return t
end
local KEY = KEYS[1]
local list = redis.call('lrange', KEY, 0, -1)
if #list > 0 then
shuffle(list)
redis.call('del', KEY)
redis.call('lpush', KEY, unpack(list))
end
return 'OK'

View File

@ -0,0 +1,12 @@
local SOURCE = KEYS[1]
local DESTINATION = KEYS[2]
local value = redis.call('rpop', SOURCE)
if value then
redis.call('set', DESTINATION, value)
return value
end
redis.call('del', DESTINATION)
return nil

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

20
apps/bot/tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"extends": "@sapphire/ts-config",
"compilerOptions": {
"rootDir": "src",
"experimentalDecorators": true,
"incremental": true,
"outDir": "dist",
"strict": true,
"tsBuildInfoFile": "dist/.tsbuildinfo",
"resolveJsonModule": true,
"noUnusedParameters": false,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"verbatimModuleSyntax": false,
"esModuleInterop": true,
"noImplicitAny": false
},
"include": ["src", "scripts", "src/env.ts"],
"exclude": ["node_modules"]
}

28
apps/dashboard/README.md Normal file
View File

@ -0,0 +1,28 @@
# Create T3 App
This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`.
## What's next? How do I make an app with this?
We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary.
If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help.
- [Next.js](https://nextjs.org)
- [NextAuth.js](https://next-auth.js.org)
- [Prisma](https://prisma.io)
- [Tailwind CSS](https://tailwindcss.com)
- [tRPC](https://trpc.io)
## Learn More
To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources:
- [Documentation](https://create.t3.gg/)
- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials
You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome!
## How do I deploy this?
Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel) and [Docker](https://create.t3.gg/en/deployment/docker) for more information.

View File

@ -0,0 +1,16 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/app/styles/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

Some files were not shown because too many files have changed in this diff Show More