summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
author8cy <[email protected]>2020-07-19 04:36:14 -0700
committer8cy <[email protected]>2020-07-19 04:36:14 -0700
commitf00bcd79995bc8d7af3f297cab8085fd28b89435 (patch)
tree9a4ddd1f0a945b1f20d98c20e621c0d8a69bb8ad /src
downloadwater-waifu-re-f00bcd79995bc8d7af3f297cab8085fd28b89435.tar.xz
water-waifu-re-f00bcd79995bc8d7af3f297cab8085fd28b89435.zip
:star:
Diffstat (limited to 'src')
-rw-r--r--src/bot/client/ReactionClient.ts107
-rw-r--r--src/bot/commands/owner/eval.ts142
-rw-r--r--src/bot/commands/owner/reload.ts65
-rw-r--r--src/bot/commands/reaction/list.ts41
-rw-r--r--src/bot/commands/reaction/new.ts182
-rw-r--r--src/bot/commands/reaction/remove.ts57
-rw-r--r--src/bot/commands/util/help.ts83
-rw-r--r--src/bot/commands/util/info.ts62
-rw-r--r--src/bot/commands/util/invite.ts26
-rw-r--r--src/bot/commands/util/ping.ts21
-rw-r--r--src/bot/commands/util/prefix.ts39
-rw-r--r--src/bot/commands/util/stats.ts58
-rw-r--r--src/bot/commands/util/vote.ts19
-rw-r--r--src/bot/index.ts3
-rw-r--r--src/bot/inhibitors/sendMessages.ts18
-rw-r--r--src/bot/listeners/client/channelDelete.ts21
-rw-r--r--src/bot/listeners/client/debug.ts15
-rw-r--r--src/bot/listeners/client/emojiDelete.ts20
-rw-r--r--src/bot/listeners/client/guildCreate.ts23
-rw-r--r--src/bot/listeners/client/messageDelete.ts20
-rw-r--r--src/bot/listeners/client/messageReactionAdd.ts65
-rw-r--r--src/bot/listeners/client/messageReactionRemove.ts55
-rw-r--r--src/bot/listeners/client/ready.ts68
-rw-r--r--src/bot/listeners/client/roleDelete.ts17
-rw-r--r--src/bot/listeners/commandHandler/commandBlocked.ts36
-rw-r--r--src/bot/listeners/commandHandler/commandStarted.ts18
-rw-r--r--src/bot/listeners/commandHandler/cooldown.ts17
-rw-r--r--src/bot/listeners/commandHandler/error.ts23
-rw-r--r--src/bot/listeners/commandHandler/missingPermissions.ts56
-rw-r--r--src/bot/util/constants.ts19
-rw-r--r--src/bot/util/index.ts17
-rw-r--r--src/bot/util/logger.ts44
-rw-r--r--src/database/index.ts5
-rw-r--r--src/database/models/Guild.ts22
-rw-r--r--src/database/models/Reaction.ts41
-rw-r--r--src/database/structures/SettingsProvider.ts211
-rw-r--r--src/database/util/constants.ts8
-rw-r--r--src/index.ts9
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();