Initial command base

This commit is contained in:
Snazzah 2020-08-10 23:08:26 -05:00
parent a14f5e3b9c
commit dab194f8f5
No known key found for this signature in database
GPG key ID: 5E71D54F3D86282E
35 changed files with 3513 additions and 2 deletions

2
.eslintignore Normal file
View file

@ -0,0 +1,2 @@
.Config
Config/

79
.eslintrc.json Normal file
View file

@ -0,0 +1,79 @@
{
"env": {
"commonjs": true,
"es6": true,
"node": true
},
"extends": "eslint:recommended",
"globals": {},
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module"
},
"rules": {
"array-bracket-spacing": [
"warn",
"never"
],
"computed-property-spacing": "warn",
"indent": [
"warn",
2
],
"keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"max-len": [
"warn",
{
"code": 110,
"ignoreComments": true,
"ignoreUrls": true
}
],
"no-cond-assign": [
2,
"except-parens"
],
"no-use-before-define": [
2,
{
"functions": false,
"classes": false,
"variables": false
}
],
"new-cap": 0,
"no-caller": 2,
"no-undef": 2,
"no-unused-vars": 1,
"no-empty": [
"error",
{
"allowEmptyCatch": true
}
],
"no-console": "off",
"no-multi-spaces": "warn",
"prefer-const": [
"warn",
{
"destructuring": "all"
}
],
"quotes": [
"warn",
"single"
],
"semi": [
"warn",
"always"
],
"spaced-comment": "warn",
"space-infix-ops": "warn"
}
}

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
* text=auto eol=lf

12
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,12 @@
version: 2
updates:
# Update packages
- package-ecosystem: npm
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 10
target-branch: dev
commit-message:
prefix: chore
include: scope

32
.github/workflows/eslint.yml vendored Normal file
View file

@ -0,0 +1,32 @@
name: ESLint
on:
push:
paths:
- "src/**"
- ".eslintrc.*"
- ".github/workflows/eslint.yml"
jobs:
update:
name: ESLint
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Install Node v12
uses: actions/setup-node@v1
with:
node-version: 12.x
- name: Install Yarn
run: npm install -g yarn
- name: Install dependencies
run: yarn install
- name: Run ESLint
run: yarn run eslint:fix
- name: Commit changes
uses: EndBug/add-and-commit@v4
with:
add: src
message: "chore(lint): Auto-fix linting errors"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules
config/default.js
config/production.js
package-lock.json
.idea/

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 LBRY Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,2 +1,25 @@
# curate
LBRY Curation bot for discord interaction
# LBRY Curation Bot
## Introduction
This bot allows the community of LBRY to support eachother through the [LBRY Foundation Discord](https://discord.gg/UgBhwZ8)
## Installation
* Pull the repo
* Install [Node.JS LTS](https://nodejs.org/) (Currently Node v12.x)
* Install [Yarn](https://yarnpkg.com/) (`npm install yarn -g`)
* Install [Redis](https://redis.io/) ([quickstart](https://redis.io/topics/quickstart))
* Install LBRY-SDK
* Set your NODE_ENV (Node environment) Environment Variable to Production (`EXPORT NODE_ENV=production`)
* In the `config/` folder, copy `_default.js` to `production.js` and edit the config as needed
## Contributions
This bot would not be possible without the following people/software:
* LBRY Inc. and the LBRY SDK
* Eris - NodeJS Library for Discord
* LBRY Foundation
* Snazzah - Creator of the Faux command base and developer of the bot
* Coolguy3289 - Developer of the bot and command flow

40
config/_default.js Normal file
View file

@ -0,0 +1,40 @@
module.exports = {
// [string] The token for the bot
token: "",
// [string] The prefix for the bot
prefix: "L!",
// [Array<string>] An array of elevated IDs, giving them access to developer commands
elevated: [],
// [string] The path where the commands will be found
commandsPath: "./src/commands",
// [boolean] Whether debug logs will be shown
debug: false,
// [number] The main embed color (#ffffff -> 0xffffff)
embedColor: 0x429bce,
// [string] curator_role_id
curatorRoleID: "",
// [string] admin_role_id
adminRoleID: "",
// [string] sdk_url
sdkURL: "",
// [Object] Eris client options (https://abal.moe/Eris/docs/Client)
discordConfig: {
autoreconnect: true,
allowedMentions: {
everyone: false,
roles: false,
users: true
},
maxShards: "auto",
messageLimit: 0,
intents: [
"guilds",
"guildEmojis",
"guildWebhooks",
"guildMessages",
"guildMessageReactions",
"directMessages",
"directMessageReactions"
] // 13865 - Intent Raw.
}
}

33
package.json Normal file
View file

@ -0,0 +1,33 @@
{
"name": "lbry-curate",
"version": "0.0.1",
"description": "Support the LBRY Community through Discord!",
"main": "src/bot.js",
"scripts": {
"start": "node src/bot.js",
"eslint": "eslint ./src",
"eslint:fix": "eslint ./src --fix"
},
"dependencies": {
"abort-controller": "^3.0.0",
"cat-loggr": "^1.1.0",
"config": "^3.3.1",
"eris": "^0.13.3",
"eventemitter3": "^4.0.4",
"fuzzy": "^0.1.3",
"moment": "^2.27.0",
"node-fetch": "^2.3.0",
"redis": "^3.0.2",
"require-reload": "^0.2.2",
"winston": "^3.3.3"
},
"devDependencies": {
"eslint": "^7.6.0"
},
"repository": {
"type": "git",
"url": "git+https://github.com/trello-talk/TrelloBot.git"
},
"author": "LBRY Curate Dev Team <curate@lbry.org>",
"license": "MIT"
}

9
pm2.json Normal file
View file

@ -0,0 +1,9 @@
{
"apps": [
{
"name": "LBRYCurate",
"script": "node",
"args": "src/bot.js"
}
]
}

148
src/bot.js Normal file
View file

@ -0,0 +1,148 @@
const Eris = require('eris');
const Database = require('./database');
const EventHandler = require('./events');
const CommandLoader = require('./commandloader');
const MessageAwaiter = require('./messageawaiter');
const path = require('path');
const CatLoggr = require('cat-loggr');
const config = require('config');
class CurateBot extends Eris.Client {
constructor({ packagePath, mainDir } = {}) {
// Initialization
const pkg = require(packagePath || `${mainDir}/package.json`);
super(config.token, JSON.parse(JSON.stringify(config.discordConfig)));
this.dir = mainDir;
this.pkg = pkg;
this.logger = new CatLoggr({
level: config.debug ? 'debug' : 'info',
levels: [
{ name: 'fatal', color: CatLoggr._chalk.red.bgBlack, err: true },
{ name: 'error', color: CatLoggr._chalk.black.bgRed, err: true },
{ name: 'warn', color: CatLoggr._chalk.black.bgYellow, err: true },
{ name: 'init', color: CatLoggr._chalk.black.bgGreen },
{ name: 'webserv', color: CatLoggr._chalk.black.bgBlue },
{ name: 'info', color: CatLoggr._chalk.black.bgCyan },
{ name: 'assert', color: CatLoggr._chalk.cyan.bgBlack },
{ name: 'poster', color: CatLoggr._chalk.yellow.bgBlack },
{ name: 'debug', color: CatLoggr._chalk.magenta.bgBlack, aliases: ['log', 'dir'] },
{ name: 'limiter', color: CatLoggr._chalk.gray.bgBlack },
{ name: 'fileload', color: CatLoggr._chalk.white.bgBlack }
]
});
this.logger.setGlobal();
this.config = config;
this.typingIntervals = new Map();
// Events
this.on('ready', () => console.info('All shards ready.'));
this.on('disconnect', () => console.warn('All shards Disconnected.'));
this.on('reconnecting', () => console.warn('Reconnecting client.'));
this.on('debug', message => console.debug(message));
// Shard Events
this.on('connect', id => console.info(`Shard ${id} connected.`));
this.on('error', (error, id) => console.error(`Error in shard ${id}`, error));
this.on('hello', (_, id) => console.debug(`Shard ${id} recieved hello.`));
this.on('warn', (message, id) => console.warn(`Warning in Shard ${id}`, message));
this.on('shardReady', id => console.info(`Shard ${id} ready.`));
this.on('shardResume', id => console.warn(`Shard ${id} resumed.`));
this.on('shardDisconnect', (error, id) => console.warn(`Shard ${id} disconnected`, error));
// SIGINT & uncaught exceptions
process.once('uncaughtException', async err => {
console.error('Uncaught Exception', err.stack);
await this.dieGracefully();
process.exit(0);
});
process.once('SIGINT', async () => {
console.info('Caught SIGINT');
await this.dieGracefully();
process.exit(0);
});
console.init('Client initialized');
}
/**
* Creates a promise that resolves on the next event
* @param {string} event The event to wait for
*/
waitTill(event) {
return new Promise(resolve => this.once(event, resolve));
}
/**
* Starts the processes and log-in to Discord.
*/
async start() {
// Redis
this.db = new Database(this);
await this.db.connect(this.config.redis);
// Discord
await this.connect();
await this.waitTill('ready');
this.editStatus('online', {
name: `${this.config.prefix}help`,
type: 3,
});
// Commands
this.cmds = new CommandLoader(this, path.join(this.dir, this.config.commandsPath));
this.cmds.reload();
this.cmds.preloadAll();
// Events
this.messageAwaiter = new MessageAwaiter(this);
this.eventHandler = new EventHandler(this);
}
/**
* KIlls the bot
*/
dieGracefully() {
return super.disconnect();
}
// Typing
/**
* Start typing in a channel
* @param {Channel} channel The channel to start typing in
*/
async startTyping(channel) {
if (this.isTyping(channel)) return;
await channel.sendTyping();
this.typingIntervals.set(channel.id, setInterval(() => {
channel.sendTyping().catch(() => this.stopTyping(channel));
}, 5000));
}
/**
* Whether the bot is currently typing in a channel
* @param {Channel} channel
*/
isTyping(channel) {
return this.typingIntervals.has(channel.id);
}
/**
* Stops typing in a channel
* @param {Channel} channel
*/
stopTyping(channel) {
if (!this.isTyping(channel)) return;
const interval = this.typingIntervals.get(channel.id);
clearInterval(interval);
this.typingIntervals.delete(channel.id);
}
}
const Bot = new CurateBot({ mainDir: path.join(__dirname, '..') });
Bot.start().catch(e => {
Bot.logger.error('Failed to start bot! Exiting in 10 seconds...');
console.error(e);
setTimeout(() => process.exit(0), 10000);
});

100
src/commandloader.js Normal file
View file

@ -0,0 +1,100 @@
const fs = require('fs');
const path = require('path');
const reload = require('require-reload')(require);
module.exports = class CommandLoader {
constructor(client, cPath) {
this.commands = [];
this.path = path.resolve(cPath);
this.client = client;
}
/**
* Loads commands from a folder
* @param {String} folderPath
*/
iterateFolder(folderPath) {
const files = fs.readdirSync(folderPath);
files.map(file => {
const filePath = path.join(folderPath, file);
const stat = fs.lstatSync(filePath);
if (stat.isSymbolicLink()) {
const realPath = fs.readlinkSync(filePath);
if (stat.isFile() && file.endsWith('.js')) {
this.load(realPath);
} else if (stat.isDirectory()) {
this.iterateFolder(realPath);
}
} else if (stat.isFile() && file.endsWith('.js'))
this.load(filePath);
else if (stat.isDirectory())
this.iterateFolder(filePath);
});
}
/**
* Loads a command
* @param {string} commandPath
*/
load(commandPath) {
console.fileload('Loading command', commandPath);
const cls = reload(commandPath);
const cmd = new cls(this.client);
cmd.path = commandPath;
this.commands.push(cmd);
return cmd;
}
/**
* Reloads all commands
*/
reload() {
this.commands = [];
this.iterateFolder(this.path);
}
/**
* Gets a command based on it's name or alias
* @param {string} name The command's name or alias
*/
get(name) {
let cmd = this.commands.find(c => c.name === name);
if (cmd) return cmd;
this.commands.forEach(c => {
if (c.options.aliases.includes(name)) cmd = c;
});
return cmd;
}
/**
* Preloads a command
* @param {string} name The command's name or alias
*/
preload(name) {
if (!this.get(name)) return;
this.get(name)._preload();
}
/**
* Preloads all commands
*/
preloadAll() {
this.commands.forEach(c => c._preload());
}
/**
* Processes the cooldown of a command
* @param {Message} message
* @param {Command} command
*/
async processCooldown(message, command) {
if (this.client.config.elevated.includes(message.author.id)) return true;
const now = Date.now() - 1;
const cooldown = command.cooldownAbs;
let userCD = await this.client.db.hget(`cooldowns:${message.author.id}`, command.name) || 0;
if (userCD) userCD = parseInt(userCD);
if (userCD + cooldown > now) return false;
await this.client.db.hset(`cooldowns:${message.author.id}`, command.name, now);
return true;
}
};

101
src/commands/help.js Normal file
View file

@ -0,0 +1,101 @@
const Command = require('../structures/Command');
const Util = require('../util');
const config = require('config');
module.exports = class Help extends Command {
get name() { return 'help'; }
get _options() { return {
aliases: [
'?', 'h', 'commands', 'cmds', // English
'yardim', 'yardım', 'komutlar', // Turkish
'ayuda', // Spanish
'ajuda' // Catalan & Portuguese
],
permissions: ['embed'],
cooldown: 0,
}; }
exec(message, { args, }) {
if (args[0]) {
// Display help on a command
const command = this.client.cmds.get(args[0]);
if (!command)
return message.channel.createMessage(`The command \`${args[0]}\` could not be found.`);
else {
const embed = {
title: `${config.prefix}${command.name}`,
color: config.embedColor,
fields: [
{ name: '*Usage*',
value: `${config.prefix}${command.name}${
command.metadata.usage ?
` \`${command.metadata.usage}\`` : ''}` }
],
description: command.metadata.description
};
// Cooldown
if (command.options.cooldown)
embed.fields.push({
name: '*Cooldown*',
value: `${command.options.cooldown.toLocaleString()} second(s)`,
inline: false
});
// Aliases
if (command.options.aliases.length !== 0) embed.fields.push({
name: '*Alias(es)*',
value: command.options.aliases.map(a => `\`${a}\``).join(', ')
});
// Image
if (command.metadata.image)
embed.image = { url: command.metadata.image };
// Note
if (command.metadata.note)
embed.fields.push({
name: '*Note*',
value: command.metadata.note
});
return message.channel.createMessage({ embed });
}
} else {
// Display general help command
const embed = {
color: config.embedColor,
description: 'LBRY Curate',
footer: { text: `\`${config.prefix}help [command]\` for more info.` },
fields: []
};
// Populate categories
const categories = {};
this.client.cmds.commands.forEach(v => {
if (!v.options.listed && !config.elevated.includes(message.author.id)) return;
const string = v.name;
if (categories[v.metadata.category])
categories[v.metadata.category].push(string);
else categories[v.metadata.category] = [string];
});
// List categories
Util.keyValueForEach(categories, (k, v) => {
embed.fields.push({
name: `*${k}*`,
value: '```' + v.join(', ') + '```',
inline: true
});
});
return message.channel.createMessage({ embed });
}
}
get metadata() { return {
category: 'General',
description: 'Shows the help message and gives information on commands.',
usage: '[command]'
}; }
};

View file

@ -0,0 +1,38 @@
/* jshint evil: true */
const Command = require('../../structures/Command');
const Util = require('../../util');
module.exports = class AsyncEval extends Command {
get name() { return 'asynceval'; }
get _options() { return {
aliases: ['ae', 'aeval', 'aevaluate', 'asyncevaluate'],
permissions: ['elevated'],
listed: false,
}; }
// eslint-disable-next-line no-unused-vars
async exec(message, opts) {
try {
const start = Date.now();
const code = Util.Prefix.strip(message, this.client).split(' ').slice(1).join(' ');
const result = await eval(`(async () => {${code}})()`);
const time = Date.now() - start;
return Util.Hastebin.autosend(
`Took ${time.toLocaleString()} ms\n\`\`\`js\n${result}\`\`\`\n`,
message);
} catch (e) {
return Util.Hastebin.autosend('```js\n' + e.stack + '\n```', message);
}
}
get metadata() { return {
category: 'Developer',
description: 'Evaluate code asynchronously.',
usage: '<code>',
note: 'Due to the added async IIFE wrapper in this command, ' +
'it is necessary to use the return statement to return a result.\n' +
'e.g. `return 1`'
}; }
};

View file

@ -0,0 +1,35 @@
/* jshint evil: true */
const Command = require('../../structures/Command');
const Util = require('../../util');
module.exports = class Eval extends Command {
get name() { return 'eval'; }
get _options() { return {
aliases: ['e'],
permissions: ['elevated'],
listed: false,
minimumArgs: 1
}; }
// eslint-disable-next-line no-unused-vars
async exec(message, opts) {
try {
const start = Date.now();
const result = eval(Util.Prefix.strip(message, this.client).split(' ').slice(1).join(' '));
const time = Date.now() - start;
return Util.Hastebin.autosend(
`Took ${time.toLocaleString()} ms\n\`\`\`js\n${result}\`\`\`\n`,
message);
} catch (e) {
return Util.Hastebin.autosend('```js\n' + e.stack + '\n```', message);
}
}
get metadata() { return {
category: 'Developer',
description: 'Evaluate code.',
usage: '<code>'
}; }
};

View file

@ -0,0 +1,34 @@
const Command = require('../../structures/Command');
const Util = require('../../util');
const { exec } = require('child_process');
module.exports = class Exec extends Command {
get name() { return 'exec'; }
get _options() { return {
aliases: ['ex', 'sys'],
permissions: ['elevated'],
listed: false,
minimumArgs: 1
}; }
codeBlock(content, lang = null) {
return `\`\`\`${lang ? `${lang}\n` : ''}${content}\`\`\``;
}
async exec(message) {
await this.client.startTyping(message.channel);
exec(Util.Prefix.strip(message, this.client).split(' ').slice(1).join(' '), (err, stdout, stderr) => {
this.client.stopTyping(message.channel);
if (err) return message.channel.createMessage(this.codeBlock(err, 'js'));
const stdErrBlock = (stderr ? this.codeBlock(stderr, 'js') + '\n' : '');
return Util.Hastebin.autosend(stdErrBlock + this.codeBlock(stdout), message);
});
}
get metadata() { return {
category: 'Developer',
description: 'Do some terminal commands.',
usage: '<command> …'
}; }
};

View file

@ -0,0 +1,23 @@
const Command = require('../../structures/Command');
module.exports = class Reload extends Command {
get name() { return 'reload'; }
get _options() { return {
aliases: ['r'],
permissions: ['elevated'],
listed: false,
}; }
async exec(message) {
const sentMessage = await message.channel.createMessage('♻️ Reloading commands…');
this.client.cmds.reload();
this.client.cmds.preloadAll();
return sentMessage.edit('✅ Reloaded commands.');
}
get metadata() { return {
category: 'Developer',
description: 'Reloads all commands.'
}; }
};

View file

@ -0,0 +1,46 @@
const Command = require('../../structures/Command');
const fs = require('fs');
module.exports = class ReloadOne extends Command {
get name() { return 'reloadone'; }
get _options() { return {
aliases: ['r1', 'reloadsingle', 'rs'],
permissions: ['elevated'],
minimumArgs: 1,
listed: false,
}; }
async exec(message, { args }) {
const commands = args.map(name => this.client.cmds.get(name));
if (commands.includes(undefined))
return message.channel.createMessage('Invalid command!');
const fileExist = commands.map(command => {
const path = command.path;
const stat = fs.lstatSync(path);
return stat.isFile();
});
if (fileExist.includes(false))
return message.channel.createMessage('A file that had a specified command no longer exists!');
const sentMessage = await message.channel.createMessage('♻️ Reloading commands…');
const reloadedCommands = commands.map(command => {
const path = command.path;
const index = this.client.cmds.commands.indexOf(command);
this.client.cmds.commands.splice(index, 1);
const newCommand = this.client.cmds.load(path);
newCommand.preload();
return newCommand;
});
return sentMessage.edit(`✅ Reloaded ${reloadedCommands.map(c => `\`${c.name}\``).join(', ')}.`);
}
get metadata() { return {
category: 'Developer',
description: 'Reloads specific commands.',
usage: '<commandName> [commandName] …'
}; }
};

View file

@ -0,0 +1,22 @@
const Command = require('../../structures/Command');
module.exports = class Restart extends Command {
get name() { return 'restart'; }
get _options() { return {
aliases: ['re'],
permissions: ['elevated'],
listed: false,
}; }
async exec(message) {
await message.channel.createMessage('Restarting...');
await this.client.dieGracefully();
process.exit(0);
}
get metadata() { return {
category: 'Developer',
description: 'Restarts the bot.'
}; }
};

27
src/commands/ping.js Normal file
View file

@ -0,0 +1,27 @@
const Command = require('../structures/Command');
module.exports = class Ping extends Command {
get name() { return 'ping'; }
get _options() { return {
aliases: ['p', 'pong'],
cooldown: 0,
}; }
async exec(message) {
const currentPing = Array.from(this.client.shards.values())
.map(shard => shard.latency).reduce((prev, val) => prev + val, 0);
const timeBeforeMessage = Date.now();
const sentMessage = await message.channel.createMessage('> :ping_pong: ***Ping...***\n' +
`> WS: ${currentPing.toLocaleString()} ms`);
await sentMessage.edit(
'> :ping_pong: ***Pong!***\n' +
`> WS: ${currentPing.toLocaleString()} ms\n` +
`> REST: ${(Date.now() - timeBeforeMessage).toLocaleString()} ms`);
}
get metadata() { return {
category: 'General',
description: 'Pong!'
}; }
};

145
src/database.js Normal file
View file

@ -0,0 +1,145 @@
const redis = require('redis');
const { EventEmitter } = require('eventemitter3');
/**
* The Redis database handler
*/
module.exports = class Database extends EventEmitter {
constructor(client) {
super();
this.client = client;
this.reconnectAfterClose = true;
console.init('Redis initialized');
}
/**
* Creates a client and connects to the database
* @param {Object} options
*/
connect({ host = 'localhost', port, password }) {
console.info('Connecting to redis...');
return new Promise((resolve, reject) => {
this.redis = redis.createClient({ host, port, password });
this.redis.on('error', this.onError.bind(this));
this.redis.on('warning', w => console.warn('Redis Warning', w));
this.redis.on('end', () => this.onClose.bind(this));
this.redis.on('reconnecting', () => console.warn('Reconnecting to redis...'));
this.redis.on('ready', () => console.info('Redis client ready.'));
this.redis.on('connect', () => console.info('Redis connection has started.'));
this.host = host;
this.port = port;
this.password = password;
this.redis.once('ready', resolve.bind(this));
this.redis.once('error', reject.bind(this));
});
}
/**
* @private
* @param {string} k Key
*/
_p(k) { return (this.client.config.prefix || '') + k; }
// #region Redis functions
hget(key, hashkey) {
return new Promise((resolve, reject) => {
this.redis.HGET(this._p(key), hashkey, (err, value) => {
if (err) reject(err);
resolve(value);
});
});
}
hset(key, hashkey, value) {
return new Promise((resolve, reject) => {
this.redis.HSET(this._p(key), hashkey, value, (err, res) => {
if (err) reject(err);
resolve(res);
});
});
}
incr(key) {
return new Promise((resolve, reject) => {
this.redis.incr(this._p(key), (err, res) => {
if (err) reject(err);
resolve(res);
});
});
}
get(key) {
return new Promise((resolve, reject) => {
this.redis.get(this._p(key), function(err, reply) {
if (err) reject(err);
resolve(reply);
});
});
}
expire(key, ttl) {
return new Promise((resolve, reject) => {
this.redis.expire(this._p(key), ttl, (err, value) => {
if (err) reject(err);
resolve(value);
});
});
}
exists(key) {
return new Promise((resolve, reject) => {
this.redis.exists(this._p(key), (err, value) => {
if (err) reject(err);
resolve(value === 1);
});
});
}
set(key, value) {
return new Promise((resolve, reject) => {
this.redis.set(this._p(key), value, (err, res) => {
if (err) reject(err);
resolve(res);
});
});
}
// #endregion
/**
* Reconnects the client
*/
async reconnect() {
console.warn('Attempting redis reconnection');
this.conn = await this.connect(this);
}
/**
* Disconnects the client
*/
disconnect() {
this.reconnectAfterClose = false;
return new Promise(resolve => {
this.redis.once('end', resolve);
this.redis.quit();
});
}
/**
* @private
*/
onError(err) {
console.error('Redis Error', err);
this.emit('error', err);
}
/**
* @private
*/
async onClose() {
console.error('Redis closed');
this.emit('close');
if (this.reconnectAfterClose) await this.reconnect();
}
};

50
src/events.js Normal file
View file

@ -0,0 +1,50 @@
const ArgumentInterpreter = require('./structures/ArgumentInterpreter');
const Util = require('./util');
const config = require('config');
module.exports = class Events {
constructor(client) {
this.client = client;
client.on('messageCreate', this.onMessage.bind(this));
client.on('messageReactionAdd', this.onReaction.bind(this));
}
async onMessage(message) {
if (message.author.bot || message.author.system) return;
// Check to see if bot can send messages
if (message.channel.type !== 1 &&
!message.channel.permissionsOf(this.client.user.id).has('sendMessages')) return;
// Message awaiter
if (this.client.messageAwaiter.processHalt(message)) return;
// Command parsing
const argInterpretor = new ArgumentInterpreter(Util.Prefix.strip(message, this.client, [config.prefix]));
const args = argInterpretor.parseAsStrings();
const commandName = args.splice(0, 1)[0];
const command = this.client.cmds.get(commandName, message);
if (!message.content.match(Util.Prefix.regex(this.client, [config.prefix])) || !command) return;
try {
await command._exec(message, {
args
});
} catch (e) {
if (this.client.config.debug) {
console.error(`The '${command.name}' command failed.`);
console.log(e);
}
message.channel.createMessage(':fire: An error occurred while processing that command!');
this.client.stopTyping(message.channel);
}
}
onReaction(message, emoji, userID) {
const id = `${message.id}:${userID}`;
if (this.client.messageAwaiter.reactionCollectors.has(id)) {
const collector = this.client.messageAwaiter.reactionCollectors.get(id);
collector._onReaction(emoji, userID);
}
}
};

154
src/messageawaiter.js Normal file
View file

@ -0,0 +1,154 @@
const Halt = require('./structures/Halt');
const ReactionCollector = require('./structures/ReactionCollector');
/**
* Handles async message functions
*/
class MessageAwaiter {
constructor(client) {
this.client = client;
this.halts = new Map();
this.reactionCollectors = new Map();
}
/**
* Creates a halt. This pauses any events in the event handler for a specific channel and user.
* This allows any async functions to handle any follow-up messages.
* @param {string} channelID The channel's ID
* @param {string} userID The user's ID
* @param {number} [timeout=30000] The time until the halt is auto-cleared
*/
createHalt(channelID, userID, timeout = 30000) {
const id = `${channelID}:${userID}`;
if (this.halts.has(id)) this.halts.get(id).end();
const halt = new Halt(this, timeout);
halt.once('end', () => this.halts.delete(id));
this.halts.set(id, halt);
return halt;
}
/**
* Creates a reaction collector. Any reactions from the user will be emitted from the collector.
* @param {string} message The message to collect from
* @param {string} userID The user's ID
* @param {number} [timeout=30000] The time until the halt is auto-cleared
*/
createReactionCollector(message, userID, timeout = 30000) {
const id = `${message.id}:${userID}`;
if (this.reactionCollectors.has(id)) this.reactionCollectors.get(id).end();
const collector = new ReactionCollector(this, timeout);
collector.once('end', () => this.reactionCollectors.delete(id));
this.reactionCollectors.set(id, collector);
return collector;
}
/**
* Gets an ongoing halt based on a message
* @param {Message} message
*/
getHalt(message) {
const id = `${message.channel.id}:${message.author.id}`;
return this.halts.get(id);
}
/**
* Processes a halt based on a message
* @param {Message} message
*/
processHalt(message) {
const id = `${message.channel.id}:${message.author.id}`;
if (this.halts.has(id)) {
const halt = this.halts.get(id);
halt._onMessage(message);
return true;
}
return false;
}
/**
* Awaits the next message from a user
* @param {Message} message The message to wait for
* @param {Object} [options] The options for the await
* @param {number} [options.filter] The message filter
* @param {number} [options.timeout=30000] The timeout for the halt
* @returns {?Message}
*/
awaitMessage(message, { filter = () => true, timeout = 30000 } = {}) {
return new Promise(resolve => {
const halt = this.createHalt(message.channel.id, message.author.id, timeout);
let foundMessage = null;
halt.on('message', nextMessage => {
if (filter(nextMessage)) {
foundMessage = nextMessage;
halt.end();
}
});
halt.on('end', () => resolve(foundMessage));
});
}
/**
* Same as {@see #awaitMessage}, but is used for getting user input via next message
* @param {Message} message The message to wait for
* @param {Object} [options] The options for the await
* @param {number} [options.filter] The message filter
* @param {number} [options.timeout=30000] The timeout for the halt
* @param {string} [options.header] The content to put in the bot message
* @returns {?Message}
*/
async getInput(message, { filter = () => true, timeout = 30000, header = null } = {}) {
await message.channel.createMessage(`<@${message.author.id}>, ` +
(header || 'Type the message you want to input.') + '\n\n' +
`Typing "<@!${this.client.user.id}> cancel" will cancel the input.`);
return new Promise(resolve => {
const halt = this.createHalt(message.channel.id, message.author.id, timeout);
let handled = false, input = null;
halt.on('message', nextMessage => {
if (filter(nextMessage)) {
const cancelRegex = new RegExp(`^(?:<@!?${this.client.user.id}>\\s?)(cancel|stop|end)$`);
if (!nextMessage.content || cancelRegex.test(nextMessage.content.toLowerCase())) {
handled = true;
message.channel.createMessage(`<@${message.author.id}>, Your last input was canceled.`);
} else input = nextMessage.content;
halt.end();
}
});
halt.on('end', async () => {
if (!input && !handled)
await message.channel.createMessage(`<@${message.author.id}>, Your last input was canceled.`);
resolve(input);
});
});
}
/**
* Same as {@see #awaitMessage}, but is used for confirmation
* @param {Message} message The message to wait for
* @param {Object} [options] The options for the await
* @param {number} [options.timeout=30000] The timeout for the halt
* @param {string} [options.header] The content to put in the bot message
* @returns {?Message}
*/
async confirm(message, { timeout = 30000, header = null } = {}) {
await message.channel.createMessage(`<@${message.author.id}>, ` +
(header || 'Are you sure you want to do this?') + '\n\n' +
'Type `yes` to confirm. Any other message will cancel the confirmation.');
return new Promise(resolve => {
const halt = this.createHalt(message.channel.id, message.author.id, timeout);
let input = false;
halt.on('message', nextMessage => {
input = nextMessage.content === 'yes';
halt.end();
});
halt.on('end', async () => {
if (!input)
await message.channel.createMessage(`<@${message.author.id}>, Confirmation canceled.`);
resolve(input);
});
});
}
}
MessageAwaiter.Halt = Halt;
module.exports = MessageAwaiter;

View file

@ -0,0 +1,182 @@
/**
* A class that iterates a string's index
* @see ArgumentInterpreter
*/
class StringIterator {
/**
* @param {string} string The string to iterate through
*/
constructor(string) {
this.string = string;
this.index = 0;
this.previous = 0;
this.end = string.length;
}
/**
* Get the character on an index and moves the index forward.
* @returns {?string}
*/
get() {
const nextChar = this.string[this.index];
if (!nextChar)
return nextChar;
else {
this.previous += this.index;
this.index += 1;
return nextChar;
}
}
/**
* Reverts to the previous index.
*/
undo() {
this.index = this.previous;
}
/**
* The previous character that was used
* @type {string}
*/
get prevChar() {
return this.string[this.previous];
}
/**
* Whether or not the index is out of range
* @type {boolean}
*/
get inEOF() {
return this.index >= this.end;
}
}
/**
* Parses arguments from a message.
*/
class ArgumentInterpreter {
/**
* @param {string} string The string that will be parsed for arguments
* @param {?Object} options The options for the interpreter
* @param {?boolean} [options.allowWhitespace=false] Whether to allow whitespace characters in the arguments
*/
constructor(string, { allowWhitespace = false } = {}) {
this.string = string;
this.allowWhitespace = allowWhitespace;
}
/**
* Parses the arguements as strings.
* @returns {Array<String>}
*/
parseAsStrings() {
const args = [];
let currentWord = '';
let quotedWord = '';
const string = this.allowWhitespace ? this.string : this.string.trim();
const iterator = new StringIterator(string);
while (!iterator.inEOF) {
const char = iterator.get();
if (char === undefined) break;
if (this.isOpeningQuote(char) && iterator.prevChar !== '\\') {
currentWord += char;
const closingQuote = ArgumentInterpreter.QUOTES[char];
// Quote iteration
while (!iterator.inEOF) {
const quotedChar = iterator.get();
// Unexpected EOF
if (quotedChar === undefined) {
args.push(...currentWord.split(' '));
break;
}
if (quotedChar == '\\') {
currentWord += quotedChar;
const nextChar = iterator.get();
if (nextChar === undefined) {
args.push(...currentWord.split(' '));
break;
}
currentWord += nextChar;
// Escaped quote
if (ArgumentInterpreter.ALL_QUOTES.includes(nextChar)) {
quotedWord += nextChar;
} else {
// Ignore escape
quotedWord += quotedChar + nextChar;
}
continue;
}
// Closing quote
if (quotedChar == closingQuote) {
currentWord = '';
args.push(quotedWord);
quotedWord = '';
break;
}
currentWord += quotedChar;
quotedWord += quotedChar;
}
continue;
}
if (/^\s$/.test(char)) {
if (currentWord)
args.push(currentWord);
currentWord = '';
continue;
}
currentWord += char;
}
if (currentWord.length)
args.push(...currentWord.split(' '));
return args;
}
/**
* Checks whether or not a character is an opening quote
* @param {string} char The character to check
*/
isOpeningQuote(char) {
return Object.keys(ArgumentInterpreter.QUOTES).includes(char);
}
}
// Opening / Closing
ArgumentInterpreter.QUOTES = {
'"': '"',
'': '',
'': '',
'“': '”',
'„': '‟',
'⹂': '⹂',
'「': '」',
'『': '』',
'〝': '〞',
'﹁': '﹂',
'﹃': '﹄',
'': '',
'「': '」',
'«': '»',
'': '',
'《': '》',
'〈': '〉',
};
ArgumentInterpreter.ALL_QUOTES = Object.keys(ArgumentInterpreter.QUOTES)
.map(i => ArgumentInterpreter.QUOTES[i])
.concat(Object.keys(ArgumentInterpreter.QUOTES));
ArgumentInterpreter.StringIterator = StringIterator;
module.exports = ArgumentInterpreter;

121
src/structures/Command.js Normal file
View file

@ -0,0 +1,121 @@
const Util = require('../util');
const config = require('config');
/**
* A command in the bot.
*/
class Command {
/**
* @param {TrelloBot} client
*/
constructor(client) {
this.client = client;
this.subCommands = {};
}
/**
* @private
*/
_preload() {
if (!this.preload() && this.client.config.debug)
this.client.cmds.logger.info('Preloading command', this.name);
}
/**
* The function executed while loading the command into the command handler.
*/
preload() {
return true;
}
/**
* @private
* @param {Message} message
* @param {Object} opts
*/
async _exec(message, opts) {
// Check minimum arguments
if (this.options.minimumArgs > 0 && opts.args.length < this.options.minimumArgs)
return message.channel.createMessage(
`${this.options.minimumArgsMessage}\nUsage: ${config.prefix}${this.name}${
this.metadata.usage ?
` \`${this.metadata.usage}\`` : ''}`);
// Check commmand permissions
if (this.options.permissions.length)
for (const i in this.options.permissions) {
const perm = this.options.permissions[i];
if (!Util.CommandPermissions[perm])
throw new Error(`Invalid command permission "${perm}"`);
if (!Util.CommandPermissions[perm](this.client, message, opts))
return message.channel.createMessage({
attach: 'I need the permission `Attach Files` to use this command!',
embed: 'I need the permission `Embed Links` to use this command!',
emoji: 'I need the permission `Use External Emojis` to use this command!',
elevated: 'Only the elevated users of the bot can use this command!',
curator: `This command requires you to have the "${
message.guild.roles.get(config.curatorRoleID).name}" role!`,
admin: `This command requires you to have the "${
message.guild.roles.get(config.adminRoleID).name}" role!`,
curatorOrAdmin: `This command requires you to have the "${
message.guild.roles.get(config.curatorRoleID).name}" or "${
message.guild.roles.get(config.adminRoleID).name}" roles!`,
guild: 'This command must be ran in a guild!',
}[perm]);
}
// Process cooldown
if (!this.cooldownAbs || await this.client.cmds.processCooldown(message, this)) {
await this.exec(message, opts);
} else {
const cd = await this.client.db.hget(`cooldowns:${message.author.id}`, this.name);
return message.channel.createMessage(
`:watch: This command is on cooldown! Wait ${
Math.ceil(this.cooldownAbs - (Date.now() - cd))} second(s) before doing this again!`);
}
}
// eslint-disable-next-line no-empty-function, no-unused-vars
exec(Message, opts) { }
/**
* The options for the command
* @type {Object}
*/
get options() {
const options = {
aliases: [],
cooldown: 2,
listed: true,
minimumArgs: 0,
permissions: [],
minimumArgsMessage: 'Not enough arguments!',
};
Object.assign(options, this._options);
return options;
}
/**
* @private
*/
_options() { return {}; }
/**
* The cooldown in milliseconnds
* @returns {number}
*/
get cooldownAbs() { return this.options.cooldown * 1000; }
/**
* The metadata for the command
* @return {Object}
*/
get metadata() {
return {
category: 'Misc.',
};
}
}
module.exports = Command;

View file

@ -0,0 +1,102 @@
const Paginator = require('./Paginator');
const lodash = require('lodash');
/**
* A generic pager that shows a list of items
*/
class GenericPager extends Paginator {
/**
* @param {TrelloBot} client The client to use
* @param {Message} message The user's message to read permissions from
* @param {Object} options The options for the pager
* @param {Array} options.items The items the paginator will display
* @param {number} [options.itemsPerPage=15] How many items a page will have
* @param {Function} [options.display] The function that will be used to display items on the prompt
* @param {Object} [options.embedExtra] The embed object to add any extra embed elements to the prompt
* @param {string} [options.itemTitle='words.item.many'] The title to use for the items
* @param {string} [options.header] The text to show above the prompt
* @param {string} [options.footer] The text to show below the prompt
*/
constructor(client, message, {
items = [], itemsPerPage = 15,
display = item => item.toString(),
embedExtra = {}, itemTitle = 'words.item.many',
header = null, footer = null
} = {}) {
super(client, message, { items, itemsPerPage });
this.displayFunc = display;
this.embedExtra = embedExtra;
this.itemTitle = itemTitle;
this.header = header;
this.footer = footer;
}
/**
* Whether or not this instance can use embed
* @returns {boolean}
*/
canEmbed() {
return this.message.channel.type === 1 ||
this.message.channel.permissionsOf(this.client.user.id).has('embedLinks');
}
/**
* Updates the current message
* @returns {Promise}
*/
updateMessage() {
return this.message.edit(this.currentMessage).catch(() => {});
}
/**
* The message for the current page
* @type {Object|string}
*/
get currentMessage() {
const displayPage = this.page.map((item, index) =>
this.displayFunc(item, index, ((this.pageNumber - 1) * this.itemsPerPage) + index));
if (this.canEmbed()) {
const embed = lodash.defaultsDeep({
title: `${this.itemTitle} ` +
`(${this.items.length}, Page ${this.pageNumber}/${this.maxPages})`,
description: this.header || undefined,
footer: this.footer ? { text: this.footer } : undefined,
fields: []
}, this.embedExtra, { color: this.client.config.embedColor });
embed.fields.push({
name: '*List Prompt*',
value: displayPage.join('\n')
});
return { embed };
} else {
const top = `${this.itemTitle} ` +
`(${this.items.length}, Page ${this.pageNumber}/${this.maxPages})`;
const lines = '─'.repeat(top.length);
return (this.header || '') + '```prolog\n' + `${top}\n` + `${lines}\n` +
displayPage.join('\n') + `${lines}\`\`\`` + (this.footer || '');
}
}
/**
* Starts the reaction collector and pagination
* @param {string} channelID The channel to post the new message to
* @param {string} userID The user's ID that started the process
* @param {number} timeout
*/
async start(channelID, userID, timeout) {
this.message = await this.client.createMessage(channelID, this.currentMessage);
return super.start(userID, timeout);
}
/**
* @private
*/
_change() {
this.updateMessage().catch(() => this.collector.end());
this.emit('change', this.pageNumber);
}
}
module.exports = GenericPager;

View file

@ -0,0 +1,141 @@
const GenericPager = require('./GenericPager');
const Paginator = require('./Paginator');
const lodash = require('lodash');
const fuzzy = require('fuzzy');
/**
* A generic pager that shows a list of items
*/
class GenericPrompt {
/**
* @param {TrelloBot} client The client to use
* @param {Message} message The user's message to read permissions from
* @param {Object} pagerOptions The options for the pager
*/
constructor(client, message, pagerOptions = {}) {
this.client = client;
this.message = message;
this.pagerOptions = pagerOptions;
this.displayFunc = pagerOptions.display || ((item) => item.toString());
// Override some pager options
this.pagerOptions.display = (item, i, ai) => `${ai + 1}. ${this.displayFunc(item, i, ai)}`;
this.pagerOptions.header = pagerOptions.header || 'Type the number of the item you want to use.';
this.pagerOptions.footer = (pagerOptions.footer ? pagerOptions.footer + '\n\n' : '') +
'Typing "cancel" will close this prompt.';
this.pagerOptions.embedExtra = this.pagerOptions.embedExtra || {};
this.pagerOptions.embedExtra.author = {
name: `${message.author.username}#${message.author.discriminator}`,
icon_url: message.author.avatarURL || message.author.defaultAvatarURL
};
this.pager = new GenericPager(client, message, this.pagerOptions);
this.halt = null;
}
/**
* Starts the prompt
* @param {string} channelID The channel to post the new message to
* @param {string} userID The user's ID that started the process
* @param {number} timeout
*/
async choose(channelID, userID, timeout) {
if (this.pager.items.length === 0)
return null;
else if (this.pager.items.length === 1)
return this.pager.items[0];
await this.pager.start(channelID, userID, timeout);
this.halt = this.client.messageAwaiter.createHalt(channelID, userID, timeout);
// Sync timeouts
if (this.pager.collector)
this.pager.collector.restart();
this.halt.restart();
return new Promise(resolve => {
let foundItem = null;
this.halt.on('message', nextMessage => {
if (this.pager.canManage())
nextMessage.delete().catch(() => {});
if (GenericPrompt.CANCEL_TRIGGERS.includes(nextMessage.content.toLowerCase())) {
foundItem = { _canceled: true };
this.halt.end();
}
const chosenIndex = parseInt(nextMessage.content);
if (chosenIndex <= 0) return;
const chosenItem = this.pager.items[chosenIndex - 1];
if (chosenItem !== undefined) {
foundItem = chosenItem;
this.halt.end();
}
});
this.halt.on('end', () => {
// In case the halt ends before reactions are finished coming up
this.pager.reactionsCleared = true;
if (this.pager.collector)
this.pager.collector.end();
this.pager.message.delete().catch(() => {});
if (foundItem && foundItem._canceled)
foundItem = null;
else if (foundItem === null)
this.pager.message.channel.createMessage(
`<@${userID}>, Your last prompt was timed out.`).catch(() => {});
resolve(foundItem);
});
if (this.pager.collector)
this.pager.collector.on('reaction', emoji => {
if (Paginator.STOP === emoji.name) {
foundItem = { _canceled: true };
this.halt.end();
}
});
});
}
/**
* Filters the items into a search and prompts results.
* @param {string} query The term to search for
* @param {Object} options The options passed on to {@see #choose} .
* @param {string} options.channelID The channel to post the new message to
* @param {string} options.userID The user's ID that started the process
* @param {number} options.timeout
* @param {string|Function} [key='name'] The path to use for searches
*/
async search(query, { channelID, userID, timeout }, key = 'name') {
if (!query)
return this.choose(channelID, userID, timeout);
const results = fuzzy.filter(query, this.pager.items, {
extract: item => {
if (typeof key === 'string')
return lodash.get(item, key);
else if (typeof key === 'function')
return key(item);
else if (key === null)
return item;
}
}).map(el => el.original);
if (!results.length)
return { _noresults: true };
const tempItems = this.pager.items;
this.pager.items = results;
const result = await this.choose(channelID, userID, timeout);
this.pager.items = tempItems;
return result;
}
}
GenericPrompt.CANCEL_TRIGGERS = [
'c', 'cancel', 's', 'stop'
];
module.exports = GenericPrompt;

61
src/structures/Halt.js Normal file
View file

@ -0,0 +1,61 @@
const EventEmitter = require('eventemitter3');
/**
* A class that represents a message halt
*/
class Halt extends EventEmitter {
constructor(messageAwaiter, timeout) {
super();
this.messageAwaiter = messageAwaiter;
this.timeout = timeout;
this.interval = null;
this.ended = false;
this.messages = new Map();
this._endBind = this._end.bind(this);
this._start();
}
/**
* Restarts the halt.
* @param {number} [timeout] The new timeout to halt by
*/
restart(timeout) {
if (this.ended) return;
clearTimeout(this.interval);
this.interval = setTimeout(this._endBind, timeout || this.timeout);
}
/**
* Ends the halt.
*/
end() {
if (this.ended) return;
clearTimeout(this.interval);
this._end();
}
/**
* @private
*/
_onMessage(message) {
this.messages.set(message.id, message);
this.emit('message', message);
}
/**
* @private
*/
_start() {
this.interval = setTimeout(this._endBind, this.timeout);
}
/**
* @private
*/
_end() {
this.ended = true;
this.emit('end');
}
}
module.exports = Halt;

View file

@ -0,0 +1,145 @@
const EventEmitter = require('eventemitter3');
const GenericPager = require('./GenericPager');
const Paginator = require('./Paginator');
const lodash = require('lodash');
/**
* A prompt that allows users to toggle multiple values
*/
class MultiSelect extends EventEmitter {
/**
* @param {TrelloBot} client The client to use
* @param {Message} message The user's message to read permissions from
* @param {Object} options The options for the multi-select
* @param {string|Array<string>} options.path The path of the boolean
* @param {string} [options.checkEmoji] The emoji that resembles true
* @param {string} [options.uncheckEmoji] The emoji that resembles false
* @param {Object} pagerOptions The options for the pager
*/
constructor(client, message, { path, checkEmoji = '☑️', uncheckEmoji = '⬜' }, pagerOptions = {}) {
super();
this.client = client;
this.message = message;
this.pagerOptions = pagerOptions;
this.displayFunc = pagerOptions.display || ((item) => item.toString());
this.boolPath = path;
// Override some pager options
this.pagerOptions.display = (item, i, ai) => {
const value = lodash.get(item, this.boolPath);
return `\`[${ai + 1}]\` ${value ? checkEmoji : uncheckEmoji} ${this.displayFunc(item, i, ai)}`;
};
this.pagerOptions.header = pagerOptions.header || 'Type the number of the item to toggle its value.';
this.pagerOptions.footer = (pagerOptions.footer ? pagerOptions.footer + '\n\n' : '') +
'Type "save" to save the selection, otherwise type "cancel" to exit.';
this.pagerOptions.embedExtra = this.pagerOptions.embedExtra || {};
this.pagerOptions.embedExtra.author = {
name: `${message.author.username}#${message.author.discriminator}`,
icon_url: message.author.avatarURL || message.author.defaultAvatarURL
};
this.pager = new GenericPager(client, message, this.pagerOptions);
this.halt = null;
}
/**
* Starts the prompt
* @param {string} channelID The channel to post the new message to
* @param {string} userID The user's ID that started the process
* @param {number} timeout
*/
async start(channelID, userID, timeout) {
if (this.pager.items.length === 0)
return null;
await this.pager.start(channelID, userID, timeout);
// React with done
if (this.pager.collector)
await this.pager.message.addReaction(MultiSelect.DONE);
this.halt = this.client.messageAwaiter.createHalt(channelID, userID, timeout);
// Sync timeouts
if (this.pager.collector)
this.pager.collector.restart();
this.halt.restart();
return new Promise(resolve => {
let result = null;
this.halt.on('message', nextMessage => {
if (this.pager.canManage())
nextMessage.delete();
if (MultiSelect.CANCEL_TRIGGERS.includes(nextMessage.content.toLowerCase())) {
result = { _canceled: true };
this.halt.end();
} else if (MultiSelect.DONE_TRIGGERS.includes(nextMessage.content.toLowerCase())) {
result = this.pager.items;
this.halt.end();
}
// Find and update item
const chosenIndex = parseInt(nextMessage.content);
if (chosenIndex <= 0) return;
let chosenItem = this.pager.items[chosenIndex - 1];
if (chosenItem !== undefined) {
const oldItem = chosenItem;
chosenItem = lodash.set(chosenItem, this.boolPath, !lodash.get(chosenItem, this.boolPath));
this.emit('update', oldItem, chosenItem, chosenIndex - 1);
this.pager.items.splice(chosenIndex - 1, 1, chosenItem);
this.pager.updateMessage();
}
this.halt.restart();
if (this.pager.collector)
this.pager.collector.restart();
});
this.halt.on('end', () => {
// In case the halt ends before reactions are finished coming up
this.pager.reactionsCleared = true;
if (this.pager.collector)
this.pager.collector.end();
this.pager.message.delete().catch(() => {});
if (result && result._canceled) {
this.pager.message.channel.createMessage(
`<@${userID}>, Your last prompt was canceled. All changes have been lost.`);
result = null;
} else if (result === null)
this.pager.message.channel.createMessage(
`<@${userID}>, Your last prompt was timed out. All changes have been lost.`);
resolve(result);
});
if (this.pager.collector) {
this.pager.collector.on('clearReactions', () => {
if (!this.pager.canManage())
this.pager.message.removeReaction(MultiSelect.DONE).catch(() => {});
});
this.pager.collector.on('reaction', emoji => {
if (Paginator.STOP === emoji.name) {
result = { _canceled: true };
this.halt.end();
} else if (MultiSelect.DONE === emoji.name) {
result = this.pager.items;
this.halt.end();
}
this.halt.restart();
});
}
});
}
}
MultiSelect.DONE = '✅';
MultiSelect.CANCEL_TRIGGERS = [
'cancel', 'stop', 'quit'
];
MultiSelect.DONE_TRIGGERS = [
'save', 'finish', 'done'
];
module.exports = MultiSelect;

184
src/structures/Paginator.js Normal file
View file

@ -0,0 +1,184 @@
const EventEmitter = require('eventemitter3');
/**
* A class that creates a paging process for messages
*/
class Paginator extends EventEmitter {
/**
* @param {TrelloBot} client The client to use
* @param {Message} message The user's message to read permissions from
* @param {Object} options The options for the paginator
* @param {Array} options.items The items the paginator will display
* @param {number} [options.itemsPerPage=15] How many items a page will have
*/
constructor(client, message, { items = [], itemsPerPage = 15 } = {}) {
super();
this.messageAwaiter = client.messageAwaiter;
this.client = client;
this.collector = null;
this.items = items;
this.message = message;
this.itemsPerPage = itemsPerPage;
this.pageNumber = 1;
this.reactionsCleared = false;
this._reactBind = this._react.bind(this);
}
/**
* All pages in the paginator
* @type {Array<Array>}
*/
get pages() {
const pages = [];
let i, j, page;
for (i = 0, j = this.items.length; i < j; i += this.itemsPerPage) {
page = this.items.slice(i, i + this.itemsPerPage);
pages.push(page);
}
return pages;
}
/**
* The current page
* @type {Array}
*/
get page() {
return this.pages[this.pageNumber - 1];
}
/**
* The current page number
* @type {number}
*/
get maxPages() {
return Math.ceil(this.items.length / this.itemsPerPage);
}
/**
* Changes the page number
* @param {number} newPage The page to change to
*/
toPage(newPage) {
if (Number(newPage)){
this.pageNumber = Number(newPage);
if (this.pageNumber < 1) this.pageNumber = 1;
if (this.pageNumber > this.maxPages) this.pageNumber = this.maxPages;
}
return this;
}
/**
* Moves to the next page
*/
nextPage() {
return this.toPage(this.pageNumber + 1);
}
/**
* Moves to the previous page
*/
previousPage() {
return this.toPage(this.pageNumber - 1);
}
/**
* Whether or not this instance can paginate
* @returns {boolean}
*/
canPaginate() {
return this.message.channel.type === 1 ||
this.message.channel.permissionsOf(this.client.user.id).has('addReactions');
}
/**
* Whether or not this instance can manage messages
* @returns {boolean}
*/
canManage() {
return this.message.channel.type !== 1 &&
this.message.channel.permissionsOf(this.client.user.id).has('manageMessages');
}
/**
* Starts the reaction collector and pagination
* @param {string} userID The user's ID that started the process
* @param {number} timeout
*/
async start(userID, timeout) {
this.reactionsCleared = false;
if (this.maxPages > 1 && this.canPaginate()) {
try {
await Promise.all([
this.message.addReaction(Paginator.PREV),
this.message.addReaction(Paginator.STOP),
this.message.addReaction(Paginator.NEXT),
]);
this.collector = this.messageAwaiter.createReactionCollector(this.message, userID, timeout);
this._hookEvents();
} catch (e) {
return this.clearReactions();
}
}
}
/**
* Clears reaction from the message
*/
async clearReactions() {
if (!this.reactionsCleared) {
this.reactionsCleared = true;
this.emit('clearReactions');
try {
if (!this.canManage())
await Promise.all([
this.message.removeReaction(Paginator.NEXT).catch(() => {}),
this.message.removeReaction(Paginator.STOP).catch(() => {}),
this.message.removeReaction(Paginator.PREV).catch(() => {})
]);
else
await this.message.removeReactions().catch(() => {});
} catch (e) {
// Do nothing
}
}
}
/**
* @private
*/
_hookEvents() {
this.collector.on('reaction', this._react.bind(this));
this.collector.once('end', this.clearReactions.bind(this));
}
/**
* @private
*/
_change() {
this.emit('change', this.pageNumber);
}
/**
* @private
*/
_react(emoji, userID) {
const oldPage = this.pageNumber;
if (Paginator.PREV == emoji.name)
this.previousPage();
else if (Paginator.NEXT == emoji.name)
this.nextPage();
else if (Paginator.STOP == emoji.name)
this.collector.end();
if (this.pageNumber !== oldPage)
this._change();
if ([Paginator.PREV, Paginator.STOP, Paginator.NEXT].includes(emoji.name) && this.canManage())
this.message.removeReaction(emoji.name, userID).catch(() => {});
this.collector.restart();
}
}
Paginator.PREV = '⬅️';
Paginator.STOP = '🛑';
Paginator.NEXT = '➡️';
module.exports = Paginator;

View file

@ -0,0 +1,60 @@
const EventEmitter = require('eventemitter3');
/**
* A class that collects reactions from a message
*/
class ReactionCollector extends EventEmitter {
constructor(messageAwaiter, timeout) {
super();
this.messageAwaiter = messageAwaiter;
this.timeout = timeout;
this.interval = null;
this.ended = false;
this._endBind = this._end.bind(this);
this._start();
}
/**
* Restarts the timeout.
* @param {number} [timeout] The new timeout to halt by
*/
restart(timeout) {
if (this.ended) return;
clearTimeout(this.interval);
this.interval = setTimeout(this._endBind, timeout || this.timeout);
}
/**
* Ends the collection.
*/
end() {
if (this.ended) return;
clearTimeout(this.interval);
this._end();
}
/**
* @private
*/
_onReaction(emoji, userID) {
this.emit('reaction', emoji, userID);
this.restart();
}
/**
* @private
*/
_start() {
this.interval = setTimeout(this._endBind, this.timeout);
}
/**
* @private
*/
_end() {
this.ended = true;
this.emit('end');
}
}
module.exports = ReactionCollector;

44
src/structures/SubMenu.js Normal file
View file

@ -0,0 +1,44 @@
const GenericPrompt = require('./GenericPrompt');
class SubMenu {
/**
* @param {TrelloBot} client The client to use
* @param {Message} message The user's message to read permissions from
* @param {Object} pagerOptions The options for the pager
*/
constructor(client, message, pagerOptions = {}) {
this.client = client;
this.message = message;
this.pagerOptions = pagerOptions;
this.prompt = new GenericPrompt(client, message, this.pagerOptions);
}
/**
* Starts the menu
* @param {string} channelID The channel to post the new message to
* @param {string} userID The user's ID that started the process
* @param {Array} menu
* @param {number} timeout
*/
async start(channelID, userID, name, menu = [], timeout = 30000) {
/*
menu = [
{
names: ['a', 'b'],
title: 'Title',
exec: (client) => ...
}
]
*/
const command = menu.find(command => command.names.includes(name ? name.toLowerCase() : null));
if (!command) {
this.prompt.pager.items = menu;
this.prompt.pager.displayFunc = (item, _, ai) => `\`[${ai + 1}]\` ${item.title}`;
const chosenCommand = await this.prompt.choose(channelID, userID, timeout);
if (!chosenCommand) return;
return chosenCommand.exec(this.client);
} else return command.exec(this.client);
}
}
module.exports = SubMenu;

258
src/util.js Normal file
View file

@ -0,0 +1,258 @@
const fetch = require('node-fetch');
const config = require('config');
/**
* Represents the utilities for the bot
* @typedef {Object} Util
*/
const Util = module.exports = {};
/**
* Iterates through each key of an object
* @memberof Util.
*/
Util.keyValueForEach = (obj, func) => Object.keys(obj).map(key => func(key, obj[key]));
/**
* @memberof Util.
* @deprecated
*/
Util.sliceKeys = (obj, f) => {
const newObject = {};
Util.keyValueForEach(obj, (k, v) => {
if (f(k, v)) newObject[k] = v;
});
return newObject;
};
/**
* Converts a number into a 00:00:00 format
* @memberof Util.
*/
Util.toHHMMSS = string => {
const sec_num = parseInt(string, 10);
let hours = Math.floor(sec_num / 3600);
let minutes = Math.floor((sec_num - (hours * 3600)) / 60);
let seconds = sec_num - (hours * 3600) - (minutes * 60);
if (hours < 10) {hours = '0' + hours;}
if (minutes < 10) {minutes = '0' + minutes;}
if (seconds < 10) {seconds = '0' + seconds;}
const time = hours + ':' + minutes + ':' + seconds;
return time;
};
/**
* @memberof Util.
* @deprecated
*/
Util.formatNumber = num => num.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,');
/**
* Flattens a JSON object
* @memberof Util.
* @see https://stackoverflow.com/a/19101235/6467130
*/
Util.flattenObject = (data) => {
const result = {};
function recurse (cur, prop) {
if (Object(cur) !== cur) {
result[prop] = cur;
} else if (Array.isArray(cur)) {
const l = cur.length;
for (let i = 0; i < l; i++)
recurse(cur[i], prop + '[' + i + ']');
if (l == 0)
result[prop] = [];
} else {
let isEmpty = true;
for (const p in cur) {
isEmpty = false;
recurse(cur[p], prop ? prop + '.' + p : p);
}
if (isEmpty && prop)
result[prop] = {};
}
}
recurse(data, '');
return result;
};
/**
* Randomness generator
* @memberof Util.
*/
Util.Random = {
int(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
},
bool() {
return Util.Random.int(0, 1) === 1;
},
array(array) {
return array[Util.Random.int(0, array.length - 1)];
},
shuffle(array) {
return array.sort(() => Math.random() - 0.5);
},
};
/**
* Prefix-related functions
* @memberof Util.
*/
Util.Prefix = {
regex(client, prefixes = null) {
if (!prefixes)
prefixes = [client.config.prefix];
return new RegExp(`^((?:<@!?${client.user.id}>|${
prefixes.map(prefix => Util.Escape.regex(prefix)).join('|')})\\s?)(\\n|.)`, 'i');
},
strip(message, client, prefixes) {
return message.content.replace(
Util.Prefix.regex(client, prefixes), '$2').replace(/\s\s+/g, ' ').trim();
},
};
/**
* Commonly used regex patterns
* @memberof Util.
* @deprecated
*/
Util.Regex = {
escape: /[-/\\^$*+?.()|[\]{}]/g,
url: /https?:\/\/(-\.)?([^\s/?.#-]+\.?)+(\/[^\s]*)?/gi,
userMention: /<@!?(\d+)>/gi,
webhookURL:
/(?:https?:\/\/)(?:canary\.|ptb\.|)discord(?:app)?\.com\/api\/webhooks\/(\d{17,18})\/([\w-]{68})/
};
/**
* Discord.JS's method of escaping characters
* @memberof Util.
*/
Util.Escape = {
regex(s) {
return s.replace(Util.Regex.escape, '\\$&');
}
};
/**
* Command permission parsers
* @memberof Util.
*/
Util.CommandPermissions = {
attach: (client, message) => message.channel.type === 1 ||
message.channel.permissionsOf(client.user.id).has('attachFiles'),
embed: (client, message) => message.channel.type === 1 ||
message.channel.permissionsOf(client.user.id).has('embedLinks'),
emoji: (client, message) => message.channel.type === 1 ||
message.channel.permissionsOf(client.user.id).has('externalEmojis'),
guild: (_, message) => !!message.guildID,
elevated: (client, message) => client.config.elevated.includes(message.author.id),
curator: (client, message) => {
if (!message.guildID) return false;
// Server owner or elevated users
if (message.channel.guild.ownerID == message.author.id ||
Util.CommandPermissions.elevated(client, message)) return true;
return message.member.roles.includes(config.curatorRoleID);
},
admin: (client, message) => {
if (!message.guildID) return false;
// Server owner or elevated users
if (message.channel.guild.ownerID == message.author.id ||
Util.CommandPermissions.elevated(client, message)) return true;
return message.member.roles.includes(config.adminRoleID);
},
curatorOrAdmin: (client, message) => {
if (!message.guildID) return false;
// Server owner or elevated users
if (message.channel.guild.ownerID == message.author.id ||
Util.CommandPermissions.elevated(client, message)) return true;
return message.member.roles.includes(config.curatorRoleID) ||
message.member.roles.includes(config.adminRoleID);
},
};
/**
* Creates a module that makes emoji fallbacks
* @memberof Util.
*/
Util.emojiFallback = ({ message, client }) => {
return (id, fallback, animated = false) => {
if (Util.CommandPermissions.emoji(client, message))
return `<${animated ? 'a' : ''}:_:${id}>`;
else return fallback;
};
};
/**
* Cuts off text to a limit
* @memberof Util.
* @param {string} text
* @param {number} limit
*/
Util.cutoffText = (text, limit = 2000) => {
return text.length > limit ? text.slice(0, limit - 1) + '…' : text;
};
/**
* Cuts off an array of text to a limit
* @memberof Util.
* @param {Array<string>} texts
* @param {number} limit
* @param {number} rollbackAmount Amount of items to roll back when the limit has been hit
*/
Util.cutoffArray = (texts, limit = 2000, rollbackAmount = 1, paddingAmount = 1) => {
let currLength = 0;
for (let i = 0; i < texts.length; i++) {
const text = texts[i];
currLength += text.length + paddingAmount;
if (currLength > limit) {
const clampedRollback = rollbackAmount > i ? i : rollbackAmount;
return texts.slice(0, (i + 1) - clampedRollback);
}
}
return texts;
};
/**
* Hastebin-related functions
* @memberof Util.
*/
Util.Hastebin = {
async autosend(content, message) {
if (content.length > 2000) {
const haste = await Util.Hastebin.post(content);
if (haste.ok)
return message.channel.createMessage(`<https://hastebin.com/${haste.key}.md>`);
else
return message.channel.createMessage({}, {
name: 'output.txt',
file: new Buffer(content)
});
} else return message.channel.createMessage(content);
},
/**
* Post text to hastebin
* @param {string} content - The content to upload
*/
async post(content) {
const haste = await fetch('https://hastebin.com/documents', {
method: 'POST',
body: content
});
if (haste.status >= 400)
return {
ok: false,
status: haste.status
};
else {
const hasteInfo = await haste.json();
return {
ok: true,
key: hasteInfo.key
};
}
}
};

1032
yarn.lock Normal file

File diff suppressed because it is too large Load diff