mirror of
https://github.com/LBRYFoundation/curate.git
synced 2025-08-23 09:27:24 +00:00
Initial command base
This commit is contained in:
parent
a14f5e3b9c
commit
dab194f8f5
35 changed files with 3513 additions and 2 deletions
2
.eslintignore
Normal file
2
.eslintignore
Normal file
|
@ -0,0 +1,2 @@
|
|||
.Config
|
||||
Config/
|
79
.eslintrc.json
Normal file
79
.eslintrc.json
Normal 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
2
.gitattributes
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
|
||||
* text=auto eol=lf
|
12
.github/dependabot.yml
vendored
Normal file
12
.github/dependabot.yml
vendored
Normal 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
32
.github/workflows/eslint.yml
vendored
Normal 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
5
.gitignore
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
config/default.js
|
||||
config/production.js
|
||||
package-lock.json
|
||||
.idea/
|
21
LICENSE
Normal file
21
LICENSE
Normal 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.
|
27
README.md
27
README.md
|
@ -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
40
config/_default.js
Normal 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
33
package.json
Normal 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
9
pm2.json
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"apps": [
|
||||
{
|
||||
"name": "LBRYCurate",
|
||||
"script": "node",
|
||||
"args": "src/bot.js"
|
||||
}
|
||||
]
|
||||
}
|
148
src/bot.js
Normal file
148
src/bot.js
Normal 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
100
src/commandloader.js
Normal 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
101
src/commands/help.js
Normal 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]'
|
||||
}; }
|
||||
};
|
38
src/commands/owner/asynceval.js
Normal file
38
src/commands/owner/asynceval.js
Normal 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`'
|
||||
}; }
|
||||
};
|
35
src/commands/owner/eval.js
Normal file
35
src/commands/owner/eval.js
Normal 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>'
|
||||
}; }
|
||||
};
|
34
src/commands/owner/exec.js
Normal file
34
src/commands/owner/exec.js
Normal 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> …'
|
||||
}; }
|
||||
};
|
23
src/commands/owner/reload.js
Normal file
23
src/commands/owner/reload.js
Normal 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.'
|
||||
}; }
|
||||
};
|
46
src/commands/owner/reloadone.js
Normal file
46
src/commands/owner/reloadone.js
Normal 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] …'
|
||||
}; }
|
||||
};
|
22
src/commands/owner/restart.js
Normal file
22
src/commands/owner/restart.js
Normal 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
27
src/commands/ping.js
Normal 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
145
src/database.js
Normal 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
50
src/events.js
Normal 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
154
src/messageawaiter.js
Normal 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;
|
182
src/structures/ArgumentInterpreter.js
Normal file
182
src/structures/ArgumentInterpreter.js
Normal 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
121
src/structures/Command.js
Normal 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;
|
102
src/structures/GenericPager.js
Normal file
102
src/structures/GenericPager.js
Normal 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;
|
141
src/structures/GenericPrompt.js
Normal file
141
src/structures/GenericPrompt.js
Normal 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
61
src/structures/Halt.js
Normal 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;
|
145
src/structures/MultiSelect.js
Normal file
145
src/structures/MultiSelect.js
Normal 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
184
src/structures/Paginator.js
Normal 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;
|
60
src/structures/ReactionCollector.js
Normal file
60
src/structures/ReactionCollector.js
Normal 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
44
src/structures/SubMenu.js
Normal 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
258
src/util.js
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
Loading…
Add table
Reference in a new issue