aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/bot.ts7
-rw-r--r--src/commands/admin/PrefixCommand.ts30
-rw-r--r--src/commands/info/BotInfoCommand.ts58
-rw-r--r--src/commands/info/HelpCommand.ts79
-rw-r--r--src/commands/info/PingCommand.ts42
-rw-r--r--src/commands/moderation/BanCommand.ts137
-rw-r--r--src/commands/moderation/KickCommand.ts72
-rw-r--r--src/commands/moderation/ModlogCommand.ts143
-rw-r--r--src/commands/moderation/WarnCommand.ts54
-rw-r--r--src/commands/owner/EvalCommand.ts139
-rw-r--r--src/commands/owner/ReloadCommand.ts34
-rw-r--r--src/config/example-options.ts30
-rw-r--r--src/inhibitors/blacklist/BlacklistInhibitor.ts14
-rw-r--r--src/lib/extensions/BotClient.ts274
-rw-r--r--src/lib/extensions/BotCommand.ts6
-rw-r--r--src/lib/extensions/BotGuild.ts38
-rw-r--r--src/lib/extensions/BotInhibitor.ts6
-rw-r--r--src/lib/extensions/BotListener.ts6
-rw-r--r--src/lib/extensions/BotMessage.ts50
-rw-r--r--src/lib/extensions/Util.ts196
-rw-r--r--src/lib/types/BaseModel.ts6
-rw-r--r--src/lib/types/Models.ts102
-rw-r--r--src/lib/utils/TopGG.ts110
-rw-r--r--src/listeners/client/ReadyListener.ts16
-rw-r--r--src/listeners/commands/CommandBlockedListener.ts34
-rw-r--r--src/listeners/guild/Unban.ts25
-rw-r--r--src/tasks.ts38
27 files changed, 1746 insertions, 0 deletions
diff --git a/src/bot.ts b/src/bot.ts
new file mode 100644
index 0000000..3d427e9
--- /dev/null
+++ b/src/bot.ts
@@ -0,0 +1,7 @@
+import { BotClient } from './lib/extensions/BotClient';
+import * as config from './config/options';
+
+const client: BotClient = new BotClient(config);
+client.start();
+
+// πŸ¦€
diff --git a/src/commands/admin/PrefixCommand.ts b/src/commands/admin/PrefixCommand.ts
new file mode 100644
index 0000000..8fb50f8
--- /dev/null
+++ b/src/commands/admin/PrefixCommand.ts
@@ -0,0 +1,30 @@
+import { BotCommand } from '../../lib/extensions/BotCommand';
+import { BotMessage } from '../../lib/extensions/BotMessage';
+
+export default class PrefixCommand extends BotCommand {
+ constructor() {
+ super('prefix', {
+ aliases: ['prefix'],
+ args: [
+ {
+ id: 'prefix'
+ }
+ ],
+ userPermissions: ['MANAGE_GUILD']
+ });
+ }
+ async exec(
+ message: BotMessage,
+ { prefix }: { prefix?: string }
+ ): Promise<void> {
+ if (prefix) {
+ await message.settings.setPrefix(prefix);
+ await message.util.send(`Sucessfully set prefix to \`${prefix}\``);
+ } else {
+ await message.settings.setPrefix(this.client.config.prefix);
+ await message.util.send(
+ `Sucessfully reset prefix to \`${this.client.config.prefix}\``
+ );
+ }
+ }
+}
diff --git a/src/commands/info/BotInfoCommand.ts b/src/commands/info/BotInfoCommand.ts
new file mode 100644
index 0000000..27e14c4
--- /dev/null
+++ b/src/commands/info/BotInfoCommand.ts
@@ -0,0 +1,58 @@
+import { MessageEmbed } from 'discord.js';
+import { BotCommand } from '../../lib/extensions/BotCommand';
+import { duration } from 'moment';
+import { BotMessage } from '../../lib/extensions/BotMessage';
+
+export default class BotInfoCommand extends BotCommand {
+ constructor() {
+ super('botinfo', {
+ aliases: ['botinfo'],
+ description: {
+ content: 'Shows information about the bot',
+ usage: 'botinfo',
+ examples: ['botinfo']
+ }
+ });
+ }
+
+ public async exec(message: BotMessage): Promise<void> {
+ const owners = (await this.client.util.mapIDs(this.client.ownerID))
+ .map((u) => u.tag)
+ .join('\n');
+ const currentCommit = (
+ await this.client.util.shell('git rev-parse HEAD')
+ ).stdout.replace('\n', '');
+ const repoUrl = (
+ await this.client.util.shell('git remote get-url origin')
+ ).stdout.replace('\n', '');
+ const embed = new MessageEmbed()
+ .setTitle('Bot Info:')
+ .addFields([
+ {
+ name: 'Owners',
+ value: owners,
+ inline: true
+ },
+ {
+ name: 'Uptime',
+ value: this.client.util.capitalize(
+ duration(this.client.uptime, 'milliseconds').humanize()
+ )
+ },
+ {
+ name: 'User count',
+ value: this.client.users.cache.size,
+ inline: true
+ },
+ {
+ name: 'Current commit',
+ value: `[${currentCommit.substring(
+ 0,
+ 7
+ )}](${repoUrl}/commit/${currentCommit})`
+ }
+ ])
+ .setTimestamp();
+ await message.util.send(embed);
+ }
+}
diff --git a/src/commands/info/HelpCommand.ts b/src/commands/info/HelpCommand.ts
new file mode 100644
index 0000000..4aa45e0
--- /dev/null
+++ b/src/commands/info/HelpCommand.ts
@@ -0,0 +1,79 @@
+import { Message, MessageEmbed } from 'discord.js';
+import { BotCommand } from '../../lib/extensions/BotCommand';
+import { stripIndent } from 'common-tags';
+import { BotMessage } from '../../lib/extensions/BotMessage';
+
+export default class HelpCommand extends BotCommand {
+ constructor() {
+ super('help', {
+ aliases: ['help'],
+ description: {
+ content: 'Shows the commands of the bot',
+ usage: 'help',
+ examples: ['help']
+ },
+ clientPermissions: ['EMBED_LINKS'],
+ args: [
+ {
+ id: 'command',
+ type: 'commandAlias'
+ }
+ ]
+ });
+ }
+
+ public async exec(
+ message: BotMessage,
+ { command }: { command: BotCommand }
+ ): Promise<Message> {
+ const prefix = this.handler.prefix;
+ if (!command) {
+ const embed = new MessageEmbed()
+ .addField(
+ 'Commands',
+ stripIndent`A list of available commands.
+ For additional info on a command, type \`${prefix}help <command>\`
+ `
+ )
+ .setFooter(
+ `For more information about a command use "${this.client.config.prefix}help <command>"`
+ )
+ .setTimestamp();
+ for (const category of this.handler.categories.values()) {
+ embed.addField(
+ `${category.id.replace(/(\b\w)/gi, (lc): string =>
+ lc.toUpperCase()
+ )}`,
+ `${category
+ .filter((cmd): boolean => cmd.aliases.length > 0)
+ .map((cmd): string => `\`${cmd.aliases[0]}\``)
+ .join(' ')}`
+ );
+ }
+ return message.util.send(embed);
+ }
+
+ const embed = new MessageEmbed()
+ .setColor([155, 200, 200])
+ .setTitle(
+ `\`${command.description.usage ? command.description.usage : ''}\``
+ )
+ .addField(
+ 'Description',
+ `${command.description.content ? command.description.content : ''} ${
+ command.ownerOnly ? '\n__Owner Only__' : ''
+ }`
+ );
+
+ if (command.aliases.length > 1)
+ embed.addField('Aliases', `\`${command.aliases.join('` `')}\``, true);
+ if (command.description.examples && command.description.examples.length)
+ embed.addField(
+ 'Examples',
+ `\`${command.description.examples.join('`\n`')}\``,
+ true
+ );
+
+ return message.util.send(embed);
+ }
+}
diff --git a/src/commands/info/PingCommand.ts b/src/commands/info/PingCommand.ts
new file mode 100644
index 0000000..5a5b819
--- /dev/null
+++ b/src/commands/info/PingCommand.ts
@@ -0,0 +1,42 @@
+import { MessageEmbed } from 'discord.js';
+import { BotCommand } from '../../lib/extensions/BotCommand';
+import { BotMessage } from '../../lib/extensions/BotMessage';
+
+export default class PingCommand extends BotCommand {
+ constructor() {
+ super('ping', {
+ aliases: ['ping'],
+ description: {
+ content: 'Gets the latency of the bot',
+ usage: 'ping',
+ examples: ['ping']
+ }
+ });
+ }
+
+ public async exec(message: BotMessage): Promise<void> {
+ const sentMessage = await message.util.send('Pong!');
+ const timestamp: number = message.editedTimestamp
+ ? message.editedTimestamp
+ : message.createdTimestamp;
+ const botLatency = `\`\`\`\n ${Math.floor(
+ sentMessage.createdTimestamp - timestamp
+ )}ms \`\`\``;
+ const apiLatency = `\`\`\`\n ${Math.round(
+ message.client.ws.ping
+ )}ms \`\`\``;
+ const embed = new MessageEmbed()
+ .setTitle('Pong! πŸ“')
+ .addField('Bot Latency', botLatency, true)
+ .addField('API Latency', apiLatency, true)
+ .setFooter(
+ message.author.username,
+ message.author.displayAvatarURL({ dynamic: true })
+ )
+ .setTimestamp();
+ await sentMessage.edit({
+ content: null,
+ embed
+ });
+ }
+}
diff --git a/src/commands/moderation/BanCommand.ts b/src/commands/moderation/BanCommand.ts
new file mode 100644
index 0000000..300101b
--- /dev/null
+++ b/src/commands/moderation/BanCommand.ts
@@ -0,0 +1,137 @@
+import { User } from 'discord.js';
+import { BotCommand } from '../../lib/extensions/BotCommand';
+import { BotMessage } from '../../lib/extensions/BotMessage';
+import { Ban, Modlog, ModlogType } from '../../lib/types/Models';
+import moment from 'moment';
+
+const durationAliases: Record<string, string[]> = {
+ weeks: ['w', 'weeks', 'week', 'wk', 'wks'],
+ days: ['d', 'days', 'day'],
+ hours: ['h', 'hours', 'hour', 'hr', 'hrs'],
+ minutes: ['m', 'min', 'mins', 'minutes', 'minute'],
+ months: ['mo', 'month', 'months']
+};
+const durationRegex = /(?:(\d+)(d(?:ays?)?|h(?:ours?|rs?)?|m(?:inutes?|ins?)?|mo(?:nths?)?|w(?:eeks?|ks?)?)(?: |$))/g;
+
+export default class PrefixCommand extends BotCommand {
+ constructor() {
+ super('ban', {
+ aliases: ['ban'],
+ args: [
+ {
+ id: 'user',
+ type: 'user',
+ prompt: {
+ start: 'What user would you like to ban?',
+ retry: 'Invalid response. What user would you like to ban?'
+ }
+ },
+ {
+ id: 'reason'
+ },
+ {
+ id: 'time',
+ match: 'option',
+ flag: '--time'
+ }
+ ],
+ clientPermissions: ['BAN_MEMBERS'],
+ userPermissions: ['BAN_MEMBERS']
+ });
+ }
+ async exec(
+ message: BotMessage,
+ { user, reason, time }: { user: User; reason?: string; time?: string }
+ ): Promise<void> {
+ const duration = moment.duration();
+ let modlogEnry: Modlog;
+ let banEntry: Ban;
+ const translatedTime: string[] = [];
+ try {
+ try {
+ if (time) {
+ const parsed = [...time.matchAll(durationRegex)];
+ if (parsed.length < 1) {
+ await message.util.send('Invalid time.');
+ return;
+ }
+ for (const part of parsed) {
+ const translated = Object.keys(durationAliases).find((k) =>
+ durationAliases[k].includes(part[2])
+ );
+ translatedTime.push(part[1] + ' ' + translated);
+ duration.add(
+ Number(part[1]),
+ translated as 'weeks' | 'days' | 'hours' | 'months' | 'minutes'
+ );
+ }
+ modlogEnry = Modlog.build({
+ user: user.id,
+ guild: message.guild.id,
+ reason,
+ type: ModlogType.TEMPBAN,
+ duration: duration.asMilliseconds(),
+ moderator: message.author.id
+ });
+ banEntry = Ban.build({
+ user: user.id,
+ guild: message.guild.id,
+ reason,
+ expires: new Date(new Date().getTime() + duration.asMilliseconds()),
+ modlog: modlogEnry.id
+ });
+ } else {
+ modlogEnry = Modlog.build({
+ user: user.id,
+ guild: message.guild.id,
+ reason,
+ type: ModlogType.BAN,
+ moderator: message.author.id
+ });
+ banEntry = Ban.build({
+ user: user.id,
+ guild: message.guild.id,
+ reason,
+ modlog: modlogEnry.id
+ });
+ }
+ await modlogEnry.save();
+ await banEntry.save();
+ } catch (e) {
+ console.error(e);
+ await message.util.send(
+ 'Error saving to database. Please report this to a developer.'
+ );
+ return;
+ }
+ try {
+ await user.send(
+ `You were banned in ${message.guild.name} ${
+ translatedTime.length >= 1
+ ? `for ${translatedTime.join(', ')}`
+ : 'permanently'
+ } with reason \`${reason || 'No reason given'}\``
+ );
+ } catch (e) {
+ await message.channel.send('Error sending message to user');
+ }
+ await message.guild.members.ban(user, {
+ reason: `Banned by ${message.author.tag} with ${
+ reason ? `reason ${reason}` : 'no reason'
+ }`
+ });
+ await message.util.send(
+ `Banned <@!${user.id}> ${
+ translatedTime.length >= 1
+ ? `for ${translatedTime.join(', ')}`
+ : 'permanently'
+ } with reason \`${reason || 'No reason given'}\``
+ );
+ } catch {
+ await message.util.send('Error banning :/');
+ await modlogEnry.destroy();
+ await banEntry.destroy();
+ return;
+ }
+ }
+}
diff --git a/src/commands/moderation/KickCommand.ts b/src/commands/moderation/KickCommand.ts
new file mode 100644
index 0000000..0dc4276
--- /dev/null
+++ b/src/commands/moderation/KickCommand.ts
@@ -0,0 +1,72 @@
+import { BotCommand } from '../../lib/extensions/BotCommand';
+import { BotMessage } from '../../lib/extensions/BotMessage';
+import { Modlog, ModlogType } from '../../lib/types/Models';
+import { GuildMember } from 'discord.js';
+
+export default class PrefixCommand extends BotCommand {
+ constructor() {
+ super('kick', {
+ aliases: ['kick'],
+ args: [
+ {
+ id: 'user',
+ type: 'member',
+ prompt: {
+ start: 'What user would you like to kick?',
+ retry: 'Invalid response. What user would you like to kick?'
+ }
+ },
+ {
+ id: 'reason'
+ }
+ ],
+ clientPermissions: ['KICK_MEMBERS'],
+ userPermissions: ['KICK_MEMBERS']
+ });
+ }
+ async exec(
+ message: BotMessage,
+ { user, reason }: { user: GuildMember; reason?: string }
+ ): Promise<void> {
+ let modlogEnry: Modlog;
+ try {
+ modlogEnry = Modlog.build({
+ user: user.id,
+ guild: message.guild.id,
+ moderator: message.author.id,
+ type: ModlogType.KICK,
+ reason
+ });
+ await modlogEnry.save();
+ } catch (e) {
+ console.error(e);
+ await message.util.send(
+ 'Error saving to database. Please report this to a developer.'
+ );
+ return;
+ }
+ try {
+ await user.send(
+ `You were kicked in ${message.guild.name} with reason \`${
+ reason || 'No reason given'
+ }\``
+ );
+ } catch (e) {
+ await message.channel.send('Error sending message to user');
+ }
+ try {
+ await user.kick(
+ `Kicked by ${message.author.tag} with ${
+ reason ? `reason ${reason}` : 'no reason'
+ }`
+ );
+ } catch {
+ await message.util.send('Error kicking :/');
+ await modlogEnry.destroy();
+ return;
+ }
+ await message.util.send(
+ `Kicked <@!${user.id}> with reason \`${reason || 'No reason given'}\``
+ );
+ }
+}
diff --git a/src/commands/moderation/ModlogCommand.ts b/src/commands/moderation/ModlogCommand.ts
new file mode 100644
index 0000000..ea35198
--- /dev/null
+++ b/src/commands/moderation/ModlogCommand.ts
@@ -0,0 +1,143 @@
+import { BotCommand } from '../../lib/extensions/BotCommand';
+import { Message } from 'discord.js';
+import { Modlog } from '../../lib/types/Models';
+import { MessageEmbed } from 'discord.js';
+import moment from 'moment';
+import { stripIndent } from 'common-tags';
+import { Argument } from 'discord-akairo';
+
+export default class ModlogCommand extends BotCommand {
+ constructor() {
+ super('modlog', {
+ aliases: ['modlog', 'modlogs'],
+ args: [
+ {
+ id: 'search',
+ prompt: {
+ start: 'What modlog id or user would you like to see?'
+ }
+ },
+ {
+ id: 'page',
+ type: 'number'
+ }
+ ],
+ userPermissions: ['MANAGE_MESSAGES']
+ });
+ }
+ *args(): unknown {
+ const search = yield {
+ id: 'search',
+ type: Argument.union('user', 'string'),
+ prompt: {
+ start: 'What modlog id or user would you like to see?'
+ }
+ };
+ if (typeof search === 'string') return { search, page: null };
+ else {
+ const page = yield {
+ id: 'page',
+ type: 'number',
+ prompt: {
+ start: 'What page?',
+ retry: 'Not a number. What page?',
+ optional: true
+ }
+ };
+ return { search, page };
+ }
+ }
+ async exec(
+ message: Message,
+ { search, page }: { search: string; page: number }
+ ): Promise<void> {
+ const foundUser = await this.client.util.resolveUserAsync(search);
+ if (foundUser) {
+ const logs = await Modlog.findAll({
+ where: {
+ guild: message.guild.id,
+ user: foundUser.id
+ },
+ order: [['createdAt', 'ASC']]
+ });
+ const niceLogs: string[] = [];
+ for (const log of logs) {
+ niceLogs.push(stripIndent`
+ ID: ${log.id}
+ Type: ${log.type.toLowerCase()}
+ User: <@!${log.user}> (${log.user})
+ Moderator: <@!${log.moderator}> (${log.moderator})
+ Duration: ${
+ log.duration
+ ? moment.duration(log.duration, 'milliseconds').humanize()
+ : 'N/A'
+ }
+ Reason: ${log.reason || 'None given'}
+ ${this.client.util.ordinal(logs.indexOf(log) + 1)} action
+ `);
+ }
+ const chunked: string[][] = this.client.util.chunk(niceLogs, 3);
+ const embedPages = chunked.map(
+ (e, i) =>
+ new MessageEmbed({
+ title: `Modlogs page ${i + 1}`,
+ description: e.join(
+ '\n-------------------------------------------------------\n'
+ ),
+ footer: {
+ text: `Page ${i + 1}/${chunked.length}`
+ }
+ })
+ );
+ if (page) {
+ await message.util.send(embedPages[page - 1]);
+ return;
+ } else {
+ await message.util.send(embedPages[0]);
+ return;
+ }
+ } else if (search) {
+ const entry = await Modlog.findByPk(search);
+ if (!entry) {
+ await message.util.send('That modlog does not exist.');
+ return;
+ }
+ await message.util.send(
+ new MessageEmbed({
+ title: `Modlog ${entry.id}`,
+ fields: [
+ {
+ name: 'Type',
+ value: entry.type.toLowerCase(),
+ inline: true
+ },
+ {
+ name: 'Duration',
+ value: `${
+ entry.duration
+ ? moment.duration(entry.duration, 'milliseconds').humanize()
+ : 'N/A'
+ }`,
+ inline: true
+ },
+ {
+ name: 'Reason',
+ value: `${entry.reason || 'None given'}`,
+ inline: true
+ },
+ {
+ name: 'Moderator',
+ value: `<@!${entry.moderator}> (${entry.moderator})`,
+ inline: true
+ },
+ {
+ name: 'User',
+ value: `<@!${entry.user}> (${entry.user})`,
+ inline: true
+ }
+ ]
+ })
+ );
+ }
+ }
+}
diff --git a/src/commands/moderation/WarnCommand.ts b/src/commands/moderation/WarnCommand.ts
new file mode 100644
index 0000000..676615d
--- /dev/null
+++ b/src/commands/moderation/WarnCommand.ts
@@ -0,0 +1,54 @@
+import { GuildMember } from 'discord.js';
+import { BotCommand } from '../../lib/extensions/BotCommand';
+import { BotMessage } from '../../lib/extensions/BotMessage';
+import { Modlog, ModlogType } from '../../lib/types/Models';
+
+export default class WarnCommand extends BotCommand {
+ public constructor() {
+ super('warn', {
+ aliases: ['warn'],
+ userPermissions: ['MANAGE_MESSAGES'],
+ args: [
+ {
+ id: 'member',
+ type: 'member'
+ },
+ {
+ id: 'reason',
+ match: 'rest'
+ }
+ ]
+ });
+ }
+ public async exec(
+ message: BotMessage,
+ { member, reason }: { member: GuildMember; reason: string }
+ ): Promise<void> {
+ try {
+ const entry = Modlog.build({
+ user: member.id,
+ guild: message.guild.id,
+ moderator: message.author.id,
+ type: ModlogType.WARN,
+ reason
+ });
+ await entry.save();
+ } catch (e) {
+ await message.util.send(
+ 'Error saving to database, please contact the developers'
+ );
+ return;
+ }
+ try {
+ await member.send(
+ `You were warned in ${message.guild.name} for reason "${reason}".`
+ );
+ } catch (e) {
+ await message.util.send('Error messaging user, warning still saved.');
+ return;
+ }
+ await message.util.send(
+ `${member.user.tag} was warned for reason "${reason}".`
+ );
+ }
+}
diff --git a/src/commands/owner/EvalCommand.ts b/src/commands/owner/EvalCommand.ts
new file mode 100644
index 0000000..f1ada89
--- /dev/null
+++ b/src/commands/owner/EvalCommand.ts
@@ -0,0 +1,139 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
+import { BotCommand } from '../../lib/extensions/BotCommand';
+import { MessageEmbed, Message } from 'discord.js';
+import { inspect, promisify } from 'util';
+import { exec } from 'child_process';
+import { BotMessage } from '../../lib/extensions/BotMessage';
+
+const clean = (text) => {
+ if (typeof text === 'string')
+ return text
+ .replace(/`/g, '`' + String.fromCharCode(8203))
+ .replace(/@/g, '@' + String.fromCharCode(8203));
+ else return text;
+};
+
+export default class EvalCommand extends BotCommand {
+ public constructor() {
+ super('eval', {
+ aliases: ['eval', 'ev'],
+ category: 'dev',
+ description: {
+ content: 'Use the command to eval stuff in the bot.',
+ usage: 'eval [--depth #] <code> [--sudo] [--silent] [--delete]',
+ examples: ['eval message.guild.name', 'eval this.client.ownerID']
+ },
+ args: [
+ {
+ id: 'depth',
+ match: 'option',
+ type: 'number',
+ flag: '--depth',
+ default: 0
+ },
+ {
+ id: 'silent',
+ match: 'flag',
+ flag: '--silent'
+ },
+ {
+ id: 'code',
+ match: 'rest',
+ type: 'string',
+ prompt: {
+ start: 'What would you like to eval?',
+ retry: 'Invalid code to eval. What would you like to eval?'
+ }
+ }
+ ],
+ ownerOnly: true,
+ clientPermissions: ['EMBED_LINKS']
+ });
+ }
+
+ public async exec(
+ message: BotMessage,
+ { depth, code, silent }: { depth: number; code: string; silent: boolean }
+ ): Promise<void> {
+ const embed: MessageEmbed = new MessageEmbed();
+
+ try {
+ let output;
+ const me = message.member,
+ member = message.member,
+ bot = this.client,
+ guild = message.guild,
+ channel = message.channel,
+ config = this.client.config,
+ sh = promisify(exec),
+ models = this.client.db.models,
+ got = await import('got');
+ output = eval(code);
+ output = await output;
+ if (typeof output !== 'string') output = inspect(output, { depth });
+ output = output.replace(
+ new RegExp(this.client.token, 'g'),
+ '[token omitted]'
+ );
+ output = clean(output);
+ embed
+ .setTitle('βœ… Evaled code successfully')
+ .addField(
+ 'πŸ“₯ Input',
+ code.length > 1012
+ ? 'Too large to display. Hastebin: ' +
+ (await this.client.util.haste(code))
+ : '```js\n' + code + '```'
+ )
+ .addField(
+ 'πŸ“€ Output',
+ output.length > 1012
+ ? 'Too large to display. Hastebin: ' +
+ (await this.client.util.haste(output))
+ : '```js\n' + output + '```'
+ )
+ .setColor('#66FF00')
+ .setFooter(
+ message.author.username,
+ message.author.displayAvatarURL({ dynamic: true })
+ )
+ .setTimestamp();
+ } catch (e) {
+ embed
+ .setTitle('❌ Code was not able to be evaled')
+ .addField(
+ 'πŸ“₯ Input',
+ code.length > 1012
+ ? 'Too large to display. Hastebin: ' +
+ (await this.client.util.haste(code))
+ : '```js\n' + code + '```'
+ )
+ .addField(
+ 'πŸ“€ Output',
+ e.length > 1012
+ ? 'Too large to display. Hastebin: ' +
+ (await this.client.util.haste(e))
+ : '```js\n' +
+ e +
+ '```Full stack:' +
+ (await this.client.util.haste(e.stack))
+ )
+ .setColor('#FF0000')
+ .setFooter(
+ message.author.username,
+ message.author.displayAvatarURL({ dynamic: true })
+ )
+ .setTimestamp();
+ }
+ if (!silent) {
+ await message.util.send(embed);
+ } else {
+ try {
+ await message.author.send(embed);
+ await message.react('<a:Check_Mark:790373952760971294>');
+ } catch (e) {
+ await message.react('❌');
+ }
+ }
+ }
+}
diff --git a/src/commands/owner/ReloadCommand.ts b/src/commands/owner/ReloadCommand.ts
new file mode 100644
index 0000000..2311424
--- /dev/null
+++ b/src/commands/owner/ReloadCommand.ts
@@ -0,0 +1,34 @@
+import { BotCommand } from '../../lib/extensions/BotCommand';
+import { stripIndent } from 'common-tags';
+import { BotMessage } from '../../lib/extensions/BotMessage';
+
+export default class ReloadCommand extends BotCommand {
+ constructor() {
+ super('reload', {
+ aliases: ['reload'],
+ description: {
+ content: 'Reloads the bot',
+ usage: 'reload',
+ examples: ['reload']
+ },
+ ownerOnly: true,
+ typing: true
+ });
+ }
+
+ public async exec(message: BotMessage): Promise<void> {
+ try {
+ await this.client.util.shell('yarn rimraf dist/');
+ await this.client.util.shell('yarn tsc');
+ this.client.commandHandler.reloadAll();
+ this.client.listenerHandler.reloadAll();
+ this.client.inhibitorHandler.reloadAll();
+ await message.util.send('πŸ” Successfully reloaded!');
+ } catch (e) {
+ await message.util.send(stripIndent`
+ An error occured while reloading:
+ ${await this.client.util.haste(e.stack)}
+ `);
+ }
+ }
+}
diff --git a/src/config/example-options.ts b/src/config/example-options.ts
new file mode 100644
index 0000000..ce204e9
--- /dev/null
+++ b/src/config/example-options.ts
@@ -0,0 +1,30 @@
+// Credentials
+export const credentials = {
+ botToken: 'token here',
+ dblToken: 'token here',
+ dblWebhookAuth: 'auth here'
+};
+
+// Options
+export const owners = [
+ '487443883127472129', // Tyman#7318
+ '642416218967375882' // πŸ’œClari#7744
+];
+export const prefix = 'u2!' as string;
+export const dev = true as boolean;
+export const channels = {
+ dblVote: 'id here',
+ log: 'id here',
+ error: 'id here',
+ dm: 'id here',
+ command: 'id here'
+};
+export const topGGPort = 3849;
+
+// Database specific
+export const db = {
+ host: 'localhost',
+ port: 5432,
+ username: 'username here',
+ password: 'password here'
+};
diff --git a/src/inhibitors/blacklist/BlacklistInhibitor.ts b/src/inhibitors/blacklist/BlacklistInhibitor.ts
new file mode 100644
index 0000000..82db4c2
--- /dev/null
+++ b/src/inhibitors/blacklist/BlacklistInhibitor.ts
@@ -0,0 +1,14 @@
+import { BotInhibitor } from '../../lib/extensions/BotInhibitor';
+
+export default class BlacklistInhibitor extends BotInhibitor {
+ constructor() {
+ super('blacklist', {
+ reason: 'blacklist'
+ });
+ }
+
+ public exec(): boolean | Promise<boolean> {
+ // This is just a placeholder for now
+ return false;
+ }
+}
diff --git a/src/lib/extensions/BotClient.ts b/src/lib/extensions/BotClient.ts
new file mode 100644
index 0000000..4d1c31a
--- /dev/null
+++ b/src/lib/extensions/BotClient.ts
@@ -0,0 +1,274 @@
+import {
+ AkairoClient,
+ CommandHandler,
+ InhibitorHandler,
+ ListenerHandler
+} from 'discord-akairo';
+import { Guild } from 'discord.js';
+import * as path from 'path';
+import { DataTypes, Model, Sequelize } from 'sequelize';
+import * as Models from '../types/Models';
+import { BotGuild } from './BotGuild';
+import { BotMessage } from './BotMessage';
+import { Util } from './Util';
+import * as Tasks from '../../tasks';
+import { v4 as uuidv4 } from 'uuid';
+import { exit } from 'process';
+import { TopGGHandler } from '../utils/TopGG';
+
+export interface BotConfig {
+ credentials: {
+ botToken: string;
+ dblToken: string;
+ dblWebhookAuth: string;
+ };
+ owners: string[];
+ prefix: string;
+ dev: boolean;
+ db: {
+ username: string;
+ password: string;
+ host: string;
+ port: number;
+ };
+ topGGPort: number;
+ channels: {
+ dblVote: string;
+ log: string;
+ error: string;
+ dm: string;
+ command: string;
+ };
+}
+
+export class BotClient extends AkairoClient {
+ public config: BotConfig;
+ public listenerHandler: ListenerHandler;
+ public inhibitorHandler: InhibitorHandler;
+ public commandHandler: CommandHandler;
+ public topGGHandler: TopGGHandler;
+ public util: Util;
+ public ownerID: string[];
+ public db: Sequelize;
+ constructor(config: BotConfig) {
+ super(
+ {
+ ownerID: config.owners
+ },
+ {
+ allowedMentions: { parse: ['users'] } // No everyone or role mentions by default
+ }
+ );
+
+ // Set token
+ this.token = config.credentials.botToken;
+
+ // Set config
+ this.config = config;
+
+ // Create listener handler
+ this.listenerHandler = new ListenerHandler(this, {
+ directory: path.join(__dirname, '..', '..', 'listeners'),
+ automateCategories: true
+ });
+
+ // Create inhibitor handler
+ this.inhibitorHandler = new InhibitorHandler(this, {
+ directory: path.join(__dirname, '..', '..', 'inhibitors'),
+ automateCategories: true
+ });
+
+ // Create command handler
+ this.commandHandler = new CommandHandler(this, {
+ directory: path.join(__dirname, '..', '..', 'commands'),
+ prefix: async ({ guild }: { guild: Guild }) => {
+ const row = await Models.Guild.findByPk(guild.id);
+ if (!row) return this.config.prefix;
+ return row.prefix as string;
+ },
+ allowMention: true,
+ handleEdits: true,
+ commandUtil: true,
+ commandUtilLifetime: 3e5,
+ argumentDefaults: {
+ prompt: {
+ timeout: 'Timed out.',
+ ended: 'Too many tries.',
+ cancel: 'Canceled.',
+ time: 3e4
+ }
+ },
+ ignorePermissions: this.config.owners,
+ ignoreCooldown: this.config.owners,
+ automateCategories: true
+ });
+
+ this.util = new Util(this);
+ this.db = new Sequelize(
+ this.config.dev ? 'utilibot-dev' : 'utilibot',
+ this.config.db.username,
+ this.config.db.password,
+ {
+ dialect: 'postgres',
+ host: this.config.db.host,
+ port: this.config.db.port,
+ logging: false
+ }
+ );
+ this.topGGHandler = new TopGGHandler(this);
+ BotGuild.install();
+ BotMessage.install();
+ }
+
+ // Initialize everything
+ private async _init(): Promise<void> {
+ this.commandHandler.useListenerHandler(this.listenerHandler);
+ this.commandHandler.useInhibitorHandler(this.inhibitorHandler);
+ this.listenerHandler.setEmitters({
+ commandHandler: this.commandHandler,
+ listenerHandler: this.listenerHandler,
+ process
+ });
+ // loads all the handlers
+ const loaders = {
+ commands: this.commandHandler,
+ listeners: this.listenerHandler,
+ inhibitors: this.inhibitorHandler
+ };
+ for (const loader of Object.keys(loaders)) {
+ try {
+ loaders[loader].loadAll();
+ console.log('Successfully loaded ' + loader + '.');
+ } catch (e) {
+ console.error('Unable to load loader ' + loader + ' with error ' + e);
+ }
+ }
+ await this.dbPreInit();
+ Object.keys(Tasks).forEach((t) => {
+ setInterval(() => Tasks[t](this), 60000);
+ });
+ this.topGGHandler.init();
+ }
+
+ public async dbPreInit(): Promise<void> {
+ await this.db.authenticate();
+ Models.Guild.init(
+ {
+ id: {
+ type: DataTypes.STRING,
+ primaryKey: true
+ },
+ prefix: {
+ type: DataTypes.STRING,
+ allowNull: false,
+ defaultValue: this.config.prefix
+ }
+ },
+ { sequelize: this.db }
+ );
+ Models.Modlog.init(
+ {
+ id: {
+ type: DataTypes.STRING,
+ primaryKey: true,
+ allowNull: false,
+ defaultValue: uuidv4
+ },
+ type: {
+ type: new DataTypes.ENUM(
+ 'BAN',
+ 'TEMPBAN',
+ 'MUTE',
+ 'TEMPMUTE',
+ 'KICK',
+ 'WARN'
+ ),
+ allowNull: false
+ },
+ user: {
+ type: DataTypes.STRING,
+ allowNull: false
+ },
+ moderator: {
+ type: DataTypes.STRING,
+ allowNull: false
+ },
+ duration: {
+ type: DataTypes.STRING,
+ allowNull: true
+ },
+ reason: {
+ type: DataTypes.STRING,
+ allowNull: true
+ },
+ guild: {
+ type: DataTypes.STRING,
+ references: {
+ model: Models.Guild as typeof Model
+ }
+ }
+ },
+ { sequelize: this.db }
+ );
+ Models.Ban.init(
+ {
+ id: {
+ type: DataTypes.STRING,
+ primaryKey: true,
+ allowNull: false,
+ defaultValue: uuidv4
+ },
+ user: {
+ type: DataTypes.STRING,
+ allowNull: false
+ },
+ guild: {
+ type: DataTypes.STRING,
+ allowNull: false,
+ references: {
+ model: Models.Guild as typeof Model,
+ key: 'id'
+ }
+ },
+ expires: {
+ type: DataTypes.DATE,
+ allowNull: true
+ },
+ reason: {
+ type: DataTypes.STRING,
+ allowNull: true
+ },
+ modlog: {
+ type: DataTypes.STRING,
+ allowNull: false,
+ references: {
+ model: Models.Modlog as typeof Model
+ }
+ }
+ },
+ { sequelize: this.db }
+ );
+ try {
+ await this.db.sync({ alter: true }); // Sync all tables to fix everything if updated
+ } catch {
+ // Ignore error
+ }
+ }
+
+ public async start(): Promise<void> {
+ try {
+ await this._init();
+ await this.login(this.token);
+ } catch (e) {
+ console.error(e.stack);
+ exit(2);
+ }
+ }
+
+ public destroy(relogin = true): void | Promise<string> {
+ super.destroy();
+ if (relogin) {
+ return this.login(this.token);
+ }
+ }
+}
diff --git a/src/lib/extensions/BotCommand.ts b/src/lib/extensions/BotCommand.ts
new file mode 100644
index 0000000..4f62714
--- /dev/null
+++ b/src/lib/extensions/BotCommand.ts
@@ -0,0 +1,6 @@
+import { Command } from 'discord-akairo';
+import { BotClient } from './BotClient';
+
+export class BotCommand extends Command {
+ public client: BotClient;
+}
diff --git a/src/lib/extensions/BotGuild.ts b/src/lib/extensions/BotGuild.ts
new file mode 100644
index 0000000..22d7834
--- /dev/null
+++ b/src/lib/extensions/BotGuild.ts
@@ -0,0 +1,38 @@
+import { Guild, Structures } from 'discord.js';
+import { BotClient } from './BotClient';
+import { Guild as GuildModel } from '../types/Models';
+
+export class GuildSettings {
+ private guild: BotGuild;
+ constructor(guild: BotGuild) {
+ this.guild = guild;
+ }
+ public async getPrefix(): Promise<string> {
+ return await GuildModel.findByPk(this.guild.id).then(
+ (gm) => gm?.prefix || this.guild.client.config.prefix
+ );
+ }
+ public async setPrefix(value: string): Promise<void> {
+ let entry = await GuildModel.findByPk(this.guild.id);
+ if (!entry) {
+ entry = GuildModel.build({
+ id: this.guild.id,
+ prefix: value
+ });
+ } else {
+ entry.prefix = value;
+ }
+ await entry.save();
+ }
+}
+
+export class BotGuild extends Guild {
+ constructor(client: BotClient, data: Record<string, unknown>) {
+ super(client, data);
+ }
+ static install(): void {
+ Structures.extend('Guild', () => BotGuild);
+ }
+ public settings = new GuildSettings(this);
+ public client: BotClient;
+}
diff --git a/src/lib/extensions/BotInhibitor.ts b/src/lib/extensions/BotInhibitor.ts
new file mode 100644
index 0000000..960aade
--- /dev/null
+++ b/src/lib/extensions/BotInhibitor.ts
@@ -0,0 +1,6 @@
+import { Inhibitor } from 'discord-akairo';
+import { BotClient } from './BotClient';
+
+export class BotInhibitor extends Inhibitor {
+ public client: BotClient;
+}
diff --git a/src/lib/extensions/BotListener.ts b/src/lib/extensions/BotListener.ts
new file mode 100644
index 0000000..9ec17b2
--- /dev/null
+++ b/src/lib/extensions/BotListener.ts
@@ -0,0 +1,6 @@
+import { Listener } from 'discord-akairo';
+import { BotClient } from './BotClient';
+
+export class BotListener extends Listener {
+ public client: BotClient;
+}
diff --git a/src/lib/extensions/BotMessage.ts b/src/lib/extensions/BotMessage.ts
new file mode 100644
index 0000000..85c2721
--- /dev/null
+++ b/src/lib/extensions/BotMessage.ts
@@ -0,0 +1,50 @@
+import {
+ TextChannel,
+ NewsChannel,
+ DMChannel,
+ Message,
+ Structures
+} from 'discord.js';
+import { BotClient } from './BotClient';
+import { Guild as GuildModel } from '../types/Models';
+import { BotGuild } from './BotGuild';
+
+export class GuildSettings {
+ private message: BotMessage;
+ constructor(message: BotMessage) {
+ this.message = message;
+ }
+ public async getPrefix(): Promise<string> {
+ return await GuildModel.findByPk(this.message.guild.id).then(
+ (gm) => gm?.prefix || this.message.client.config.prefix
+ );
+ }
+ public async setPrefix(value: string): Promise<void> {
+ let entry = await GuildModel.findByPk(this.message.guild.id);
+ if (!entry) {
+ entry = GuildModel.build({
+ id: this.message.guild.id,
+ prefix: value
+ });
+ } else {
+ entry.prefix = value;
+ }
+ await entry.save();
+ }
+}
+
+export class BotMessage extends Message {
+ constructor(
+ client: BotClient,
+ data: Record<string, unknown>,
+ channel: TextChannel | DMChannel | NewsChannel
+ ) {
+ super(client, data, channel);
+ }
+ public guild: BotGuild;
+ public client: BotClient;
+ static install(): void {
+ Structures.extend('Message', () => BotMessage);
+ }
+ public settings = new GuildSettings(this);
+}
diff --git a/src/lib/extensions/Util.ts b/src/lib/extensions/Util.ts
new file mode 100644
index 0000000..20bfd48
--- /dev/null
+++ b/src/lib/extensions/Util.ts
@@ -0,0 +1,196 @@
+import { ClientUtil } from 'discord-akairo';
+import { BotClient } from './BotClient';
+import { User } from 'discord.js';
+import { promisify } from 'util';
+import { exec } from 'child_process';
+import got from 'got';
+import { TextChannel } from 'discord.js';
+
+interface hastebinRes {
+ key: string;
+}
+
+export class Util extends ClientUtil {
+ /**
+ * The client of this ClientUtil
+ * @type {BotClient}
+ */
+ public client: BotClient;
+ /**
+ * The hastebin urls used to post to hastebin, attempts to post in order
+ * @type {string[]}
+ */
+ public hasteURLs = [
+ 'https://hst.sh',
+ 'https://hasteb.in',
+ 'https://hastebin.com',
+ 'https://mystb.in',
+ 'https://haste.clicksminuteper.net',
+ 'https://paste.pythondiscord.com',
+ 'https://haste.unbelievaboat.com',
+ 'https://haste.tyman.tech'
+ ];
+ /**
+ * A simple promise exec method
+ */
+ private exec = promisify(exec);
+
+ /**
+ * Creates this client util
+ * @param client The client to initialize with
+ */
+ constructor(client: BotClient) {
+ super(client);
+ }
+
+ /**
+ * Maps an array of user ids to user objects.
+ * @param ids The list of IDs to map
+ * @returns The list of users mapped
+ */
+ public async mapIDs(ids: string[]): Promise<User[]> {
+ return await Promise.all(ids.map((id) => this.client.users.fetch(id)));
+ }
+
+ /**
+ * Capitalizes the first letter of the given text
+ * @param text The text to capitalize
+ * @returns The capitalized text
+ */
+ public capitalize(text: string): string {
+ return text.charAt(0).toUpperCase() + text.slice(1);
+ }
+
+ /**
+ * Runs a shell command and gives the output
+ * @param command The shell command to run
+ * @returns The stdout and stderr of the shell command
+ */
+ public async shell(
+ command: string
+ ): Promise<{
+ stdout: string;
+ stderr: string;
+ }> {
+ return await this.exec(command);
+ }
+
+ /**
+ * Posts text to hastebin
+ * @param content The text to post
+ * @returns The url of the posted text
+ */
+ public async haste(content: string): Promise<string> {
+ for (const url of this.hasteURLs) {
+ try {
+ const res: hastebinRes = await got
+ .post(`${url}/documents`, { body: content })
+ .json();
+ return `${url}/${res.key}`;
+ } catch (e) {
+ // pass
+ }
+ }
+ throw new Error('No urls worked. (wtf)');
+ }
+
+ /**
+ * Logs something but only in dev mode
+ * @param content The thing to log
+ */
+ public devLog(content: unknown): void {
+ if (this.client.config.dev) console.log(content);
+ }
+
+ /**
+ * Resolves a user-provided string into a user object, if possible
+ * @param text The text to try and resolve
+ * @returns The user resolved or null
+ */
+ public async resolveUserAsync(text: string): Promise<User | null> {
+ const idReg = /\d{17,19}/;
+ const idMatch = text.match(idReg);
+ if (idMatch) {
+ try {
+ const user = await this.client.users.fetch(text);
+ return user;
+ } catch {
+ // pass
+ }
+ }
+ const mentionReg = /<@!?(?<id>\d{17,19})>/;
+ const mentionMatch = text.match(mentionReg);
+ if (mentionMatch) {
+ try {
+ const user = await this.client.users.fetch(mentionMatch.groups.id);
+ return user;
+ } catch {
+ // pass
+ }
+ }
+ const user = this.client.users.cache.find((u) => u.username === text);
+ if (user) return user;
+ return null;
+ }
+
+ /**
+ * Appends the correct ordinal to the given number
+ * @param n The number to append an ordinal to
+ * @returns The number with the ordinal
+ */
+ public ordinal(n: number): string {
+ const s = ['th', 'st', 'nd', 'rd'],
+ v = n % 100;
+ return n + (s[(v - 20) % 10] || s[v] || s[0]);
+ }
+
+ /**
+ * Chunks an array to the specified size
+ * @param arr The array to chunk
+ * @param perChunk The amount of items per chunk
+ * @returns The chunked array
+ */
+ public chunk<T>(arr: T[], perChunk: number): T[][] {
+ return arr.reduce((all, one, i) => {
+ const ch = Math.floor(i / perChunk);
+ all[ch] = [].concat(all[ch] || [], one);
+ return all;
+ }, []);
+ }
+
+ /**
+ * Logs a message to console and log channel as info
+ * @param message The message to send
+ */
+ public async info(message: string): Promise<void> {
+ console.log(`INFO: ${message}`);
+ const channel = (await this.client.channels.fetch(
+ this.client.config.channels.log
+ )) as TextChannel;
+ await channel.send(`INFO: ${message}`);
+ }
+
+ /**
+ * Logs a message to console and log channel as a warning
+ * @param message The message to send
+ */
+ public async warn(message: string): Promise<void> {
+ console.warn(`WARN: ${message}`);
+ const channel = (await this.client.channels.fetch(
+ this.client.config.channels.log
+ )) as TextChannel;
+ await channel.send(`WARN: ${message}`);
+ }
+
+ /**
+ * Logs a message to console and log channel as an error
+ * @param message The message to send
+ */
+ public async error(message: string): Promise<void> {
+ console.error(`ERROR: ${message}`);
+ const channel = (await this.client.channels.fetch(
+ this.client.config.channels.error
+ )) as TextChannel;
+ await channel.send(`ERROR: ${message}`);
+ }
+}
diff --git a/src/lib/types/BaseModel.ts b/src/lib/types/BaseModel.ts
new file mode 100644
index 0000000..fdbd706
--- /dev/null
+++ b/src/lib/types/BaseModel.ts
@@ -0,0 +1,6 @@
+import { Model } from 'sequelize';
+
+export abstract class BaseModel<A, B> extends Model<A, B> {
+ public readonly createdAt: Date;
+ public readonly updatedAt: Date;
+}
diff --git a/src/lib/types/Models.ts b/src/lib/types/Models.ts
new file mode 100644
index 0000000..6ea890e
--- /dev/null
+++ b/src/lib/types/Models.ts
@@ -0,0 +1,102 @@
+import { Optional } from 'sequelize';
+import { BaseModel } from './BaseModel';
+
+export interface GuildModel {
+ id: string;
+ prefix: string;
+}
+export type GuildModelCreationAttributes = Optional<GuildModel, 'prefix'>;
+
+export class Guild
+ extends BaseModel<GuildModel, GuildModelCreationAttributes>
+ implements GuildModel {
+ id: string;
+ prefix: string;
+}
+
+export interface BanModel {
+ id: string;
+ user: string;
+ guild: string;
+ reason: string;
+ expires: Date;
+ modlog: string;
+}
+export interface BanModelCreationAttributes {
+ id?: string;
+ user: string;
+ guild: string;
+ reason?: string;
+ expires?: Date;
+ modlog: string;
+}
+
+export class Ban
+ extends BaseModel<BanModel, BanModelCreationAttributes>
+ implements BanModel {
+ /**
+ * The ID of this ban (no real use just for a primary key)
+ */
+ id: string;
+ /**
+ * The user who is banned
+ */
+ user: string;
+ /**
+ * The guild they are banned from
+ */
+ guild: string;
+ /**
+ * The reason they are banned (optional)
+ */
+ reason: string | null;
+ /**
+ * The date at which this ban expires and should be unbanned (optional)
+ */
+ expires: Date | null;
+ /**
+ * The ref to the modlog entry
+ */
+ modlog: string;
+}
+
+export enum ModlogType {
+ BAN = 'BAN',
+ TEMPBAN = 'TEMPBAN',
+ KICK = 'KICK',
+ MUTE = 'MUTE',
+ TEMPMUTE = 'TEMPMUTE',
+ WARN = 'WARN'
+}
+
+export interface ModlogModel {
+ id: string;
+ type: ModlogType;
+ user: string;
+ moderator: string;
+ reason: string;
+ duration: number;
+ guild: string;
+}
+
+export interface ModlogModelCreationAttributes {
+ id?: string;
+ type: ModlogType;
+ user: string;
+ moderator: string;
+ reason?: string;
+ duration?: number;
+ guild: string;
+}
+
+export class Modlog
+ extends BaseModel<ModlogModel, ModlogModelCreationAttributes>
+ implements ModlogModel {
+ id: string;
+ type: ModlogType;
+ user: string;
+ moderator: string;
+ guild: string;
+ reason: string | null;
+ duration: number | null;
+}
diff --git a/src/lib/utils/TopGG.ts b/src/lib/utils/TopGG.ts
new file mode 100644
index 0000000..9c06816
--- /dev/null
+++ b/src/lib/utils/TopGG.ts
@@ -0,0 +1,110 @@
+import { Api } from '@top-gg/sdk';
+import { BotStats, WebhookPayload } from '@top-gg/sdk/dist/typings';
+import { BotClient } from '../extensions/BotClient';
+import { topGGPort, credentials, channels } from '../../config/options';
+import express, { Express } from 'express';
+import { TextChannel, MessageEmbed, WebhookClient } from 'discord.js';
+import { stripIndent } from 'common-tags';
+import {
+ json as bodyParserJSON,
+ urlencoded as bodyParserUrlEncoded
+} from 'body-parser';
+
+export class TopGGHandler {
+ public api = new Api(credentials.dblToken);
+ public client: BotClient;
+ public server: Express = express();
+ public constructor(client: BotClient) {
+ this.client = client;
+ }
+ public init(): void {
+ setInterval(this.postGuilds.bind(this), 60000);
+ this.server.use(bodyParserJSON());
+ this.server.use(bodyParserUrlEncoded({ extended: true }));
+ this.server.post('/dblwebhook', async (req, res) => {
+ if (req.headers.authorization !== credentials.dblWebhookAuth) {
+ res.status(403).send('Unauthorized');
+ await this.client.util.warn(
+ `Unauthorized DBL webhook request πŸ‘€ ${await this.client.util.haste(
+ JSON.stringify(
+ {
+ 'Correct Auth': credentials.dblWebhookAuth,
+ 'Given Auth': req.headers.authorization,
+ 'Headers': req.headers,
+ 'Body': req.body
+ },
+ null,
+ '\t'
+ )
+ )}`
+ );
+ return;
+ } else {
+ res.status(200).send('OK');
+ }
+ const data = req.body as WebhookPayload;
+ await this.postVoteWebhook(data);
+ });
+ this.server.listen(topGGPort, () => {
+ console.log(`Started express top.gg server at port ${topGGPort}`);
+ });
+ }
+ public async postGuilds(): Promise<BotStats> {
+ if (this.client.config.dev) return;
+ return await this.api.postStats({
+ serverCount: this.client.guilds.cache.size,
+ shardCount: this.client.shard ? this.client.shard.count : 1
+ });
+ }
+ public async postVoteWebhook(data: WebhookPayload): Promise<void> {
+ try {
+ if (data.type === 'test') {
+ await this.client.util.info(
+ `Test vote webhook data recieved, ${await this.client.util.haste(
+ JSON.stringify(data, null, '\t')
+ )}`
+ );
+ return;
+ } else {
+ const parsedData = {
+ user: await this.client.users.fetch(data.user),
+ type: data.type as 'upvote' | 'test',
+ isWeekend: data.isWeekend
+ };
+ const channel = (await this.client.channels.fetch(
+ channels.dblVote
+ )) as TextChannel;
+ const webhooks = await channel.fetchWebhooks();
+ const webhook =
+ webhooks.size < 1
+ ? await channel.createWebhook('Utilibot Voting')
+ : webhooks.first();
+ const webhookClient = new WebhookClient(webhook.id, webhook.token, {
+ allowedMentions: { parse: [] }
+ });
+ await webhookClient.send(undefined, {
+ username: 'Utilibot Voting',
+ avatarURL: this.client.user.avatarURL({ dynamic: true }),
+ embeds: [
+ new MessageEmbed()
+ .setTitle('Top.GG Vote')
+ // prettier-ignore
+ .setDescription(
+ stripIndent`
+ User: ${parsedData.user.tag}
+ Weekend (worth double): ${parsedData.isWeekend ? 'Yes' : 'No'}
+ `
+ )
+ .setAuthor(
+ parsedData.user.tag,
+ parsedData.user.avatarURL({ dynamic: true })
+ )
+ .setTimestamp()
+ ]
+ });
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ }
+}
diff --git a/src/listeners/client/ReadyListener.ts b/src/listeners/client/ReadyListener.ts
new file mode 100644
index 0000000..ae510f6
--- /dev/null
+++ b/src/listeners/client/ReadyListener.ts
@@ -0,0 +1,16 @@
+import { BotListener } from '../../lib/extensions/BotListener';
+
+export default class CommandBlockedListener extends BotListener {
+ public constructor() {
+ super('ready', {
+ emitter: 'client',
+ event: 'ready'
+ });
+ }
+
+ public async exec(): Promise<void> {
+ await this.client.util.info(
+ `Sucessfully logged in as ${this.client.user.tag}`
+ );
+ }
+}
diff --git a/src/listeners/commands/CommandBlockedListener.ts b/src/listeners/commands/CommandBlockedListener.ts
new file mode 100644
index 0000000..82e53a9
--- /dev/null
+++ b/src/listeners/commands/CommandBlockedListener.ts
@@ -0,0 +1,34 @@
+import { BotListener } from '../../lib/extensions/BotListener';
+import { Command } from 'discord-akairo';
+import { Message } from 'discord.js';
+
+export default class CommandBlockedListener extends BotListener {
+ public constructor() {
+ super('commandBlocked', {
+ emitter: 'commandHandler',
+ event: 'commandBlocked'
+ });
+ }
+
+ public async exec(
+ message: Message,
+ command: Command,
+ reason: string
+ ): Promise<void> {
+ switch (reason) {
+ case 'owner': {
+ await message.util.send(
+ `You must be an owner to run command \`${message.util.parsed.command}\``
+ );
+ break;
+ }
+ case 'blacklist': {
+ // pass
+ break;
+ }
+ default: {
+ await message.util.send(`Command blocked with reason \`${reason}\``);
+ }
+ }
+ }
+}
diff --git a/src/listeners/guild/Unban.ts b/src/listeners/guild/Unban.ts
new file mode 100644
index 0000000..7f85132
--- /dev/null
+++ b/src/listeners/guild/Unban.ts
@@ -0,0 +1,25 @@
+import { User } from 'discord.js';
+import { BotGuild } from '../../lib/extensions/BotGuild';
+import { BotListener } from '../../lib/extensions/BotListener';
+import { Ban } from '../../lib/types/Models';
+
+export default class CommandBlockedListener extends BotListener {
+ public constructor() {
+ super('guildBanRemove', {
+ emitter: 'client',
+ event: 'guildBanRemove'
+ });
+ }
+
+ public async exec(guild: BotGuild, user: User): Promise<void> {
+ const bans = await Ban.findAll({
+ where: {
+ user: user.id,
+ guild: guild.id
+ }
+ });
+ for (const dbBan of bans) {
+ await dbBan.destroy();
+ }
+ }
+}
diff --git a/src/tasks.ts b/src/tasks.ts
new file mode 100644
index 0000000..3aa07c8
--- /dev/null
+++ b/src/tasks.ts
@@ -0,0 +1,38 @@
+import { DiscordAPIError } from 'discord.js';
+import { Op } from 'sequelize';
+import { BotClient } from './lib/extensions/BotClient';
+import { Ban } from './lib/types/Models';
+
+export const BanTask = async (client: BotClient): Promise<void> => {
+ const rows = await Ban.findAll({
+ where: {
+ [Op.and]: [
+ {
+ expires: {
+ [Op.lt]: new Date() // Find all rows with an expiry date before now
+ }
+ }
+ ]
+ }
+ });
+ client.util.devLog(`Queried bans, found ${rows.length} expired bans.`);
+ for (const row of rows) {
+ const guild = client.guilds.cache.get(row.guild);
+ if (!guild) {
+ await row.destroy();
+ continue;
+ }
+ try {
+ await guild.members.unban(
+ row.user,
+ `Unbanning user because tempban expired`
+ );
+ } catch (e) {
+ if (e instanceof DiscordAPIError) {
+ // Member not banned, ignore
+ } else throw e;
+ }
+ await row.destroy();
+ client.util.devLog('Unbanned user');
+ }
+};