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
|
||||||
LBRY Curation bot for discord interaction
|
|
||||||
|
## 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