src/bot/index.js
'use babel';
'use strict';
import Discord from 'discord.js';
import { LocalStorage } from 'node-localstorage';
import winston from 'winston';
import request from 'request-promise-native';
import semver from 'semver';
import Config from './config';
import Permissions from './permissions';
import Util from './util';
import Registry from '../commands/registry';
import Dispatcher from '../commands/dispatcher';
import Module from '../commands/module';
import CommandBuilder from '../commands/builder';
import Setting from '../storage/models/setting';
import SettingStorage from '../storage/settings';
import ModRoleStorage from '../storage/mod-roles';
import AllowedChannelStorage from '../storage/allowed-channels';
import HelpCommand from '../commands/info/help';
import AboutCommand from '../commands/info/about';
import ListModulesCommand from '../commands/modules/list';
import ToggleModuleCommand from '../commands/modules/toggle';
import EnableModuleCommand from '../commands/modules/enable';
import DisableModuleCommand from '../commands/modules/disable';
import ListModRolesCommand from '../commands/mod-roles/list';
import AddModRoleCommand from '../commands/mod-roles/add';
import DeleteModRoleCommand from '../commands/mod-roles/delete';
import ClearModRolesCommand from '../commands/mod-roles/clear';
import ListAllowedChannelsCommand from '../commands/channels/list-allowed';
import AllowChannelCommand from '../commands/channels/allow';
import DisallowChannelCommand from '../commands/channels/disallow';
import ClearAllowedChannelsCommand from '../commands/channels/clear-allowed';
import PrefixCommand from '../commands/util/prefix';
import EvalCommand from '../commands/util/eval';
import ShowBlacklistCommand from '../commands/blacklist/show';
import BlacklistUserCommand from '../commands/blacklist/user';
import BlacklistGuildCommand from '../commands/blacklist/guild';
/** A Discord bot that has its own command registry, storage, utilities, etc. */
export default class Bot {
/** @param {ConfigObject} config - The configuration to use */
constructor(config) {
/** @type {Client} */
this.client = null;
/** @type {CommandDispatcher} */
this.dispatcher = null;
/** @type {BotConfig} */
this.config = new Config(config);
/** @type {BotPermissions} */
this.permissions = null;
/** @type {BotUtil} */
this.util = null;
/** @type {LocalStorage} */
this.localStorage = null;
/**
* @type {Object}
* @property {SettingStorage} settings
* @property {ModRoleStorage} modRoles
* @property {AllowedChannelStorage} allowedChannels
*/
this.storage = {
settings: null,
modRoles: null,
allowedChannels: null
};
/** @type {Object} */
this.evalObjects = {};
}
/**
* Instantiates all bot classes and storages, and creates the client
* @return {Client} The bot client
*/
createClient() {
if(this.client) throw new Error('Client has already been created.');
const config = this.config.values;
if(config.selfbot) config.clientOptions.bot = false;
// Verify some stuff
if(!config.token) throw new Error('"token" must be specified on the config.');
if(!config.name) throw new Error('"name" must be specified on the config.');
if(!config.version) throw new Error('"version" must be specified on the config.');
// Output safe config
const debugConfig = Object.assign({}, config);
if(debugConfig.token) debugConfig.token = '--snip--';
if(debugConfig.carbonKey) debugConfig.carbonKey = '--snip--';
if(debugConfig.bdpwKey) debugConfig.bdpwKey = '--snip--';
for(const key of Object.keys(debugConfig)) if(key.length === 1 || key.includes('-')) delete debugConfig[key];
this.logger.debug('Configuration:', debugConfig);
// Create client and bot classes
const clientOptions = Object.assign({}, defaultClientOptions, config.clientOptions);
const client = new Discord.Client(clientOptions);
this.client = client;
this.localStorage = new LocalStorage(config.storage);
this.storage.settings = new SettingStorage(this.localStorage, this.logger);
this.storage.modRoles = new ModRoleStorage(this.localStorage, this.logger);
this.storage.allowedChannels = new AllowedChannelStorage(this.localStorage, this.logger);
this.dispatcher = new Dispatcher(this);
this.permissions = new Permissions(client, this.storage.modRoles, this.storage.settings, this.config);
this.util = new Util(client, this.storage.settings, this.config);
this.logger.info('Client created.', clientOptions);
// Set up logging and the playing game text
client.on('error', err => { this.logger.error(err); });
client.on('warn', msg => { this.logger.warn(msg); });
client.on('debug', msg => { this.logger.debug(msg); });
client.on('disconnect', () => { this.logger.warn('Disconnected.'); });
client.on('reconnecting', () => { this.logger.warn('Reconnecting...'); });
client.on('guildCreate', guild => { this.logger.info(`Joined guild ${guild} (ID: ${guild.id}).`); });
client.on('guildDelete', guild => { this.logger.info(`Left guild ${guild} (ID: ${guild.id}).`); });
client.on('ready', () => {
this.logger.info(`Bot is ready; logged in as ${client.user.username}#${client.user.discriminator} (ID: ${client.user.id})`);
if(config.playingGame) client.user.setGame(config.playingGame);
});
// Set up command handling
const messageErr = err => { this.logger.error('Error while handling message. This may be an issue with GRAF.', err); };
client.on('message', message => {
this._logMessage(message);
this.dispatcher.handleMessage(message).catch(messageErr);
});
client.on('messageUpdate', (oldMessage, newMessage) => {
this._logMessage(newMessage, oldMessage);
this.dispatcher.handleMessage(newMessage, oldMessage).catch(messageErr);
});
// Set up guild blacklisting
client.on('guildCreate', guild => {
const guilds = this.storage.settings.getValue(null, 'blacklisted-guilds');
if(guilds && guilds.includes(guild.id)) {
this.logger.info('Guild is blacklisted; leaving.', { name: guild.name, id: guild.id });
guild.leave();
}
});
// Remove channels/roles from storages upon their deletion
client.on('channelDelete', channel => {
if(channel.guild && this.storage.allowedChannels.exists(channel.guild, channel.id)) {
this.logger.verbose('Removing orphaned allowed channel.', { guild: channel.guild ? channel.guild.id : null, channel: channel.id });
this.storage.allowedChannels.delete(channel);
}
});
client.on('roleDelete', role => {
if(this.storage.modRoles.exists(role.guild, role.id)) {
this.logger.verbose('Removing orphaned mod role.', { guild: role.guild.id, role: role.id });
this.storage.modRoles.delete(role);
}
});
// Fetch the owner
if(config.owner) {
client.once('ready', () => {
client.fetchUser(config.owner).catch((err) => { this.logger.error('Unable to fetch the owner user.', err); });
});
}
// Set up update checking
if(config.updateURL) {
client.once('ready', () => {
this._checkForUpdate();
if(config.updateCheck > 0) setInterval(this._checkForUpdate.bind(this), config.updateCheck * 60 * 1000);
});
}
// Set up Carbon guild count updates
if(config.carbonUrl && config.carbonKey) {
client.once('ready', this._sendCarbonStats.bind(this));
client.on('guildCreate', this._sendCarbonStats.bind(this));
client.on('guildDelete', this._sendCarbonStats.bind(this));
}
// Set up BDPW guild count updates
if(config.bdpwUrl && config.bdpwKey) {
client.once('ready', this._sendBDPWStats.bind(this));
client.on('guildCreate', this._sendBDPWStats.bind(this));
client.on('guildDelete', this._sendBDPWStats.bind(this));
}
// Log in
this.logger.info('Logging in with token...');
client.login(config.token).catch(err => {
this.logger.error('Failed to login.');
this.logger.error(err);
});
return client;
}
/**
* Create a command builder
* @param {CommandInfo} [info] - The command information
* @param {CommandBuilderFunctions} [funcs] - The command functions to set
* @return {CommandBuilder} The builder
*/
buildCommand(info = null, funcs = null) {
return new CommandBuilder(this, info, funcs);
}
/**
* Registers a single command to the bot's registry
* @param {Command|function} command - Either a Command instance, or a constructor for one
* @return {Bot} This bot
* @see {@link Bot#registerCommands}
*/
registerCommand(command) {
return this.registerCommands([command]);
}
/**
* Registers multiple commands to the bot's registry
* @param {Command[]|function[]} commands - An array of Command instances or constructors
* @return {Bot} This bot
*/
registerCommands(commands) {
if(!Array.isArray(commands)) throw new TypeError('Commands must be an array.');
for(let i = 0; i < commands.length; i++) if(typeof commands[i] === 'function') commands[i] = new commands[i](this);
this.registry.registerCommands(commands);
return this;
}
/**
* Registers a single module to the bot's registry
* @param {Module|function|string[]} module - A Module instance, a constructor, or an array of [ID, Name]
* @return {Bot} This bot
* @see {@link Bot#registerModules}
*/
registerModule(module) {
return this.registerModules([module]);
}
/**
* Registers multiple modules to the bot's registry
* @param {Module[]|function[]|Array[]} modules - An array of Module instances, constructors, or arrays of [ID, Name]
* @return {Bot} This bot
*/
registerModules(modules) {
if(!Array.isArray(modules)) throw new TypeError('Modules must be an array.');
for(let i = 0; i < modules.length; i++) {
if(typeof modules[i] === 'function') {
modules[i] = new modules[i](this);
} else if(Array.isArray(modules[i])) {
modules[i] = new Module(this, ...modules[i]);
} else if(!(modules[i] instanceof Module)) {
modules[i] = new Module(this, modules[i].id, modules[i].name, modules[i].commands);
}
}
this.registry.registerModules(modules);
return this;
}
/**
* Registers both the default modules and commands to the bot's registry
* @return {Bot} This bot
*/
registerDefaults() {
this.registerDefaultModules();
this.registerDefaultCommands();
return this;
}
/**
* Registers the default modules to the bot's registry
* @return {Bot} This bot
*/
registerDefaultModules() {
this.registerModules([
['info', 'Information'],
['mod-roles', 'Moderator roles'],
['channels', 'Channels'],
['util', 'Utility'],
['modules', 'Modules', true],
['blacklist', 'Blacklisting', true]
]);
return this;
}
/**
* Registers the default commands to the bot's registry
* @param {Object} [options] - Object specifying what commands to register
* @param {boolean} [options.about=true] - Whether or not to register the built-in about command
* @param {boolean} [options.modRoles=true] - Whether or not to register the built-in mod roles commands
* @param {boolean} [options.channels=true] - Whether or not to register the built-in channels commands
* @param {boolean} [options.blacklist=true] - Whether or not to register the built-in blacklist commands
* @return {Bot} This bot
*/
registerDefaultCommands({ about = true, modRoles = true, channels = true, blacklist = true } = {}) {
this.registerCommands([
HelpCommand,
PrefixCommand,
EvalCommand,
ListModulesCommand,
ToggleModuleCommand,
EnableModuleCommand,
DisableModuleCommand
]);
if(about) this.registerCommand(AboutCommand);
if(modRoles) {
this.registerCommands([
ListModRolesCommand,
AddModRoleCommand,
DeleteModRoleCommand,
ClearModRolesCommand
]);
}
if(channels) {
this.registerCommands([
ListAllowedChannelsCommand,
AllowChannelCommand,
DisallowChannelCommand,
ClearAllowedChannelsCommand
]);
}
if(blacklist) {
this.registerCommands([
ShowBlacklistCommand,
BlacklistUserCommand,
BlacklistGuildCommand
]);
}
return this;
}
/**
* Registers a single object to be usable by the eval command
* @param {string} key - The key for the object
* @param {Object} obj - The object
* @return {Bot} This bot
* @see {@link Bot#registerEvalObjects}
*/
registerEvalObject(key, obj) {
const registerObj = {};
registerObj[key] = obj;
return this.registerEvalObjects(registerObj);
}
/**
* Registers multiple objects to be usable by the eval command
* @param {Object} obj - An object of keys: values
* @return {Bot} This bot
*/
registerEvalObjects(obj) {
Object.assign(this.evalObjects, obj);
return this;
}
/** @type {CommandRegistry} */
get registry() {
if(!this._registry) this._registry = new Registry(this.logger);
return this._registry;
}
/** @type {Logger} */
get logger() {
if(!this._logger) {
const timestamp = () => {
const dt = new Date();
const min = dt.getUTCMinutes();
const sec = dt.getUTCSeconds();
const ms = dt.getUTCMilliseconds();
return `${dt.getUTCFullYear()}-${dt.getUTCMonth()}-${dt.getUTCDate()} `
+ `${dt.getUTCHours()}:${min >= 10 ? min : `0${min}`}:${sec >= 10 ? sec : `0${sec}`}.${ms >= 100 ? ms : ms > 10 ? `0${ms}` : `00${ms}`}`;
};
this._logger = new winston.Logger({
levels: {
error: 0,
warn: 1,
info: 2,
verbose: 3,
message: 4,
debug: 5
},
colors: {
error: 'red',
warn: 'yellow',
info: 'green',
verbose: 'blue',
message: 'cyan',
debug: 'magenta'
},
transports: [
new winston.transports.Console({
level: this.config.values.consoleLevel,
colorize: true,
handleExceptions: true,
humanReadableUnhandledException: true,
timestamp
})
],
filters: [
(lvl, msg) => this.client && this.client.options.shardCount > 0 ? `[${this.client.options.shardId}] ${msg}` : msg
]
});
if(this.config.values.log) {
this._logger.add(winston.transports.File, {
level: this.config.values.logLevel,
filename: this.config.values.log,
maxsize: this.config.values.logMaxSize,
maxFiles: this.config.values.logMaxFiles,
tailable: true,
json: false,
handleExceptions: true,
humanReadableUnhandledException: true,
timestamp
});
}
}
return this._logger;
}
/**
* Logs a message
* @param {Message} message - The message
* @param {Message} [oldMessage] - The old message if edited
*/
_logMessage(message, oldMessage = null) {
if(!this.config.values.logMessages) return;
if(oldMessage && message.content === oldMessage.content) return;
const prefix = `${message.guild ? `[${message.guild.name}][${message.channel.name}]` : '[DM]'} ${message.author.username}#${message.author.discriminator}`;
this.logger.message(`${prefix}: ${message.content}`);
if(oldMessage) this.logger.message(`${prefix} EDITED FROM: ${oldMessage.content}`);
}
/**
* Checks for an update for the bot
*/
_checkForUpdate() {
const config = this.config.values;
request(config.updateURL).then(body => {
const masterVersion = JSON.parse(body).version;
if(!semver.gt(masterVersion, config.version)) return;
const message = `An update for ${config.name} is available! Current version is ${config.version}, latest available is ${masterVersion}.`;
this.logger.warn(message);
const savedVersion = this.storage.settings.getValue(null, 'notified-version');
if(savedVersion !== masterVersion && this.client && config.owner) {
this.client.users.get(config.owner).sendMessage(message);
this.storage.settings.save(new Setting(null, 'notified-version', masterVersion));
}
}).catch(err => {
this.logger.error('Error while checking for an update', err);
});
}
/**
* Sends guild count to Carbon
*/
_sendCarbonStats() {
const config = this.config.values;
request({
method: 'POST',
uri: config.carbonUrl,
body: Object.assign(this._stats, { key: config.carbonKey }),
json: true
}).then(() => {
this.logger.info(`Sent guild count to Carbon with ${this.client.guilds.size} guilds.`);
}).catch(err => {
this.logger.error('Error while sending guild count to Carbon.', err);
});
}
/**
* Sends guild count to bots.discord.pw
*/
_sendBDPWStats() {
const config = this.config.values;
request({
method: 'POST',
uri: `${config.bdpwUrl}/bots/${this.client.user.id}/stats`,
headers: { Authorization: config.bdpwKey },
body: this._stats,
json: true
}).then(() => {
this.logger.info(`Sent guild count to bots.discord.pw with ${this.client.guilds.size} guilds.`);
}).catch(err => {
this.logger.error('Error while sending guild count to bots.discord.pw.', err);
});
}
/**
* Stats object to send to Carbon/BDPW
* @type {Object}
*/
get _stats() {
/* eslint-disable camelcase */
const body = { server_count: this.client.guilds.size };
if(this.client.options.shardCount > 0) {
body.shard_id = this.client.options.shardId;
body.shard_count = this.client.options.shardCount;
}
/* eslint-enable camelcase */
return body;
}
}
const defaultClientOptions = { bot: true };