diff options
Diffstat (limited to 'src')
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'); + } +}; |