diff options
| author | 8cy <[email protected]> | 2020-07-19 04:36:14 -0700 |
|---|---|---|
| committer | 8cy <[email protected]> | 2020-07-19 04:36:14 -0700 |
| commit | f00bcd79995bc8d7af3f297cab8085fd28b89435 (patch) | |
| tree | 9a4ddd1f0a945b1f20d98c20e621c0d8a69bb8ad /src | |
| download | water-waifu-re-f00bcd79995bc8d7af3f297cab8085fd28b89435.tar.xz water-waifu-re-f00bcd79995bc8d7af3f297cab8085fd28b89435.zip | |
:star:
Diffstat (limited to 'src')
38 files changed, 1753 insertions, 0 deletions
diff --git a/src/bot/client/ReactionClient.ts b/src/bot/client/ReactionClient.ts new file mode 100644 index 0000000..ce83645 --- /dev/null +++ b/src/bot/client/ReactionClient.ts @@ -0,0 +1,107 @@ +import { AkairoClient, CommandHandler, InhibitorHandler, ListenerHandler } from 'discord-akairo'; +import { ColorResolvable, Message } from 'discord.js'; +import { join } from 'path'; +import { Logger } from 'winston'; +import { SettingsProvider } from '../../database'; +import { logger } from '../util/logger'; + +declare module 'discord-akairo' { + interface AkairoClient { + logger: Logger; + commandHandler: CommandHandler; + config: ReactionConfig; + settings: SettingsProvider; + } +} + +export interface ReactionConfig { + token: string; + color: ColorResolvable; + owners: string | string[]; + prefix: string; +} + +export default class ReactionClient extends AkairoClient { + public constructor(config: ReactionConfig) { + super({ + messageCacheMaxSize: 50, + messageCacheLifetime: 300, + messageSweepInterval: 900, + ownerID: config.owners, + partials: ['MESSAGE', 'REACTION'], + }); + + this.config = config; + + this.on( + 'shardError', + (err: Error, id: any): Logger => this.logger.error(`[SHARD ${id} ERROR] ${err.message}`, err.stack), + ).on('warn', (warn: any): Logger => this.logger.warn(`[CLIENT WARN] ${warn}`)); + } + + public readonly config: ReactionConfig; + + public logger = logger; + + public commandHandler: CommandHandler = new CommandHandler(this, { + directory: join(__dirname, '..', 'commands'), + prefix: async (msg: Message): Promise<string> => { + if (msg.guild) { + const doc = this.settings.cache.guilds.get(msg.guild.id); + if (doc?.prefix) return doc.prefix; + } + return this.config.prefix; + }, + aliasReplacement: /-/g, + allowMention: true, + handleEdits: true, + commandUtil: true, + commandUtilLifetime: 3e5, + defaultCooldown: 3000, + ignorePermissions: this.ownerID, + argumentDefaults: { + prompt: { + modifyStart: (msg: Message, str: string) => + `${msg.author}, ${str}\n...or type \`cancel\` to cancel this command.`, + modifyRetry: (msg: Message, str: string) => + `${msg.author}, ${str}\n...or type \`cancel\` to cancel this command.`, + timeout: 'You took too long! This command has been cancelled.', + ended: 'Too many tries. *tsk tsk* This command has been cancelled.', + cancel: 'If you say so. Command cancelled.', + retries: 3, + time: 30000, + }, + otherwise: '', + }, + }); + + public inhibitorHandler: InhibitorHandler = new InhibitorHandler(this, { + directory: join(__dirname, '..', 'inhibitors'), + }); + + public listenerHandler: ListenerHandler = new ListenerHandler(this, { + directory: join(__dirname, '..', 'listeners'), + }); + + public settings: SettingsProvider = new SettingsProvider(this); + + private async load(): Promise<this> { + await this.settings.init(); + this.commandHandler.useInhibitorHandler(this.inhibitorHandler); + this.commandHandler.useListenerHandler(this.listenerHandler); + this.listenerHandler.setEmitters({ + commandHandler: this.commandHandler, + inhibitorHandler: this.inhibitorHandler, + listenerHandler: this.listenerHandler, + }); + this.commandHandler.loadAll(); + this.inhibitorHandler.loadAll(); + this.listenerHandler.loadAll(); + return this; + } + + public async launch(): Promise<string> { + await this.load(); + return this.login(this.config.token); + } +} diff --git a/src/bot/commands/owner/eval.ts b/src/bot/commands/owner/eval.ts new file mode 100644 index 0000000..37dbbde --- /dev/null +++ b/src/bot/commands/owner/eval.ts @@ -0,0 +1,142 @@ +// https://github.com/almostSouji/cor/blob/master/src/bot/commands/owner/eval.ts +import { execSync } from 'child_process'; +import { Command } from 'discord-akairo'; +import { Message } from 'discord.js'; +import util from 'util'; +import { postHaste } from '../../util'; +import { MESSAGES, SENSITIVE_PATTERN_REPLACEMENT } from '../../util/constants'; + +export default class EvalCommand extends Command { + public constructor() { + super('eval', { + category: 'owner', + aliases: ['eval', 'js', 'e'], + clientPermissions: ['SEND_MESSAGES'], + description: { + content: 'Evaluate JavaScript code.', + }, + ownerOnly: true, + args: [ + { + id: 'code', + match: 'text', + prompt: { + start: 'what code would you like to evaluate?', + }, + }, + { + id: 'terminal', + flag: ['--t'], + match: 'flag', + }, + { + id: 'input', + match: 'flag', + flag: ['--input', '--in', '--i'], + }, + { + id: 'noout', + match: 'flag', + flag: ['--noout', '--nout', '--no'], + }, + { + id: 'notype', + match: 'flag', + flag: ['--notype', '--notp'], + }, + { + id: 'notime', + match: 'flag', + flag: ['--notime', '--noti'], + }, + { + id: 'haste', + match: 'flag', + flag: ['--haste', '--h'], + }, + ], + }); + } + + private _clean(text: any): any { + if (typeof text === 'string') { + text = text.replace(/`/g, `\`${String.fromCharCode(8203)}`).replace(/@/g, `@${String.fromCharCode(8203)}`); + + return text.replace(new RegExp(this.client.token!, 'gi'), SENSITIVE_PATTERN_REPLACEMENT); + } + + return text; + } + + public async exec( + msg: Message, + { + code, + terminal, + input, + noout, + notype, + notime, + haste, + }: { + code: string; + terminal: boolean; + input: boolean; + noout: boolean; + notype: boolean; + notime: boolean; + haste: boolean; + }, + ): Promise<Message | Message[] | void> { + if (terminal) { + try { + const exec = execSync(code).toString(); + return msg.util!.send(exec.substring(0, 1900), { code: 'fix' }); + } catch (err) { + return msg.util!.send(err.toString(), { code: 'fix' }); + } + } + + let evaled; + try { + const hrStart = process.hrtime(); + evaled = eval(code); // eslint-disable-line no-eval + + // eslint-disable-next-line + if (evaled != null && typeof evaled.then === 'function') evaled = await evaled; + const hrStop = process.hrtime(hrStart); + + let response = ''; + if (input) { + response += MESSAGES.COMMANDS.EVAL.INPUT(code); + } + if (!noout) { + response += MESSAGES.COMMANDS.EVAL.OUTPUT(this._clean(util.inspect(evaled, { depth: 0 }))); + } + if (!notype && !noout) { + response += `β’ Type: \`${typeof evaled}\``; + } + if (!noout && !notime) { + response += ` β’ time taken: \`${(hrStop[0] * 1e9 + hrStop[1]) / 1e6}ms\``; + } + if (haste) { + const hasteLink = await postHaste(this._clean(util.inspect(evaled)), 'js'); + response += `\nβ’ Full Inspect: ${hasteLink}`; + } + if (response.length > 20000) { + try { + const hasteLink = await postHaste(this._clean(util.inspect(evaled))); + return msg.util?.send(MESSAGES.COMMANDS.EVAL.LONG_OUTPUT(hasteLink)); + } catch (hasteerror) { + return msg.util?.send(MESSAGES.COMMANDS.EVAL.ERRORS.TOO_LONG); + } + } + if (response.length > 0) { + await msg.util?.send(response); + } + } catch (err) { + this.client.logger.error('[EVAL ERROR]', err.stack); + return msg.util?.send(MESSAGES.COMMANDS.EVAL.ERRORS.CODE_BLOCK(this._clean(err))); + } + } +} diff --git a/src/bot/commands/owner/reload.ts b/src/bot/commands/owner/reload.ts new file mode 100644 index 0000000..de84e22 --- /dev/null +++ b/src/bot/commands/owner/reload.ts @@ -0,0 +1,65 @@ +import { Command, Inhibitor, Listener } from 'discord-akairo'; +import { Message } from 'discord.js'; + +export default class ReloadCommand extends Command { + public constructor() { + super('reload', { + aliases: ['reload', 're', 'restart'], + category: 'owner', + ownerOnly: true, + description: { + content: 'Reloads a module.', + usage: '<module> [type:]', + }, + }); + } + + public *args() { + const type = yield { + match: 'option', + flag: ['type:'], + type: [ + ['command', 'c'], + ['inhibitor', 'i'], + ['listener', 'l'], + ], + default: 'command', + }; + + const mod = yield { + type: (msg: Message, phrase: string) => { + if (!phrase) return null; + + const types: { [key: string]: string } = { + command: 'commandAlias', + inhibitor: 'inhibitor', + listener: 'listener', + }; + + const resolver = this.handler.resolver.type(types[type]!); + + return resolver(msg, phrase); + }, + }; + + return { type, mod }; + } + + public async exec( + msg: Message, + { type, mod }: { type: any; mod: Command | Inhibitor | Listener }, + ): Promise<Message | Message[] | void> { + if (!mod) { + return msg.util?.reply(`Invalid ${type} ${type === 'command' ? 'alias' : 'ID'} specified to reload.`); + } + + try { + mod.reload(); + return msg.util?.reply(`Sucessfully reloaded ${type} \`${mod.id}\`.`); + } catch (err) { + this.client.logger.error(`Error occured reloading ${type} ${mod.id}`); + this.client.logger.error(err); + return msg.util?.reply(`Failed to reload ${type} \`${mod.id}\`.`); + } + } +} diff --git a/src/bot/commands/reaction/list.ts b/src/bot/commands/reaction/list.ts new file mode 100644 index 0000000..5f67c23 --- /dev/null +++ b/src/bot/commands/reaction/list.ts @@ -0,0 +1,41 @@ +import { Command } from 'discord-akairo'; +import { Message } from 'discord.js'; +import { oneLine } from 'common-tags'; + +export default class ListCommand extends Command { + public constructor() { + super('list', { + aliases: ['list', 'show', 'all', 'ls'], + channel: 'guild', + clientPermissions: ['SEND_MESSAGES'], + description: { + content: 'Lists all current reaction roles.', + }, + category: 'Reaction Roles', + userPermissions: ['MANAGE_ROLES'], + }); + } + + public async exec(msg: Message): Promise<Message | Message[]> { + const reactions = this.client.settings.cache.reactions.filter(r => r.guildID === msg.guild!.id && r.active); + if (!reactions.size) return msg.util!.reply('you have no live reaction roles!'); + + const embed = this.client.util + .embed() + .setTitle('Live Reaction Roles') + .setDescription( + reactions + .map(r => { + const emoji = r.emojiType === 'custom' ? this.client.emojis.cache.get(r.emoji) : r.emoji; + return oneLine`[\`${r.id}\`] ${emoji} + ${this.client.channels.cache.get(r.channelID) || '#deleted-channel'} + ${msg.guild!.roles.cache.get(r.roleID) || '@deleted-role'} + `; + }) + .join('\n') + .substring(0, 2048), + ); + + return msg.util!.reply({ embed }); + } +} diff --git a/src/bot/commands/reaction/new.ts b/src/bot/commands/reaction/new.ts new file mode 100644 index 0000000..659f005 --- /dev/null +++ b/src/bot/commands/reaction/new.ts @@ -0,0 +1,182 @@ +import { stripIndents } from 'common-tags'; +import { Command } from 'discord-akairo'; +import { Message, MessageReaction, Permissions, Role, TextChannel, User } from 'discord.js'; +import * as nodemoji from 'node-emoji'; + +export default class AddReactionCommand extends Command { + public constructor() { + super('add', { + aliases: ['new', 'add', 'addrole', 'reactionrole'], + channel: 'guild', + category: 'Reaction Roles', + description: { + content: 'Creates a new reaction role.', + usage: '<type> <channel> <message id> <emoji> <role>', + examples: [ + '1 #reaction-roles 603009228180815882 π Member', + '2 welcome 603009471236538389 :blobbouce: Blob', + '3 roles 602918902141288489 :apple: Apples', + ], + }, + userPermissions: [Permissions.FLAGS.MANAGE_ROLES], + clientPermissions: [ + Permissions.FLAGS.ADD_REACTIONS, + Permissions.FLAGS.MANAGE_ROLES, + Permissions.FLAGS.MANAGE_MESSAGES, + ], + }); + } + + public *args(m: Message): object { + const type = yield { + type: 'number', + prompt: { + start: stripIndents` + What type of reaction role do you wish to create? + + \`[1]\` for react to add and remove. *Classic* + ~~\`[2]\` for react to add only. + \`[3]\` for react to delete only.~~ + `, + restart: stripIndents` + Please provide a valid number for which type of reaction role do you wish to create? + + \`[1]\` Both react to add and remove. *Classic* + ~~\`[2]\` Only react to add. + \`[3]\` Only react to remove role.~~ + `, + }, + }; + + const channel = yield { + type: 'textChannel', + prompt: { + start: "What channel of the message you'd like to add this reaction role to?", + retry: 'Please provide a valid channel.', + }, + }; + + const message = yield { + type: async (_: Message, str: string): Promise<null | Message> => { + if (str) { + try { + const m = await channel.messages.fetch(str); + if (m) return m; + } catch {} + } + return null; + }, + prompt: { + start: 'What is the ID of the message you want to add that reaction role to?', + retry: 'Pleae provide a valid message ID.', + }, + }; + + const emoji = yield { + type: async (_: Message, str: string): Promise<string | null> => { + if (str) { + const unicode = nodemoji.find(str); + if (unicode) return unicode.emoji; + + const custom = this.client.emojis.cache.find(r => r.toString() === str); + if (custom) return custom.id; + return null; + } + + const message = await m.channel.send( + stripIndents`Please **react** to **this** message with the emoji you wish to use? + If it's a custom emoji, please ensure I'm in the server that it's from!`, + ); + // Please **react** to **this** message or respond with the emoji you wish to use? + // If it's a custom emoji, please ensure I'm in the server that it's from! + + const collector = await message.awaitReactions((_: MessageReaction, u: User): boolean => m.author.id === u.id, { + max: 1, + }); + if (!collector || collector.size !== 1) return null; + + const collected = collector.first()!; + + if (collected.emoji.id) { + const emoji = this.client.emojis.cache.find(e => e.id === collected.emoji.id); + if (emoji) return emoji.id; + return null; + } + + return null; + }, + prompt: { + start: + "Please **respond** to **this** message with the emoji you wish to use? If it's a custom emoji, please ensure I'm in the server that it's from!", + retry: + "Please **respond** to **this** message with a valid emoji. If it's a custom emoji, please ensure I'm in the server that it's from!", + }, + }; + + const role = yield { + type: 'role', + match: 'rest', + prompt: { + start: 'What role would you like to apply when they react?', + retry: 'Please provide a valid role.', + }, + }; + + return { type, channel, message, emoji, role }; + } + + public async exec( + msg: Message, + { + type, + channel, + message, + emoji, + role, + }: { type: number; channel: TextChannel; message: Message; emoji: string; role: Role }, + ): Promise<Message | Message[] | void> { + if (!channel.permissionsFor(this.client.user!.id)!.has(Permissions.FLAGS.ADD_REACTIONS)) + return msg.util?.reply(`I'm missing the permissions to react in ${channel}!`); + + const reaction = await message.react(emoji).catch((err: Error) => err); + + if (reaction instanceof Error) + return msg.util?.reply(`an error occurred when trying to react to that message: \`${reaction}\`.`); + + const id = this.makeID(); + + await this.client.settings.new('reaction', { + guildID: msg.guild!.id, + messageID: message.id, + userID: msg.author.id, + channelID: channel.id, + id, + emoji, + emojiType: emoji.length >= 3 ? 'custom' : 'unicode', + roleID: role.id, + uses: 0, + type, + }); + + const embed = this.client.util + .embed() + .setColor(this.client.config.color) + .setTitle('New Reaction Role!') + .setDescription("Please make sure my highest role is above the one you're trying to assign!") + .addField('π’ Reference ID', id) + .addField('π Channel', `${channel} \`[${channel.id}]\``) + .addField('π¬ Message', `\`${message.id}\``) + .addField('π Emoji', emoji.length >= 3 ? `${emoji} \`[${emoji}]\`` : emoji) + .addField('πΌ Role', `${role} \`[${role.id}]\``); + return msg.util?.send({ embed }); + } + + public makeID(times?: number): string { + const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + return 'X' + .repeat(times || 4) + .split('') + .map(() => possible.charAt(Math.floor(Math.random() * possible.length))) + .join(''); + } +} diff --git a/src/bot/commands/reaction/remove.ts b/src/bot/commands/reaction/remove.ts new file mode 100644 index 0000000..aad0860 --- /dev/null +++ b/src/bot/commands/reaction/remove.ts @@ -0,0 +1,57 @@ +import { Command } from 'discord-akairo'; +import { Message, TextChannel } from 'discord.js'; +import { Reaction } from '../../../database/models/Reaction'; + +export default class RemoveCommand extends Command { + public constructor() { + super('remove', { + aliases: ['delete', 'remove', 'del', 'rm'], + category: 'Reaction Roles', + channel: 'guild', + description: { + content: 'Removes a reaction from a message via an identifier.', + usage: '<identifier>', + }, + userPermissions: ['MANAGE_ROLES'], + args: [ + { + id: 'reaction', + type: (msg: Message, str: string): Reaction | null => { + const req = this.client.settings.cache.reactions.find(r => r.id === str && r.guildID === msg.guild!.id); + if (!req) return null; + return req; + }, + match: 'rest', + prompt: { + start: "Pleae provied the unique identifier for the reaction you'd like to delete.", + retry: + "Please provide a valid identifier for the reaction role you'd like to delete. You can also delete the whole message to delete reaction roles on it.", + }, + }, + ], + }); + } + + public async exec(msg: Message, { reaction }: { reaction: Reaction }): Promise<Message | Message[] | void> { + this.client.logger.info(reaction); + try { + const chan = this.client.channels.cache.get(reaction.channelID) as TextChannel; + if (!chan) throw new Error("That channel doesn't exist!"); + const message = await chan.messages.fetch(reaction.messageID); + if (!message) throw new Error("That message doesn't exist!"); + await message.reactions.cache.get(reaction.emoji)!.users.remove(this.client.user!.id); + } catch (err) { + this.client.logger.error(`[ERROR in REMOVE CMD]: ${err}.`); + } + + this.client.settings.set( + 'reaction', + { messageID: reaction.messageID }, + { + active: false, + }, + ); + + return msg.util?.reply('successfully deleted that reaction role.'); + } +} diff --git a/src/bot/commands/util/help.ts b/src/bot/commands/util/help.ts new file mode 100644 index 0000000..37313b7 --- /dev/null +++ b/src/bot/commands/util/help.ts @@ -0,0 +1,83 @@ +import { stripIndents } from 'common-tags'; +import { Command, PrefixSupplier } from 'discord-akairo'; +import { Message } from 'discord.js'; + +export default class HelpCommand extends Command { + public constructor() { + super('help', { + category: 'Utilities', + aliases: ['help'], + description: { + content: 'Displays all available commands or detailed info for a specific command.', + usage: '[command]', + examples: ['', 'ebay', 'size'], + }, + clientPermissions: ['EMBED_LINKS'], + args: [ + { + id: 'command', + type: 'commandAlias', + prompt: { + start: 'Which command would you like more info on?', + retry: 'Please provide a valid command.', + optional: true, + }, + }, + ], + }); + } + + public async exec(msg: Message, { command }: { command: Command | null }): Promise<Message | Message[]> { + // const prefix = (this.handler.prefix as PrefixSupplier)(msg); + const prefix = 'w$'; + if (!command) { + const embed = this.client.util + .embed() + .setColor(this.client.config.color) + .setFooter('Made with Love by Sin π') + .setTitle('π Commands').setDescription(stripIndents` + This is a list of all commands. + For more info on a command, type \`${prefix}help <command>\` + `); + + for (const category of this.handler.categories.values()) { + if (category.id === 'owner') continue; + embed.addField( + `πΌ ${category.id}`, + `${category + .filter(cmd => cmd.aliases.length > 0) + .map( + cmd => + `${prefix}\`${cmd.aliases[0]}\`${ + cmd.description && cmd.description.content + ? ` - ${cmd.description.content.split('\n')[0].substring(0, 120)}` + : '' + }`, + ) + .join('\n') || "Nothin' to see here! "}`, + ); + } + return msg.util!.send({ embed }); + } + const embed = this.client.util + .embed() + .setColor(this.client.config.color) + .setFooter('Made with Love by Sin π') + .setTitle( + `\`${this.client.config.prefix}${command.aliases[0]} ${ + command.description.usage ? command.description.usage : '' + }\``, + ); + + if (command.description.content) embed.addField('Β» Description', command.description.content); + if (command.aliases.length > 1) embed.addField('Β» Aliases', `\`${command.aliases.join('`, `')}\``); + if (command.description.examples && command.description.examples.length) + embed.addField( + 'Β» Examples', + `\`${prefix}${command.aliases[0]} ${command.description.examples + .map((e: string): string => `${this.client.config.prefix}${e}`) + .join('\n')}\``, + ); + return msg.util!.send({ embed }); + } +} diff --git a/src/bot/commands/util/info.ts b/src/bot/commands/util/info.ts new file mode 100644 index 0000000..ad783b5 --- /dev/null +++ b/src/bot/commands/util/info.ts @@ -0,0 +1,62 @@ +import { stripIndents } from 'common-tags'; +import { Command, PrefixSupplier } from 'discord-akairo'; +import { Message } from 'discord.js'; + +export default class InfoCommand extends Command { + public constructor() { + super('info', { + aliases: ['guide', 'about', 'info'], + clientPermissions: ['SEND_MESSAGES'], + description: { + content: 'Gives information about the bot.', + }, + category: 'Utilities', + }); + } + + public async exec(msg: Message): Promise<Message | Message[]> { + // const prefix = (this.handler.prefix as PrefixSupplier)(msg); + const prefix = 'w$'; + const embed = this.client.util + .embed() + .setColor(this.client.config.color) + .setAuthor(`${this.client.user!.username} Guide`, this.client.user!.displayAvatarURL()) + .addField( + '**Prerequisites**', + stripIndents` + β’ Invite the bot. + β’ Ensure the bot has the permissions \`Manage Messages\`, \`Send Messages\`, and \`Add Reactions\`. + β’ If you plan on creating a Reaction Role, you must have the \`Manage Roles\` permissions. + β’ My highest role must be above the role you want to assign upon reaction. + `, + ) + .addField( + '**Setup**', + stripIndents` + The format for a reaction role is as follows: **\`${prefix} new <type> <channel> <message ID> <emoji> <role>\`**. + The \`type\` representes what kind of reaction role you'd like to create. If you specify \`1\`, it will be a classic react to add, unreact to remove. \`2\` is a react to add only and vice versa with \`3\`. + The \`channel\` represents the text channel of the message you want to setup the reaction role on. + The \`message ID\` represents the ID of the message you want to configure the reaction role on. + The \`emoji\` represents the emoji that users must react with to recieve or get the role removed. + And lastly, the \`role\` is the role you want to apply or remove. + + \* If you don't to provide all the arguments at once, you can run \`${prefix}new\` alone and use the Reaction Role Builderβ’. + \* When running the command, don't include the <>'s + \* Some emojis may not be compatible. All Guild emojis and only single-code unicode emojis may work. + `, + ) + .addField( + '**Deletion**', + stripIndents` + If you'd like to delete an existing reaction role, you can: + a) delete the message the reaction role is on + b) run \`${prefix}del <ID>\` (the ID provided when you made the Reaction Role) + `, + ) + .setDescription(stripIndents` + ${this.client.user!.username} is a Discord bot to create reaction-based role assignment. + When a user reacts to a message that has a live message reaction, they will be applied or given the configured role. + `); + return msg.util!.send({ embed }); + } +} diff --git a/src/bot/commands/util/invite.ts b/src/bot/commands/util/invite.ts new file mode 100644 index 0000000..ab6ba22 --- /dev/null +++ b/src/bot/commands/util/invite.ts @@ -0,0 +1,26 @@ +/* import { Command } from 'discord-akairo'; +import { Message } from 'discord.js'; +import { stripIndents } from 'common-tags'; + +export default class InviteCommand extends Command { + public constructor() { + super('invite', { + aliases: ['invite', 'support', 'inv'], + clientPermissions: ['EMBED_LINKS'], + description: { + content: 'Provides an invite link for the bot and our support server.', + }, + category: 'Utilities', + }); + } + + public async exec(msg: Message): Promise<Message | Message[] | void> { + const embed = this.client.util.embed().setColor(this.client.config.color).setDescription(stripIndents` + You can invite **${this.client.user!.username}** to your server with [\`this\`](${await this.client.generateInvite( + 268782656, + )}) link! + You can join our **Support Server** by clicking [\`this link\`](https://discord.sycer.dev/)! + `); + return msg.util?.send({ embed }); + } +} */ diff --git a/src/bot/commands/util/ping.ts b/src/bot/commands/util/ping.ts new file mode 100644 index 0000000..99e7fec --- /dev/null +++ b/src/bot/commands/util/ping.ts @@ -0,0 +1,21 @@ +import { Command } from 'discord-akairo'; +import { Message } from 'discord.js'; + +export default class PingCommand extends Command { + public constructor() { + super('ping', { + aliases: ['ping', 'latency', 'test'], + clientPermissions: ['SEND_MESSAGES'], + description: { + content: "Checks the bot's ping to Discord.", + }, + category: 'Utilities', + }); + } + + public async exec(msg: Message): Promise<Message | Message[]> { + const message = await msg.util!.send('Ping?'); + const ping = Math.round(message.createdTimestamp - msg.createdTimestamp); + return message.edit(`Pong! \`${ping}ms\``); + } +} diff --git a/src/bot/commands/util/prefix.ts b/src/bot/commands/util/prefix.ts new file mode 100644 index 0000000..5ff3f0c --- /dev/null +++ b/src/bot/commands/util/prefix.ts @@ -0,0 +1,39 @@ +/* import { Command, Argument } from 'discord-akairo'; +import { Message } from 'discord.js'; + +export default class PrefixCommand extends Command { + public constructor() { + super('prefix', { + category: 'Utilities', + channel: 'guild', + aliases: ['prefix'], + args: [ + { + id: 'prefix', + type: Argument.validate('string', (_, p) => !/\s/.test(p) && p.length <= 10), + prompt: { + start: 'What do you want to set the prefix to?', + retry: "C'mon. I need a prefix without spaces and less than 10 characters", + optional: true, + }, + }, + ], + userPermissions: ['MANAGE_GUILD'], + description: { + content: "Changes this server's prefix.", + usage: '[prefix]', + examples: ['', '?', '>'], + }, + }); + } + + public async exec(msg: Message, { prefix }: { prefix: string }): Promise<Message | Message[] | void> { + if (!prefix) { + const prefix = this.client.settings.cache.guilds.get(this.client.user!.id)!.prefix; + return msg.util!.reply(`the current prefix is \`${prefix}\`.`); + } + + await this.client.settings.set('guild', { id: msg.guild!.id }, { prefix }); + return msg.util!.reply(`successfully set the prefix to \`${prefix}\`.`); + } +} */ diff --git a/src/bot/commands/util/stats.ts b/src/bot/commands/util/stats.ts new file mode 100644 index 0000000..7fe2a27 --- /dev/null +++ b/src/bot/commands/util/stats.ts @@ -0,0 +1,58 @@ +import { Command, version as akairoversion } from 'discord-akairo'; +import { Message, version as djsversion } from 'discord.js'; +import { stripIndents } from 'common-tags'; +import * as moment from 'moment'; +import 'moment-duration-format'; + +export default class StatsCommand extends Command { + public constructor() { + super('stats', { + aliases: ['stats', 'uptime'], + clientPermissions: ['EMBED_LINKS'], + description: { + content: 'Provides some stats on the bot.', + }, + category: 'Utilities', + }); + } + + public async exec(msg: Message): Promise<Message | Message[]> { + const duration = moment.duration(this.client.uptime!).format(' D[d] H[h] m[m] s[s]'); + const embed = this.client.util + .embed() + .setTitle(`${this.client.user!.username} Stats`) + .setThumbnail(this.client.user!.displayAvatarURL()) + .addField(`\`β°\` Uptime`, duration, true) + .addField(`\`πΎ\`Memory Usage`, `${(process.memoryUsage().heapUsed / 1024 / 1024).toFixed(2)}MB`, true) + .addField( + `\`π\` General Stats`, + stripIndents` + β’ Servers: ${this.client.guilds.cache.size.toLocaleString('en-US')} + β’ Channels: ${this.client.channels.cache.size.toLocaleString('en-US')} + β’ Users: ${this.client.guilds.cache + .reduce((prev, val) => prev + val.memberCount, 0) + .toLocaleString('en-US')} + `, + true, + ) + .addField( + '`π΄` Reaction Role Stats', + stripIndents` + β’ Current: ${this.client.settings.cache.reactions.filter(r => r.active).size} + β’ Lifetime: ${this.client.settings.cache.reactions.size} + `, + true, + ) + .addField( + '`π` Library Info', + stripIndents` + [\`Akairo Framework\`](https://discord-akairo.github.io/#/): ${akairoversion} + [\`Discord.js\`](https://discord.js.org/#/): ${djsversion} + `, + true, + ) + .addField('`π¨β` Lead Developer', (await this.client.fetchApplication()).owner!.toString(), true) + .setColor(this.client.config.color); + return msg.util!.send({ embed }); + } +} diff --git a/src/bot/commands/util/vote.ts b/src/bot/commands/util/vote.ts new file mode 100644 index 0000000..1ac6616 --- /dev/null +++ b/src/bot/commands/util/vote.ts @@ -0,0 +1,19 @@ +/* import { Command } from 'discord-akairo'; +import { Message } from 'discord.js'; + +export default class VoteCommand extends Command { + public constructor() { + super('vote', { + aliases: ['vote', 'premium'], + clientPermissions: ['SEND_MESSAGES'], + description: { + content: 'Gives information about where you can recieve premium beneifts.', + }, + category: 'Utilities', + }); + } + + public async exec(msg: Message): Promise<Message | Message[] | void> { + return msg.util?.reply('Soonβ’.'); + } +} */ diff --git a/src/bot/index.ts b/src/bot/index.ts new file mode 100644 index 0000000..3cfb5c9 --- /dev/null +++ b/src/bot/index.ts @@ -0,0 +1,3 @@ +import ReactionClient from './client/ReactionClient'; + +export default ReactionClient; diff --git a/src/bot/inhibitors/sendMessages.ts b/src/bot/inhibitors/sendMessages.ts new file mode 100644 index 0000000..51e1fe3 --- /dev/null +++ b/src/bot/inhibitors/sendMessages.ts @@ -0,0 +1,18 @@ +import { Inhibitor } from 'discord-akairo'; +import { Message, TextChannel } from 'discord.js'; + +export default class SendMessagesInhibtor extends Inhibitor { + public constructor() { + super('sendMessages', { + reason: 'sendMessages', + }); + } + + // @ts-ignore + public exec(msg: Message): boolean { + if (!msg.guild) return false; + if (msg.channel instanceof TextChannel) { + return !msg.channel.permissionsFor(this.client.user!)!.has('SEND_MESSAGES'); + } + } +} diff --git a/src/bot/listeners/client/channelDelete.ts b/src/bot/listeners/client/channelDelete.ts new file mode 100644 index 0000000..f8c45db --- /dev/null +++ b/src/bot/listeners/client/channelDelete.ts @@ -0,0 +1,21 @@ +import { Listener } from 'discord-akairo'; +import { GuildChannel } from 'discord.js'; + +export default class ChannelDeleteListener extends Listener { + public constructor() { + super('channelDelete', { + emitter: 'client', + event: 'channelDelete', + category: 'client', + }); + } + + public exec(channel: GuildChannel): void { + if (!channel.guild) return; + const existing = this.client.settings.cache.reactions.filter(r => r.channelID === channel.id); + if (!existing.size) return; + for (const c of existing.values()) { + this.client.settings.set('reaction', { id: c.id }, { active: false }); + } + } +} diff --git a/src/bot/listeners/client/debug.ts b/src/bot/listeners/client/debug.ts new file mode 100644 index 0000000..d74b592 --- /dev/null +++ b/src/bot/listeners/client/debug.ts @@ -0,0 +1,15 @@ +import { Listener } from 'discord-akairo'; + +export default class DebugListener extends Listener { + public constructor() { + super('debug', { + emitter: 'client', + event: 'debug', + category: 'client', + }); + } + + public exec(event: any): void { + this.client.logger.info(`[DEBUG]: ${event}`); + } +} diff --git a/src/bot/listeners/client/emojiDelete.ts b/src/bot/listeners/client/emojiDelete.ts new file mode 100644 index 0000000..012d324 --- /dev/null +++ b/src/bot/listeners/client/emojiDelete.ts @@ -0,0 +1,20 @@ +import { Listener } from 'discord-akairo'; +import { GuildEmoji } from 'discord.js'; + +export default class EmojiDeleteListener extends Listener { + public constructor() { + super('emojiDelete', { + emitter: 'client', + event: 'emojiDelete', + category: 'client', + }); + } + + public exec(emoji: GuildEmoji): void { + const existing = this.client.settings.cache.reactions.filter(r => r.emoji === emoji.id && r.emojiType === 'custom'); + if (!existing.size) return; + for (const c of existing.values()) { + this.client.settings.set('reaction', { id: c.id }, { active: false }); + } + } +} diff --git a/src/bot/listeners/client/guildCreate.ts b/src/bot/listeners/client/guildCreate.ts new file mode 100644 index 0000000..f649e2c --- /dev/null +++ b/src/bot/listeners/client/guildCreate.ts @@ -0,0 +1,23 @@ +import { Listener } from 'discord-akairo'; +import { Guild } from 'discord.js'; + +export default class GuildCreateListener extends Listener { + public constructor() { + super('guildCreate', { + emitter: 'client', + event: 'guildCreate', + category: 'client', + }); + } + + public async exec(guild: Guild): Promise<void> { + const existing = this.client.settings.cache.guilds.get(guild.id); + if (!existing) { + this.client.settings.new('guild', { + id: guild.id, + premium: false, + prefix: process.env.PREFIX || 'r!', + }); + } + } +} diff --git a/src/bot/listeners/client/messageDelete.ts b/src/bot/listeners/client/messageDelete.ts new file mode 100644 index 0000000..63d8e56 --- /dev/null +++ b/src/bot/listeners/client/messageDelete.ts @@ -0,0 +1,20 @@ +import { Listener } from 'discord-akairo'; +import { Message } from 'discord.js'; + +export default class MessageDeleteListener extends Listener { + public constructor() { + super('messageDelete', { + emitter: 'client', + event: 'messageDelete', + category: 'client', + }); + } + + public exec(msg: Message): void { + const existing = this.client.settings.cache.reactions.filter(r => r.messageID === msg.id); + if (!existing.size) return; + for (const c of existing.values()) { + this.client.settings.set('reaction', { id: c.id }, { active: false }); + } + } +} diff --git a/src/bot/listeners/client/messageReactionAdd.ts b/src/bot/listeners/client/messageReactionAdd.ts new file mode 100644 index 0000000..1ef2ea9 --- /dev/null +++ b/src/bot/listeners/client/messageReactionAdd.ts @@ -0,0 +1,65 @@ +import { Listener } from 'discord-akairo'; +import { User, MessageReaction, Permissions } from 'discord.js'; +import { stripIndents } from 'common-tags'; + +export default class ReactionAddListener extends Listener { + public queue: Set<string> = new Set(); + + public constructor() { + super('messageReactionAdd', { + emitter: 'client', + event: 'messageReactionAdd', + category: 'client', + }); + } + + public async exec(reaction: MessageReaction, user: User): Promise<boolean | void> { + let msg = reaction.message; + if (msg.partial) msg = await msg.fetch(); + + // ignore a message reaction that isn't a guild + if (!msg.guild) return; + + const key = `${reaction.emoji.toString()}:${user.id}`; + if (this.queue.has(key)) return; + this.queue.add(key); + + // fetch our ME because it can be uncached + if (!msg.guild.me) await msg.guild.members.fetch(this.client.user?.id!); + + // get all of our message reactions with the message ID of our message. If none, return. + const messages = this.client.settings.cache.reactions.filter(r => r.messageID === msg.id); + if (!messages.size) return this.queue.delete(key); + + const rr = messages.find(r => [reaction.emoji.name, reaction.emoji.id].includes(r.emoji)); + if (!rr || !rr.active) return this.queue.delete(key); + + // fetch the role store because it may be uncached + const role = await msg.guild.roles.fetch(rr.roleID).catch(() => undefined); + if (!role) return this.queue.delete(key); + + // check if we have permissions to manage roles + if (!msg.guild.me?.permissions.has(Permissions.FLAGS.MANAGE_ROLES)) return this.queue.delete(key); + + // check if we have the permissions to apply that specific role + if (role.comparePositionTo(msg.guild.me.roles.highest) >= 0) return this.queue.delete(key); + + const member = await msg.guild.members.fetch(user).catch(() => undefined); + if (!member) return this.queue.delete(key); + + try { + await member.roles.add(role); + await member.send(stripIndents` + You've been given the **${role.name}** role in ${msg.guild.name}. + Please Note: You must wait 5 seconds before you can un-react to have me remove ${role.name}. + `); + } catch (err) { + this.client.logger.info(`[ADDROLE ERROR]: ${err}.`); + } + + // remove the user from the queue system in 2500 seconds so they can't spam reactions + setTimeout(() => { + this.queue.delete(key); + }, 2500); + } +} diff --git a/src/bot/listeners/client/messageReactionRemove.ts b/src/bot/listeners/client/messageReactionRemove.ts new file mode 100644 index 0000000..7a1e8f7 --- /dev/null +++ b/src/bot/listeners/client/messageReactionRemove.ts @@ -0,0 +1,55 @@ +import { Listener } from 'discord-akairo'; +import { User, MessageReaction, Permissions } from 'discord.js'; +import { stripIndents } from 'common-tags'; + +export default class MessageReactionRemove extends Listener { + public queue: Set<string> = new Set(); + + public constructor() { + super('messageReactionRemove', { + emitter: 'client', + event: 'messageReactionRemove', + category: 'client', + }); + } + + public async exec(reaction: MessageReaction, user: User): Promise<boolean | void> { + let msg = reaction.message; + if (msg.partial) msg = await msg.fetch(); + + // ignore a message reaction that isn't a guild + if (!msg.guild) return; + + // fetch our ME because it can be uncached + if (!msg.guild.me || msg.guild.me.partial) await msg.guild.members.fetch(this.client.user?.id!); + + // get all of our message reactions with the message ID of our message. If none, return. + const messages = this.client.settings.cache.reactions.filter(r => r.messageID === msg.id); + if (!messages || !messages.size) return; + + const rr = messages.find(r => [reaction.emoji.name, reaction.emoji.id].includes(r.emoji)); + if (!rr || !rr.active) return; + + // fetch the role store because it may be uncached + const role = await msg.guild.roles.fetch(rr.roleID).catch(() => undefined); + if (!role) return; + + // check if we have permissions to manage roles + if (!msg.guild.me!.permissions.has(Permissions.FLAGS.MANAGE_ROLES)) return; + + // check if we have the permissions to apply that specific role + if (role.comparePositionTo(msg.guild.me!.roles.highest) >= 0) return; + + const member = await msg.guild.members.fetch(user).catch(() => undefined); + if (!member || !member.roles.cache.has(role.id)) return; + + try { + await member.roles.remove(role); + await member.send(stripIndents` + The **${role.name}** role has been removed from you in ${msg.guild.name}. + + Please Note: You must wait 5 seconds before you can re-react to have **${role.name}** reinstated. + `); + } catch {} + } +} diff --git a/src/bot/listeners/client/ready.ts b/src/bot/listeners/client/ready.ts new file mode 100644 index 0000000..2b08519 --- /dev/null +++ b/src/bot/listeners/client/ready.ts @@ -0,0 +1,68 @@ +import { Listener } from 'discord-akairo'; +import { ActivityType, Guild } from 'discord.js'; + +export interface ReactionStatus { + text: string; + type: ActivityType; +} + +export default class ReadyListener extends Listener { + public constructor() { + super('ready', { + emitter: 'client', + event: 'ready', + category: 'client', + }); + } + + public async exec(): Promise<void> { + this.client.logger.info(`[READY] ${this.client.user?.tag} is ready.`); + + const activities: ReactionStatus[] = [ + { + text: `${this.client.settings.cache.reactions.size} Reaction Roles πͺ`, + type: 'WATCHING', + }, + { + text: 'https://sycer.dev/ π', + type: 'WATCHING', + }, + { + text: `with ${this.client.guilds.cache.reduce((prev, val) => { + return prev + val.memberCount; + }, 0)} Users πͺ`, + type: 'PLAYING', + }, + { + text: `${this.client.guilds.cache.size.toLocaleString('en-US')} Guilds π‘`, + type: 'WATCHING', + }, + ]; + + const statuses = this.infinite(activities); + + setInterval(() => { + const status = statuses.next() as IteratorResult<ReactionStatus>; + this.client.user!.setActivity(status.value.text, { type: status.value.type }); + }, 300000); + + setInterval(() => this._clearPresences(), 9e5); + } + + private _clearPresences(): void { + const i = this.client.guilds.cache.reduce((acc: number, g: Guild): number => { + acc += g.presences.cache.size; + g.presences.cache.clear(); + return acc; + }, 0); + this.client.emit('debug', `[PRESNCES]: Cleared ${i} presneces in ${this.client.guilds.cache.size} guilds.`); + } + + public *infinite(arr: ReactionStatus[]) { + let i = 0; + while (true) { + yield arr[i]; + i = (i + 1) % arr.length; + } + } +} diff --git a/src/bot/listeners/client/roleDelete.ts b/src/bot/listeners/client/roleDelete.ts new file mode 100644 index 0000000..fd4acf9 --- /dev/null +++ b/src/bot/listeners/client/roleDelete.ts @@ -0,0 +1,17 @@ +import { Listener } from 'discord-akairo'; +import { Role } from 'discord.js'; + +export default class RoleDelete extends Listener { + public constructor() { + super('roleDelete', { + emitter: 'client', + event: 'roleDelete', + category: 'client', + }); + } + + public exec(role: Role): void { + const existing = this.client.settings.cache.reactions.filter(r => r.roleID === role.id); + for (const { _id } of existing.values()) this.client.settings.set('reaction', { _id }, { active: false }); + } +} diff --git a/src/bot/listeners/commandHandler/commandBlocked.ts b/src/bot/listeners/commandHandler/commandBlocked.ts new file mode 100644 index 0000000..c35c9a5 --- /dev/null +++ b/src/bot/listeners/commandHandler/commandBlocked.ts @@ -0,0 +1,36 @@ +import { Listener, Command } from 'discord-akairo'; +import { Message, TextChannel } from 'discord.js'; + +export default class CommandBlockedListener extends Listener { + public constructor() { + super('commandBlocked', { + event: 'commandBlocked', + emitter: 'commandHandler', + category: 'commandHandler', + }); + } + + public async exec(msg: Message, command: Command, reason: string): Promise<Message | Message[] | void> { + if (reason === 'sendMessages') return; + + const text = { + owner: 'you must be the owner to use this command.', + guild: 'you must be in a guild to use this command.', + dm: 'This command must be ran in DMs.', + } as { [key: string]: string }; + + const location = msg.guild ? msg.guild.name : msg.author.tag; + this.client.logger.info(`[COMMANDS BLOCKED] ${command.id} with reason ${reason} in ${location}`); + + const res = text[reason]; + if (!res) return; + + if ( + msg.guild && + msg.channel instanceof TextChannel && + msg.channel.permissionsFor(this.client.user!)!.has('SEND_MESSAGES') + ) { + return msg.util!.reply(res); + } + } +} diff --git a/src/bot/listeners/commandHandler/commandStarted.ts b/src/bot/listeners/commandHandler/commandStarted.ts new file mode 100644 index 0000000..7122f73 --- /dev/null +++ b/src/bot/listeners/commandHandler/commandStarted.ts @@ -0,0 +1,18 @@ +import { Listener, Command } from 'discord-akairo'; +import { Message } from 'discord.js'; + +export default class CommandStartedListener extends Listener { + public constructor() { + super('commandStarted', { + emitter: 'commandHandler', + event: 'commandStarted', + category: 'commandHandler', + }); + } + + public exec(msg: Message, command: Command): void { + if (msg.util!.parsed!.command) return; + const where = msg.guild ? msg.guild.name : msg.author.tag; + this.client.logger.info(`[COMMAND STARTED] ${command.id} in ${where}`); + } +} diff --git a/src/bot/listeners/commandHandler/cooldown.ts b/src/bot/listeners/commandHandler/cooldown.ts new file mode 100644 index 0000000..0c133dd --- /dev/null +++ b/src/bot/listeners/commandHandler/cooldown.ts @@ -0,0 +1,17 @@ +import { Listener } from 'discord-akairo'; +import { Message } from 'discord.js'; + +export default class CooldownListener extends Listener { + public constructor() { + super('cooldown', { + emitter: 'commandHandler', + event: 'cooldown', + category: 'commandHandler', + }); + } + + public exec(msg: Message, _: any, time: number): Promise<Message | Message[]> { + time /= 1000; + return msg.util!.reply(`Chill out! You can use that command again in ${time.toFixed()} seconds.`); + } +} diff --git a/src/bot/listeners/commandHandler/error.ts b/src/bot/listeners/commandHandler/error.ts new file mode 100644 index 0000000..dc7fa9b --- /dev/null +++ b/src/bot/listeners/commandHandler/error.ts @@ -0,0 +1,23 @@ +import { Listener } from 'discord-akairo'; +import { Message, TextChannel } from 'discord.js'; + +export default class ErrorHandler extends Listener { + public constructor() { + super('error', { + emitter: 'commandHandler', + event: 'error', + category: 'commandHandler', + }); + } + + public async exec(err: Error, msg: Message): Promise<Message | Message[] | void> { + this.client.logger.error(`[COMMAND ERROR] ${err} ${err.stack}`); + if ( + msg.guild && + msg.channel instanceof TextChannel && + msg.channel.permissionsFor(this.client.user!)!.has('SEND_MESSAGES') + ) { + return msg.channel.send(['Looks like an error occured.', '```js', `${err}`, '```']); + } + } +} diff --git a/src/bot/listeners/commandHandler/missingPermissions.ts b/src/bot/listeners/commandHandler/missingPermissions.ts new file mode 100644 index 0000000..8600a51 --- /dev/null +++ b/src/bot/listeners/commandHandler/missingPermissions.ts @@ -0,0 +1,56 @@ +import { Listener, Command } from 'discord-akairo'; +import { Message, User, TextChannel } from 'discord.js'; + +const MISSING = { + notOwner: 'this command is reserved for the server owner!', +} as { [key: string]: string }; + +export default class MissingPermissionsListener extends Listener { + public constructor() { + super('missingPermissions', { + category: 'commandHandler', + emitter: 'commandHandler', + event: 'missingPermissions', + }); + } + + public exec(msg: Message, _: Command, type: any, missing: any): void | Promise<Message | void> { + if (Object.keys(missing).includes(missing)) return msg.util!.reply(MISSING[missing]); + + let text; + if (type === 'client') { + const str = this.missingPermissions(msg.channel as TextChannel, this.client.user!, missing); + text = `I'm missing ${str} to process that command!`; + } else { + const str = this.missingPermissions(msg.channel as TextChannel, msg.author, missing); + text = `you're missing ${str} to use that command!`; + } + + if ( + msg.guild && + msg.channel instanceof TextChannel && + msg.channel.permissionsFor(this.client.user!)!.has('SEND_MESSAGES') + ) { + return msg.util!.reply(text); + } + } + + // credit to 1Computer1 + public missingPermissions(channel: TextChannel, user: User, permissions: any) { + const missingPerms = channel + .permissionsFor(user)! + .missing(permissions) + .map((str: string): string => { + if (str === 'VIEW_CHANNEL') return '`Read Messages`'; + if (str === 'SEND_TTS_MESSAGES') return '`Send TTS Messages`'; + if (str === 'USE_VAD') return '`Use VAD`'; + return `\`${str + .replace(/_/g, ' ') + .toLowerCase() + .replace(/\b(\w)/g, (char: string): string => char.toUpperCase())}\``; + }); + return missingPerms.length > 1 + ? `${missingPerms.slice(0, -1).join(', ')} and ${missingPerms.slice(-1)[0]}` + : missingPerms[0]; + } +} diff --git a/src/bot/util/constants.ts b/src/bot/util/constants.ts new file mode 100644 index 0000000..e182023 --- /dev/null +++ b/src/bot/util/constants.ts @@ -0,0 +1,19 @@ +export const SENSITIVE_PATTERN_REPLACEMENT = '[REDACTED]'; + +export const MESSAGES = { + NOT_ACTIVATED: "this server hasn't been activated yet!", + COMMANDS: { + EVAL: { + LONG_OUTPUT: (link: string): string => `Output too long, uploading it to hastebin instead: ${link}.`, + INPUT: (code: string): string => `Input:\`\`\`js\n${code}\n\`\`\``, + OUTPUT: (code: string): string => `Output:\`\`\`js\n${code}\n\`\`\``, + TYPE: ``, + TIME: ``, + HASTEBIN: ``, + ERRORS: { + TOO_LONG: `Output too long, failed to upload to hastebin as well.`, + CODE_BLOCK: (err: Error): string => `Error:\`\`\`xl\n${err}\n\`\`\``, + }, + }, + }, +}; diff --git a/src/bot/util/index.ts b/src/bot/util/index.ts new file mode 100644 index 0000000..59c2ffa --- /dev/null +++ b/src/bot/util/index.ts @@ -0,0 +1,17 @@ +import fetch from 'node-fetch'; + +export async function postHaste(code: string, lang?: string): Promise<string> { + try { + if (code.length > 400000) { + return 'Document exceeds maximum length.'; + } + const res = await fetch('https://paste.nomsy.net/documents', { method: 'POST', body: code }); + const { key, message } = await res.json(); + if (!key) { + return message; + } + return `https://paste.nomsy.net/${key}${lang && `.${lang}`}`; + } catch (err) { + throw err; + } +} diff --git a/src/bot/util/logger.ts b/src/bot/util/logger.ts new file mode 100644 index 0000000..6b87806 --- /dev/null +++ b/src/bot/util/logger.ts @@ -0,0 +1,44 @@ +import { createLogger, transports, format, addColors } from 'winston'; + +const loggerLevels = { + levels: { + error: 0, + debug: 1, + warn: 2, + data: 3, + info: 4, + verbose: 5, + silly: 6, + custom: 7, + }, + colors: { + error: 'red', + debug: 'blue', + warn: 'yellow', + data: 'grey', + info: 'green', + verbose: 'cyan', + silly: 'magenta', + custom: 'yellow', + }, +}; + +addColors(loggerLevels.colors); + +export const logger = createLogger({ + levels: loggerLevels.levels, + format: format.combine( + format.colorize({ level: true }), + format.errors({ stack: true }), + format.splat(), + format.timestamp({ format: 'MM/DD/YYYY HH:mm:ss' }), + format.printf((data: any) => { + const { timestamp, level, message, ...rest } = data; + return `[${timestamp}] ${level}: ${message}${ + Object.keys(rest).length ? `\n${JSON.stringify(rest, null, 2)}` : '' + }`; + }), + ), + transports: new transports.Console(), + level: 'custom', +}); diff --git a/src/database/index.ts b/src/database/index.ts new file mode 100644 index 0000000..29401cb --- /dev/null +++ b/src/database/index.ts @@ -0,0 +1,5 @@ +import Guild from './models/Guild'; +import Reaction from './models/Reaction'; +import SettingsProvider from './structures/SettingsProvider'; + +export { SettingsProvider, Guild, Reaction }; diff --git a/src/database/models/Guild.ts b/src/database/models/Guild.ts new file mode 100644 index 0000000..7ed73d5 --- /dev/null +++ b/src/database/models/Guild.ts @@ -0,0 +1,22 @@ +import { Document, Schema, model } from 'mongoose'; + +export interface Guild extends Document { + id: string; + prefix: string; + premium: boolean; + expiresAt: Date; +} + +const Guild: Schema = new Schema( + { + id: String, + prefix: String, + premium: Boolean, + expiresAt: Date, + }, + { + strict: false, + }, +); + +export default model<Guild>('Guild', Guild); diff --git a/src/database/models/Reaction.ts b/src/database/models/Reaction.ts new file mode 100644 index 0000000..f72a799 --- /dev/null +++ b/src/database/models/Reaction.ts @@ -0,0 +1,41 @@ +import { Document, Schema, model } from 'mongoose'; + +export interface Reaction extends Document { + guildID: string; + messageID: string; + channelID: string; + userID: string; + id: string; + emoji: string; + emojiType: string; + roleID: string; + uses: number; + expiresAt?: Date; + type: number; + active: boolean; +} + +const Reaction: Schema = new Schema( + { + guildID: String, + messageID: String, + channelID: String, + userID: String, + id: String, + emoji: String, + emojiType: String, + roleID: String, + uses: Number, + expiresAt: Date, + type: Number, + active: { + type: Boolean, + default: true, + }, + }, + { + strict: false, + }, +); + +export default model<Reaction>('Reaction', Reaction); diff --git a/src/database/structures/SettingsProvider.ts b/src/database/structures/SettingsProvider.ts new file mode 100644 index 0000000..8ca6b92 --- /dev/null +++ b/src/database/structures/SettingsProvider.ts @@ -0,0 +1,211 @@ +import { Collection } from 'discord.js'; +import { connect, Model, connection, Connection } from 'mongoose'; +import { Logger } from 'winston'; +import ReactionModel, { Reaction } from '../models/Reaction'; +import GuildModel, { Guild } from '../models/Guild'; +import { MONGO_EVENTS } from '../util/constants'; +import ReactionClient from '../../bot/client/ReactionClient'; + +let i = 0; + +/** + * The key, model and cached collection of a database model. + * @interface + */ +interface Combo { + key: string; + model: Model<any>; + cache: Collection<string, any>; +} + +/** + * The Settings Provider that handles all database reads and rights. + * @private + */ +export default class SettingsProvider { + protected readonly client: ReactionClient; + + protected readonly guilds: Collection<string, Guild> = new Collection(); + protected readonly reactions: Collection<string, Reaction> = new Collection(); + + protected readonly GuildModel = GuildModel; + protected readonly ReactionModel = ReactionModel; + + /** + * + * @param {GiveawayClient} client - The extended Akairo Client + */ + public constructor(client: ReactionClient) { + this.client = client; + } + + /** + * Retuns all the collection caches. + * @returns {Object} + */ + public get cache() { + return { + guilds: this.guilds, + reactions: this.reactions, + }; + } + + /** + * Returns the database combos + * @returns {Combo[]} + */ + public get combos(): Combo[] { + return [ + { + key: 'guild', + model: this.GuildModel, + cache: this.guilds, + }, + { + key: 'reaction', + model: this.ReactionModel, + cache: this.reactions, + }, + ]; + } + + /** + * Creates a new database document with the provided collection name and data. + * @param {string} type - The collection name + * @param {object} data - The data for the new document + * @returns {Docuement} + */ + public async new(type: 'guild', data: Partial<Guild>): Promise<Guild>; + public async new(type: 'reaction', data: Partial<Reaction>): Promise<Reaction>; + public async new(type: string, data: object): Promise<object> { + const combo = this.combos.find(c => c.key === type); + if (combo) { + const document = new combo.model(data); + await document.save(); + this.client.logger.data(`[DATABASE] Made new ${combo.model.modelName} document with ID of ${document._id}.`); + combo.cache.set(document.id, document); + return document; + } + throw Error(`"${type}" is not a valid model key.`); + } + + /** + * Updates the a database document's data. + * @param {Types} type - The collection name + * @param {object} key - The search paramaters for the document + * @param {object} data - The data you wish to overwrite in the update + * @returns {Promise<Faction | Guild | null>} + */ + public async set(type: 'guild', key: Partial<Guild>, data: Partial<Guild>): Promise<Guild | null>; + public async set(type: 'reaction', key: Partial<Reaction>, data: Partial<Reaction>): Promise<Reaction | null>; + public async set(type: string, key: object, data: object): Promise<object | null> { + const combo = this.combos.find(c => c.key === type); + if (combo) { + const document = await combo.model.findOneAndUpdate(key, { $set: data }, { new: true }); + if (document) { + this.client.logger.verbose(`[DATABASE] Edited ${combo.model.modelName} document with ID of ${document._id}.`); + combo.cache.set(document.id, document); + return document; + } + return null; + } + throw Error(`"${type}" is not a valid model key.`); + } + + /** + * Removes a database document. + * @param {Types} type - The collection name + * @param {object} data - The search paramaters for the document + * @returns {Promise<Faction | Guild | null>>} The document that was removed, if any. + */ + public async remove(type: 'guild', data: Partial<Guild>): Promise<Guild | null>; + public async remove(type: 'user', data: Partial<Reaction>): Promise<Reaction | null>; + public async remove(type: string, data: object): Promise<object | null> { + const combo = this.combos.find(c => c.key === type); + if (combo) { + const document = await combo.model.findOneAndRemove(data); + if (document) { + this.client.logger.verbose(`[DATABASE] Edited ${combo.model.modelName} document with ID of ${document._id}.`); + combo.cache.delete(document.id); + return document; + } + return null; + } + throw Error(`"${type}" is not a valid model key.`); + } + + /** + * Caching all database documents. + * @returns {number} The amount of documents cached total. + * @private + */ + private async _cacheAll(): Promise<number> { + for (const combo of this.combos) await this._cache(combo); + return i; + } + + /** + * Caching each collection's documents. + * @param {Combo} combo - The combo name + * @returns {number} The amount of documents cached from that collection. + * @private + */ + private async _cache(combo: Combo): Promise<any> { + const items = await combo.model.find(); + for (const i of items) combo.cache.set(i.id, i); + this.client.logger.verbose( + `[DATABASE]: Cached ${items.length.toLocaleString('en-US')} items from ${combo.model.modelName}.`, + ); + return (i += items.length); + } + + /** + * Connect to the database + * @param {string} url - the mongodb uri + * @returns {Promise<number | Logger>} Returns a + */ + private async _connect(url: string | undefined): Promise<Logger | number> { + if (url) { + const start = Date.now(); + try { + await connect(url, { + useCreateIndex: true, + useNewUrlParser: true, + useFindAndModify: false, + useUnifiedTopology: true, + }); + } catch (err) { + this.client.logger.error(`[DATABASE] Error when connecting to MongoDB:\n${err.stack}`); + process.exit(1); + } + return this.client.logger.verbose(`[DATABASE] Connected to MongoDB in ${Date.now() - start}ms.`); + } + this.client.logger.error('[DATABASE] No MongoDB url provided!'); + return process.exit(1); + } + + /** + * Adds all the listeners to the mongo connection. + * @param connection - The mongoose connection + * @returns {void} + * @private + */ + private _addListeners(connection: Connection): void { + for (const [event, msg] of Object.entries(MONGO_EVENTS)) { + connection.on(event, () => this.client.logger.data(`[DATABASE]: ${msg}`)); + } + } + + /** + * Starts the Settings Provider + * @returns {SettingsProvider} + */ + public async init(): Promise<this> { + this._addListeners(connection); + await this._connect(process.env.MONGO); + this.client.logger.verbose(`[DATABASE]: Now caching ${this.combos.length} schema documents.`); + await this._cacheAll(); + this.client.logger.info(`[DATABASE] [LAUNCHED] Successfully connected and cached ${i} documents.`); + return this; + } +} diff --git a/src/database/util/constants.ts b/src/database/util/constants.ts new file mode 100644 index 0000000..911a28a --- /dev/null +++ b/src/database/util/constants.ts @@ -0,0 +1,8 @@ +export const MONGO_EVENTS = { + connecting: 'Connecting to MongoDB...', + connected: 'Successfully connected to MongoDB.', + disconnecting: 'Disconnecting from MongoDB...', + disconnected: 'Disconnected from MongoDB...', + close: 'MongoDB connection closed.', + reconnected: 'Successfully reconnected to MongoDB.', +} as { [key: string]: string }; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..be58b49 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,9 @@ +import ReactionClient from './bot'; +require('dotenv').config(); + +new ReactionClient({ + token: process.env.TOKEN!, + owners: process.env.OWNERS!.split(','), + color: process.env.COLOR!, + prefix: process.env.PREFIX!, +}).launch(); |