├── .env.example ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .prettierignore ├── .sapphirerc.json ├── README.md ├── package.json ├── src ├── commands │ ├── Development │ │ ├── eval.ts │ │ └── reload.ts │ ├── General │ │ └── ping.ts │ └── Guardian │ │ └── limits.ts ├── index.ts ├── lib │ ├── constants.ts │ ├── env-parser.ts │ ├── setup.ts │ └── utils.ts ├── listeners │ ├── commands │ │ ├── chatInputCommandDenied.ts │ │ ├── chatInputCommandError.ts │ │ ├── commandSuccessLogger.ts │ │ ├── contextMenuCommandDenied.ts │ │ └── contextMenuCommandError.ts │ └── ready.ts └── preconditions │ └── OwnerOnly.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # Token | https://discord.com/developers/applications -> New Application -> Bot -> Create 2 | DISCORD_TOKEN= 3 | 4 | # Owners | A list of owners (separated by comma) for your bot, will have access to eval and OwnerOnly commands. 5 | OWNERS= -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | 28 | - OS: [e.g. iOS] 29 | - Browser [e.g. chrome, safari] 30 | - Version [e.g. 22] 31 | 32 | **Smartphone (please complete the following information):** 33 | 34 | - Device: [e.g. iPhone6] 35 | - OS: [e.g. iOS8.1] 36 | - Browser [e.g. stock browser, safari] 37 | - Version [e.g. 22] 38 | 39 | **Additional context** 40 | Add any other context about the problem here. 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore a blackhole and the folder for development 2 | node_modules/ 3 | .vs/ 4 | .idea/ 5 | *.iml 6 | 7 | # Yarn files 8 | .yarn/install-state.gz 9 | .yarn/build-state.yml 10 | 11 | # Environment variables 12 | .DS_Store 13 | 14 | dist/ 15 | 16 | # Ignore the config file (contains sensitive information such as tokens) 17 | config.ts 18 | 19 | # Ignore heapsnapshot and log files 20 | *.heapsnapshot 21 | *.log 22 | 23 | # Ignore npm lockfiles file 24 | package-lock.json 25 | 26 | # Environment variables 27 | .env 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ -------------------------------------------------------------------------------- /.sapphirerc.json: -------------------------------------------------------------------------------- 1 | { 2 | "projectLanguage": "ts", 3 | "locations": { 4 | "base": "src", 5 | "arguments": "arguments", 6 | "commands": "commands", 7 | "listeners": "listeners", 8 | "preconditions": "preconditions" 9 | }, 10 | "customFileTemplates": { 11 | "enabled": false, 12 | "location": "" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### discord-bot-template 2 | 3 | A basic TypeScript starter template for Discord bots built on [`@sapphiredev/framework`](https://github.com/sapphiredev/framework) 4 | 5 | ### JavaScript Version 6 | 7 | An outdated, although functional JavaScript template is available on the _`javascript`_ branch. **Not recommended.** 8 | 9 | ### Database 10 | 11 | _TODO_ 12 | 13 | ### Getting Started 14 | 15 | ```bash 16 | git clone https://github.com/lorencerri/discord-bot-template.git # Clone the repo 17 | cd discord-bot-template # Enter the directory 18 | copy .env.example ./src/.env # Copy the .env.example file into the src folder and rename it .env 19 | nano .env # Fill in the .env file 20 | npm install # Install dependencies 21 | npm run build # Build the bot 22 | npm run start # Start the bot 23 | ``` 24 | 25 | ### Default Commands 26 | 27 | > **NOTE:** These are all slash commands 28 | 29 | - eval 30 | - reload (**NOTE:** Reloading slash commands is typically buggy) 31 | - ping 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "discord-bot-template", 3 | "version": "2.0.0", 4 | "main": "dist/index.js", 5 | "author": "lorencerri", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/lorencerri/discord-bot-template.git" 10 | }, 11 | "bugs": { 12 | "url": "https://github.com/lorencerri/discord-bot-template/issues" 13 | }, 14 | "homepage": "https://github.com/lorencerri/discord-bot-template#readme", 15 | "dependencies": { 16 | "@discordjs/builders": "^1.2.0", 17 | "@discordjs/collection": "^1.1.0", 18 | "@sapphire/decorators": "^5.0.0", 19 | "@sapphire/discord-utilities": "^2.11.6", 20 | "@sapphire/discord.js-utilities": "^5.0.0", 21 | "@sapphire/fetch": "^2.4.1", 22 | "@sapphire/framework": "^3.1.0", 23 | "@sapphire/plugin-api": "^4.0.0", 24 | "@sapphire/plugin-editable-commands": "^2.0.1", 25 | "@sapphire/plugin-logger": "^3.0.0", 26 | "@sapphire/plugin-subcommands": "^3.2.0", 27 | "@sapphire/stopwatch": "^1.4.1", 28 | "@sapphire/time-utilities": "^1.7.6", 29 | "@sapphire/type": "^2.2.4", 30 | "@sapphire/utilities": "^3.9.2", 31 | "colorette": "^2.0.19", 32 | "discord.js": "^13.10.3", 33 | "dotenv-cra": "^3.0.2", 34 | "reflect-metadata": "^0.1.13" 35 | }, 36 | "devDependencies": { 37 | "@sapphire/prettier-config": "^1.4.4", 38 | "@sapphire/ts-config": "^3.3.4", 39 | "@types/node": "^18.7.14", 40 | "@types/ws": "^8.5.3", 41 | "npm-run-all": "^4.1.5", 42 | "prettier": "^2.7.1", 43 | "tsc-watch": "^5.0.3", 44 | "typescript": "^4.8.2" 45 | }, 46 | "scripts": { 47 | "build": "tsc", 48 | "watch": "tsc -w", 49 | "start": "node dist/index.js", 50 | "dev": "run-s build start", 51 | "watch:start": "tsc-watch --onSuccess \"node ./dist/index.js\"", 52 | "format": "prettier --write \"src/**/*.ts\"" 53 | }, 54 | "prettier": "@sapphire/prettier-config" 55 | } 56 | -------------------------------------------------------------------------------- /src/commands/Development/eval.ts: -------------------------------------------------------------------------------- 1 | import { ApplyOptions } from '@sapphire/decorators'; 2 | import { Command, CommandOptions, RegisterBehavior } from '@sapphire/framework'; 3 | import { Type } from '@sapphire/type'; 4 | import { codeBlock, isThenable } from '@sapphire/utilities'; 5 | import { MessageEmbed } from 'discord.js'; 6 | import { inspect } from 'util'; 7 | 8 | @ApplyOptions({ 9 | aliases: ['ev'], 10 | description: 'Evaluates any JavaScript code', 11 | preconditions: ['OwnerOnly'], 12 | }) 13 | export class EvalCommand extends Command { 14 | async clean(token = '', text = '') { 15 | if (text.constructor.name == 'Promise') text = await text; 16 | if (typeof text !== 'string') 17 | text = require('util').inspect(text, { 18 | depth: 1 19 | }); 20 | return text.replace(/`/g, '`' + String.fromCharCode(8203)).replace(/@/g, '@' + String.fromCharCode(8203)).replace(token, ''); 21 | } 22 | 23 | private async eval(interaction: Command.ChatInputInteraction, code: string, flags: { depth: number; showHidden: boolean }) { 24 | 25 | let success = true; 26 | let result = null; 27 | 28 | try { 29 | // eslint-disable-next-line no-eval 30 | result = await eval(code); 31 | result = this.clean(interaction?.client?.token || '', result); 32 | } catch (error) { 33 | if (error && error instanceof Error && error.stack) { 34 | this.container.client.logger.error(error); 35 | } 36 | result = error; 37 | success = false; 38 | } 39 | 40 | const type = new Type(result).toString(); 41 | if (isThenable(result)) result = await result; 42 | 43 | if (typeof result !== 'string') { 44 | result = inspect(result, { 45 | depth: flags.depth, 46 | showHidden: flags.showHidden 47 | }); 48 | } 49 | 50 | return { result, success, type }; 51 | } 52 | 53 | async chatInputRun(interaction: Command.ChatInputInteraction) { 54 | const code = interaction.options.get('code', true).value || ''; 55 | const depth = interaction.options.get('depth')?.value || 2; 56 | const symbols = interaction.options.get('symbols')?.value || false; 57 | const silent = interaction.options.get('silent')?.value || false; 58 | 59 | if (typeof code !== 'string') throw new Error('code is not a string'); 60 | if (typeof depth !== 'number') throw new Error('depth is not a number'); 61 | if (typeof symbols !== 'boolean') throw new Error('showHidden is not a boolean') 62 | if (typeof silent !== 'boolean') throw new Error('silent is not a boolean') 63 | 64 | const { result, success, type } = await this.eval(interaction, code, { depth, showHidden: symbols }); 65 | 66 | if (silent) return null; 67 | 68 | const embed = new MessageEmbed(); 69 | 70 | if (success) embed.setColor(0x6d8d4e).setTitle('Success').setDescription(codeBlock('typescript', result)); 71 | else embed.setColor(0x6d3737).setTitle('Error').setDescription(codeBlock('js', result)); 72 | embed.addField('Type', `**\`${type}\`**`) 73 | 74 | if ((embed?.description?.length || 0) > 2000) { 75 | return interaction.reply({ 76 | content: `Output was too long... sent the result as a file.`, 77 | files: [{ attachment: Buffer.from(result), name: 'output.js' }] 78 | }); 79 | } 80 | 81 | return interaction.reply({ embeds: [embed] }); 82 | } 83 | 84 | registerApplicationCommands(registry: Command.Registry) { 85 | registry.registerChatInputCommand((builder) => 86 | builder 87 | .setName('eval') 88 | .setDescription('Evaluates any JavaScript code') 89 | .addStringOption((option) => option.setName('code').setDescription('The input to evaluate').setRequired(true)) 90 | .addIntegerOption((option) => option.setName('depth').setDescription('The amount of times to recurse when formatting the output')) 91 | .addBooleanOption((option) => option.setName('symbols').setDescription('Whether or not to include non-enumerable symbols in the output')) 92 | .addBooleanOption((option) => option.setName('silent').setDescription('Whether or not to display an output')), 93 | { behaviorWhenNotIdentical: RegisterBehavior.Overwrite } 94 | ); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/commands/Development/reload.ts: -------------------------------------------------------------------------------- 1 | import type { AutocompleteCommand, CommandOptions } from "@sapphire/framework"; 2 | import { ApplicationCommandOptionChoiceData, MessageEmbed } from "discord.js"; 3 | 4 | import { ApplyOptions } from "@sapphire/decorators"; 5 | import { Command, RegisterBehavior } from '@sapphire/framework'; 6 | import { Stopwatch } from "@sapphire/stopwatch"; 7 | 8 | @ApplyOptions({ 9 | name: 'reload', 10 | description: 'Reloads a piece or store', 11 | preconditions: ['OwnerOnly'], 12 | }) 13 | export class ReloadCommand extends Command { 14 | async chatInputRun(interaction: Command.ChatInputInteraction) { 15 | const type = interaction.options.getSubcommand(true); 16 | const name = interaction.options.get('name')?.value || ''; 17 | const timer = new Stopwatch().stop(); 18 | 19 | if (typeof name !== 'string') throw new Error("name is not a string"); 20 | 21 | const embed = new MessageEmbed() 22 | .setColor(0xb18652) 23 | .setDescription( 24 | `Reloading ${type !== 'all' 25 | ? `**\`${type}: ${name}\`**` 26 | : 'all pieces and stores' 27 | }, please wait...` 28 | ); 29 | 30 | await interaction.reply({ 31 | embeds: [embed], 32 | fetchReply: true 33 | }); 34 | 35 | const stores = this.container.stores; 36 | timer.start(); 37 | 38 | if (type === 'piece') { 39 | let match; 40 | for (const [_, store] of stores) { 41 | for (const [_, piece] of store) { 42 | if (piece.name === name) { 43 | match = piece; 44 | break; 45 | } 46 | } 47 | } 48 | match?.reload(); 49 | embed.setDescription(`Successfully reloaded **\`${name}\`** piece!`) 50 | } else if (type === 'store') { 51 | const store = stores.find(store => store.name === name); 52 | if (!store) throw new Error(`store:${name} could not be found`) 53 | await store.loadAll(); 54 | embed.setDescription(`Successfully reloaded **\`${name}\`** store!`) 55 | } else { 56 | await Promise.all(stores.map(store => store.loadAll())); 57 | embed.setDescription(`Successfully reloaded all **\`${stores.size}\`** stores!`); 58 | } 59 | 60 | embed.setFooter({ text: `Duration: ${timer.stop().toString()}` }).setColor(0x6d8d4e); 61 | await interaction.editReply({ embeds: [embed] }); 62 | } 63 | 64 | public async autocompleteRun(...[interaction]: Parameters) { 65 | const type = interaction.options.getSubcommand(true); 66 | const query = (String(interaction.options.getFocused()) || "").trim(); 67 | 68 | let stores = this.container.stores; 69 | let values: ApplicationCommandOptionChoiceData[] = []; 70 | 71 | if (type === 'store') { 72 | for (const [_, store] of stores) { 73 | const name = store.name; 74 | if (query && !name.includes(query)) continue; 75 | values.push({ name: store.name, value: store.name }) 76 | } 77 | } else if (type === 'piece') { 78 | for (const [_, store] of stores) { 79 | for (const [_, piece] of store) { 80 | const name = piece.name; 81 | if (piece.location.full.includes('node_modules')) continue; 82 | if (query && !name.includes(query)) continue; 83 | values.push({ name: piece.name, value: piece.name }) 84 | } 85 | } 86 | } 87 | 88 | return interaction.respond(values); 89 | } 90 | 91 | registerApplicationCommands(registry: Command.Registry) { 92 | registry.registerChatInputCommand((builder) => builder.setName('reload') 93 | .setDescription('Reloads a piece or store') 94 | .addSubcommand(subcommand => subcommand.setName('piece').setDescription('Reload a piece of code') 95 | .addStringOption(option => // 96 | option.setName('name').setDescription('The name of the piece to reload').setRequired(true).setAutocomplete(true))) 97 | .addSubcommand(subcommand => subcommand.setName('store').setDescription('Reload a store') 98 | .addStringOption(option => // 99 | option.setName('name').setDescription('The name of the store to reload').setRequired(true).setAutocomplete(true))) 100 | .addSubcommand(subcommand => subcommand.setName('all').setDescription('Reload all pieces and stores')), 101 | { behaviorWhenNotIdentical: RegisterBehavior.Overwrite } 102 | ); 103 | } 104 | } -------------------------------------------------------------------------------- /src/commands/General/ping.ts: -------------------------------------------------------------------------------- 1 | import { ApplyOptions } from '@sapphire/decorators'; 2 | import { 3 | Command, 4 | CommandOptions, 5 | RegisterBehavior, 6 | } from '@sapphire/framework'; 7 | import { isMessageInstance } from '@sapphire/discord.js-utilities'; 8 | import { ApplicationCommandType } from 'discord-api-types/v10'; 9 | import { Message, MessageEmbed } from 'discord.js'; 10 | 11 | @ApplyOptions({ 12 | name: 'ping', 13 | description: 'Ping bot to see if it is alive.', 14 | }) 15 | export class PingCommand extends Command { 16 | async ping(interaction: Command.ChatInputInteraction | Command.ContextMenuInteraction): Promise { 17 | 18 | const embed = new MessageEmbed() 19 | .setTitle("Ping?") 20 | .setDescription("`Please wait...`"); 21 | 22 | const msg = await interaction.reply({ embeds: [embed], ephemeral: true, fetchReply: true }); 23 | 24 | if (!(msg instanceof Message)) throw new Error("The returned message is not a Message instance."); 25 | if (!isMessageInstance(msg)) throw new Error("Failed to send message."); 26 | 27 | const diff = msg.createdTimestamp - interaction.createdTimestamp; 28 | const ping = Math.round(this.container.client.ws.ping); 29 | 30 | embed.setTitle('Pong!').setDescription(`Round trip took: ${diff}ms. Heartbeat: ${ping}ms`) 31 | return interaction.editReply({ embeds: [embed] }); 32 | } 33 | 34 | chatInputRun(interaction: Command.ChatInputInteraction) { 35 | return this.ping(interaction); 36 | } 37 | 38 | contextMenuRun(interaction: Command.ContextMenuInteraction) { 39 | return this.ping(interaction); 40 | } 41 | 42 | registerApplicationCommands(registry: Command.Registry) { 43 | registry.registerContextMenuCommand((builder) => builder.setName('ping').setType(ApplicationCommandType.Message), 44 | { behaviorWhenNotIdentical: RegisterBehavior.Overwrite }); 45 | 46 | registry.registerChatInputCommand((builder) => builder.setName('ping').setDescription("Ping bot to see if it is alive."), 47 | { behaviorWhenNotIdentical: RegisterBehavior.Overwrite }); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/commands/Guardian/limits.ts: -------------------------------------------------------------------------------- 1 | import type { AutocompleteCommand, CommandOptions } from "@sapphire/framework"; 2 | 3 | import { ApplyOptions } from "@sapphire/decorators"; 4 | import { Command, RegisterBehavior } from '@sapphire/framework'; 5 | 6 | @ApplyOptions({ 7 | name: 'limits', 8 | description: 'View or set interaction limits for the server', 9 | preconditions: ['OwnerOnly'], 10 | }) 11 | export class LimitsCommand extends Command { 12 | async chatInputRun(_interaction: Command.ChatInputInteraction) { 13 | throw new Error('not implemented...'); 14 | } 15 | 16 | public async autocompleteRun(...[_interaction]: Parameters) { 17 | throw new Error('not implemented...'); 18 | } 19 | 20 | registerApplicationCommands(registry: Command.Registry) { 21 | registry.registerChatInputCommand((builder) => builder.setName('limits') 22 | .setDescription('View, add, or remove an interaction limit for the server') 23 | .addSubcommand(subcommand => subcommand.setName('add').setDescription('Add a limit for a specific action') 24 | .addStringOption(option => // 25 | option.setName('action').setDescription('The name of the action to update').setRequired(true).setAutocomplete(true)) 26 | .addStringOption(option => // 27 | option.setName('limit').setDescription('The new amount of times this action can be performed in an interval').setRequired(true)) 28 | .addStringOption(option => // 29 | option.setName('interval').setDescription('The interval for the action limit').setRequired(true).setAutocomplete(true)) 30 | ) 31 | .addSubcommand(subcommand => subcommand.setName('view').setDescription('Returns all of the current limits')) 32 | .addSubcommand(subcommand => subcommand.setName('remove').setDescription('Returns all of the current limits') 33 | .addStringOption(option => // 34 | option.setName('limit').setDescription('The specific limit to remove').setRequired(true).setAutocomplete(true))), 35 | { behaviorWhenNotIdentical: RegisterBehavior.Overwrite } 36 | ); 37 | } 38 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import './lib/setup'; 2 | import { LogLevel, SapphireClient } from '@sapphire/framework'; 3 | 4 | const client = new SapphireClient({ 5 | defaultPrefix: '!', 6 | regexPrefix: /^(hey +)?bot[,! ]/i, 7 | caseInsensitiveCommands: true, 8 | logger: { 9 | level: LogLevel.Debug 10 | }, 11 | shards: 'auto', 12 | intents: [ 13 | 'GUILDS', 14 | 'GUILD_MEMBERS', 15 | 'GUILD_BANS', 16 | 'GUILD_EMOJIS_AND_STICKERS', 17 | 'GUILD_VOICE_STATES', 18 | 'GUILD_MESSAGES', 19 | 'GUILD_MESSAGE_REACTIONS', 20 | 'DIRECT_MESSAGES', 21 | 'DIRECT_MESSAGE_REACTIONS' 22 | ] 23 | }); 24 | 25 | const main = async () => { 26 | try { 27 | client.logger.info('Logging in'); 28 | await client.login(); 29 | client.logger.info('logged in'); 30 | } catch (error) { 31 | client.logger.fatal(error); 32 | client.destroy(); 33 | process.exit(1); 34 | } 35 | }; 36 | 37 | main(); 38 | -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | 3 | export const rootDir = join(__dirname, '..', '..'); 4 | export const srcDir = join(rootDir, 'src'); 5 | 6 | export const RandomLoadingMessage = ['Computing...', 'Thinking...', 'Cooking some food', 'Give me a moment', 'Loading...']; 7 | -------------------------------------------------------------------------------- /src/lib/env-parser.ts: -------------------------------------------------------------------------------- 1 | import { isNullishOrEmpty } from '@sapphire/utilities'; 2 | 3 | export function envParseArray(key: 'OWNERS', defaultValue?: string[]): string[] { 4 | const value = process.env[key]; 5 | if (isNullishOrEmpty(value)) { 6 | if (defaultValue === undefined) throw new Error(`[ENV] ${key} - The key must be an array, but is empty or undefined.`); 7 | return defaultValue; 8 | } 9 | 10 | return value.split(' '); 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/setup.ts: -------------------------------------------------------------------------------- 1 | // Unless explicitly defined, set NODE_ENV as development: 2 | process.env.NODE_ENV ??= 'development'; 3 | 4 | import 'reflect-metadata'; 5 | import '@sapphire/plugin-logger/register'; 6 | import '@sapphire/plugin-api/register'; 7 | import '@sapphire/plugin-editable-commands/register'; 8 | import * as colorette from 'colorette'; 9 | import { config } from 'dotenv-cra'; 10 | import { join } from 'path'; 11 | import { inspect } from 'util'; 12 | import { srcDir } from './constants'; 13 | 14 | // Read env var 15 | config({ path: join(srcDir, '.env') }); 16 | 17 | // Set default inspection depth 18 | inspect.defaultOptions.depth = 1; 19 | 20 | // Enable colorette 21 | colorette.createColors({ useColor: true }); 22 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { send } from '@sapphire/plugin-editable-commands'; 2 | import { Message, MessageEmbed } from 'discord.js'; 3 | import { RandomLoadingMessage } from './constants'; 4 | 5 | /** 6 | * Picks a random item from an array 7 | * @param array The array to pick a random item from 8 | * @example 9 | * const randomEntry = pickRandom([1, 2, 3, 4]) // 1 10 | */ 11 | export function pickRandom(array: readonly T[]): T { 12 | const { length } = array; 13 | return array[Math.floor(Math.random() * length)]; 14 | } 15 | 16 | /** 17 | * Sends a loading message to the current channel 18 | * @param message The message data for which to send the loading message 19 | */ 20 | export function sendLoadingMessage(message: Message): Promise { 21 | return send(message, { embeds: [new MessageEmbed().setDescription(pickRandom(RandomLoadingMessage)).setColor('#FF0000')] }); 22 | } 23 | -------------------------------------------------------------------------------- /src/listeners/commands/chatInputCommandDenied.ts: -------------------------------------------------------------------------------- 1 | import type { ChatInputCommandDeniedPayload, Events } from '@sapphire/framework'; 2 | import { Listener, UserError } from '@sapphire/framework'; 3 | import { MessageEmbed } from 'discord.js'; 4 | 5 | export class ChatInputCommandDenied extends Listener { 6 | public async run(error: UserError, { interaction }: ChatInputCommandDeniedPayload) { 7 | const embed = new MessageEmbed() 8 | .setColor(0x6d3737) 9 | .setTitle('Chat Input Command Denied') 10 | .setDescription(`**Message:**\n\`${error.message}\``); 11 | 12 | return interaction.reply({ embeds: [embed] }); 13 | } 14 | } -------------------------------------------------------------------------------- /src/listeners/commands/chatInputCommandError.ts: -------------------------------------------------------------------------------- 1 | import type { ChatInputCommandErrorPayload, Events } from '@sapphire/framework'; 2 | import { Listener } from '@sapphire/framework'; 3 | import { MessageEmbed } from 'discord.js'; 4 | 5 | export class ChatInputCommandError extends Listener { 6 | public async run(error: Error, { interaction }: ChatInputCommandErrorPayload): Promise { 7 | const embed = new MessageEmbed() 8 | .setColor(0x6d3737) 9 | .setTitle('Chat Input Command Error') 10 | .setDescription(`**Message:**\n\`${error.message}\``); 11 | 12 | if (interaction.replied) return interaction.editReply({ embeds: [embed] }) 13 | else return interaction.reply({ embeds: [embed] }); 14 | } 15 | } -------------------------------------------------------------------------------- /src/listeners/commands/commandSuccessLogger.ts: -------------------------------------------------------------------------------- 1 | import type { MessageCommandSuccessPayload, ListenerOptions, PieceContext } from '@sapphire/framework'; 2 | import { Command, Events, Listener, LogLevel } from '@sapphire/framework'; 3 | import type { Logger } from '@sapphire/plugin-logger'; 4 | import { cyan } from 'colorette'; 5 | import type { Guild, User } from 'discord.js'; 6 | 7 | export class UserEvent extends Listener { 8 | public constructor(context: PieceContext, options?: ListenerOptions) { 9 | super(context, { 10 | ...options, 11 | event: Events.MessageCommandSuccess 12 | }); 13 | } 14 | 15 | public run({ message, command }: MessageCommandSuccessPayload) { 16 | const shard = this.shard(message.guild?.shardId ?? 0); 17 | const commandName = this.command(command); 18 | const author = this.author(message.author); 19 | const sentAt = message.guild ? this.guild(message.guild) : this.direct(); 20 | this.container.logger.debug(`${shard} - ${commandName} ${author} ${sentAt}`); 21 | } 22 | 23 | public onLoad() { 24 | this.enabled = (this.container.logger as Logger).level <= LogLevel.Debug; 25 | return super.onLoad(); 26 | } 27 | 28 | private shard(id: number) { 29 | return `[${cyan(id.toString())}]`; 30 | } 31 | 32 | private command(command: Command) { 33 | return cyan(command.name); 34 | } 35 | 36 | private author(author: User) { 37 | return `${author.username}[${cyan(author.id)}]`; 38 | } 39 | 40 | private direct() { 41 | return cyan('Direct Messages'); 42 | } 43 | 44 | private guild(guild: Guild) { 45 | return `${guild.name}[${cyan(guild.id)}]`; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/listeners/commands/contextMenuCommandDenied.ts: -------------------------------------------------------------------------------- 1 | import type { ContextMenuCommandDeniedPayload, Events } from '@sapphire/framework'; 2 | import { Listener, UserError } from '@sapphire/framework'; 3 | import { MessageEmbed } from 'discord.js'; 4 | 5 | export class ContextMenuCommandDenied extends Listener { 6 | public async run(error: UserError, { interaction }: ContextMenuCommandDeniedPayload) { 7 | const embed = new MessageEmbed() 8 | .setColor(0x6d3737) 9 | .setTitle('Context Menu Command Denied') 10 | .setDescription(`**Message:**\n\`${error.message}\``); 11 | 12 | return interaction.reply({ embeds: [embed] }); 13 | } 14 | } -------------------------------------------------------------------------------- /src/listeners/commands/contextMenuCommandError.ts: -------------------------------------------------------------------------------- 1 | import type { ContextMenuCommandErrorPayload, Events } from '@sapphire/framework'; 2 | import { Listener } from '@sapphire/framework'; 3 | import { MessageEmbed } from 'discord.js'; 4 | 5 | export class ContextMenuCommandError extends Listener { 6 | public async run(error: Error, { interaction }: ContextMenuCommandErrorPayload): Promise { 7 | const embed = new MessageEmbed() 8 | .setColor(0x6d3737) 9 | .setTitle('Context Menu Command Error') 10 | .setDescription(`**Message:**\n\`${error.message}\``); 11 | 12 | if (interaction.replied) return interaction.editReply({ embeds: [embed] }) 13 | else return interaction.reply({ embeds: [embed] }); 14 | } 15 | } -------------------------------------------------------------------------------- /src/listeners/ready.ts: -------------------------------------------------------------------------------- 1 | import type { ListenerOptions, PieceContext } from '@sapphire/framework'; 2 | import { Listener, Store } from '@sapphire/framework'; 3 | import { blue, gray, green, magenta, magentaBright, white, yellow } from 'colorette'; 4 | 5 | const dev = process.env.NODE_ENV !== 'production'; 6 | 7 | export class UserEvent extends Listener { 8 | private readonly style = dev ? yellow : blue; 9 | 10 | public constructor(context: PieceContext, options?: ListenerOptions) { 11 | super(context, { 12 | ...options, 13 | once: true 14 | }); 15 | } 16 | 17 | public run() { 18 | this.printBanner(); 19 | this.printStoreDebugInformation(); 20 | } 21 | 22 | private printBanner() { 23 | const success = green('+'); 24 | 25 | const llc = dev ? magentaBright : white; 26 | const blc = dev ? magenta : blue; 27 | 28 | const line01 = llc(''); 29 | const line02 = llc(''); 30 | const line03 = llc(''); 31 | 32 | // Offset Pad 33 | const pad = ' '.repeat(7); 34 | 35 | console.log( 36 | String.raw` 37 | ${line01} ${pad}${blc('1.0.0')} 38 | ${line02} ${pad}[${success}] Gateway 39 | ${line03}${dev ? ` ${pad}${blc('<')}${llc('/')}${blc('>')} ${llc('DEVELOPMENT MODE')}` : ''} 40 | `.trim() 41 | ); 42 | } 43 | 44 | private printStoreDebugInformation() { 45 | const { client, logger } = this.container; 46 | const stores = [...client.stores.values()]; 47 | const last = stores.pop()!; 48 | 49 | for (const store of stores) logger.info(this.styleStore(store, false)); 50 | logger.info(this.styleStore(last, true)); 51 | } 52 | 53 | private styleStore(store: Store, last: boolean) { 54 | return gray(`${last ? '└─' : '├─'} Loaded ${this.style(store.size.toString().padEnd(3, ' '))} ${store.name}.`); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/preconditions/OwnerOnly.ts: -------------------------------------------------------------------------------- 1 | import { Command, Precondition } from '@sapphire/framework'; 2 | import { envParseArray } from '../lib/env-parser'; 3 | 4 | const OWNERS = envParseArray('OWNERS'); 5 | 6 | export class OwnerOnlyPrecondition extends Precondition { 7 | public chatInputRun(interaction: Command.ChatInputInteraction) { 8 | return this.checkOwner(interaction.user.id); 9 | } 10 | 11 | public contextMenuRun(interaction: Command.ContextMenuInteraction) { 12 | return this.checkOwner(interaction.user.id); 13 | } 14 | 15 | checkOwner(userId: string) { 16 | return OWNERS.includes(userId) ? this.ok() : this.error({ message: 'This command can only be used by the owner.' }); 17 | } 18 | } 19 | 20 | declare module '@sapphire/framework' { 21 | interface Preconditions { 22 | OwnerOnly: never; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sapphire/ts-config", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "dist", 6 | "tsBuildInfoFile": "dist/.tsbuildinfo", 7 | "skipLibCheck": true 8 | }, 9 | "include": ["src"] 10 | } 11 | --------------------------------------------------------------------------------