dexare
Version:
Modular and extendable Discord bot framework
323 lines (280 loc) • 11.1 kB
text/typescript
import Collection from '@discordjs/collection';
import Eris from 'eris';
import EventEmitter from 'eventemitter3';
import { ErisEventNames } from '../constants';
import DexareModule from '../module';
import CommandsModule from '../modules/commands';
import CollectorModule from '../modules/collector';
import { ErisEvents, LoggerExtra } from '../types';
import LoggerHandler from '../util/logger';
import TypedEmitter from '../util/typedEmitter';
import EventRegistry from './events';
import PermissionRegistry from './permissions';
import DataManager from '../dataManager';
import MemoryDataManager from '../dataManagers/memory';
export interface BaseConfig {
token: string;
erisOptions?: Eris.ClientOptions;
elevated?: string | Array<string>;
}
/**
* The events typings for the {@link DexareClient}.
* @private
*/
export interface DexareClientEvents extends ErisEvents {
logger(level: string, group: string, args: any[], extra?: LoggerExtra): void;
beforeConnect(): void;
afterConnect(): void;
beforeDisconnect(reconnect: boolean | 'auto'): void;
afterDisconnect(reconnect: boolean | 'auto'): void;
}
/** @hidden */
export type DexareEvents = DexareClientEvents & {
[event: string]: (...args: any[]) => void;
};
export default class DexareClient<
T extends BaseConfig = BaseConfig
> extends (EventEmitter as any as new () => TypedEmitter<DexareEvents>) {
config: T;
readonly bot: Eris.Client;
readonly permissions: PermissionRegistry<this>;
readonly events = new EventRegistry<this>(this);
readonly logger = new LoggerHandler<this>(this, 'dexare/client');
readonly modules = new Collection<string, DexareModule<this>>();
readonly commands = new CommandsModule<this>(this);
readonly collector = new CollectorModule<this>(this);
data: DataManager = new MemoryDataManager(this);
// eslint-disable-next-line no-undef
private readonly _typingIntervals = new Map<string, any>();
private readonly _hookedEvents: string[] = [];
private _erisEventsLogged = false;
constructor(config: T, bot?: Eris.Client) {
// eslint-disable-next-line constructor-super
super();
this.config = config;
if (bot) this.bot = bot;
else {
let token = this.config.token;
if (!this.config.token.startsWith('Bot ')) token = 'Bot ' + this.config.token;
this.bot = new Eris.Client(token, this.config.erisOptions);
}
this.permissions = new PermissionRegistry(this);
this.modules.set('commands', this.commands);
this.commands._load();
this.modules.set('collector', this.collector);
this.collector._load();
}
/**
* Load modules into the client.
* @param moduleObjects The modules to load.
* @returns The client for chaining purposes
*/
loadModules(...moduleObjects: any[]) {
const modules = moduleObjects.map(this._resolveModule.bind(this));
const loadOrder = this._getLoadOrder(modules);
for (const modName of loadOrder) {
const mod = modules.find((mod) => mod.options.name === modName)!;
if (this.modules.has(mod.options.name))
throw new Error(`A module in the client already has been named "${mod.options.name}".`);
this._log('debug', `Loading module "${modName}"`);
this.modules.set(modName, mod);
mod._load();
}
return this;
}
/**
* Load modules into the client asynchronously.
* @param moduleObjects The modules to load.
* @returns The client for chaining purposes
*/
async loadModulesAsync(...moduleObjects: any[]) {
const modules = moduleObjects.map(this._resolveModule.bind(this));
const loadOrder = this._getLoadOrder(modules);
for (const modName of loadOrder) {
const mod = modules.find((mod) => mod.options.name === modName)!;
if (this.modules.has(mod.options.name))
throw new Error(`A module in the client already has been named "${mod.options.name}".`);
this._log('debug', `Loading module "${modName}"`);
this.modules.set(modName, mod);
await mod._load();
}
}
/**
* Loads a module asynchronously into the client.
* @param moduleObject The module to load
*/
async loadModule(moduleObject: any) {
const mod = this._resolveModule(moduleObject);
if (this.modules.has(mod.options.name))
throw new Error(`A module in the client already has been named "${mod.options.name}".`);
this._log('debug', `Loading module "${mod.options.name}"`);
this.modules.set(mod.options.name, mod);
await mod._load();
}
/**
* Unloads a module.
* @param moduleName The module to unload
*/
async unloadModule(moduleName: string) {
if (!this.modules.has(moduleName)) return;
const mod = this.modules.get(moduleName)!;
this._log('debug', `Unloading module "${moduleName}"`);
await mod.unload();
this.modules.delete(moduleName);
}
/**
* Loads a data manager asynchronously into the client.
* @param moduleObject The manager to load
* @param startOnLoad Whether to start the manager after loading
*/
async loadDataManager(mgrObject: any, startOnLoad = false) {
if (typeof mgrObject === 'function') mgrObject = new mgrObject(this);
else if (typeof mgrObject.default === 'function') mgrObject = new mgrObject.default(this);
if (typeof mgrObject.start !== 'function')
throw new Error(`Invalid data manager object to load: ${mgrObject}`);
await this.data.stop();
this.data = mgrObject;
if (startOnLoad) await this.data.start();
}
/**
* Log events to console.
* @param logLevel The level to log at.
* @param excludeModules The modules to exclude
*/
logToConsole(logLevel: 'debug' | 'info' | 'warn' | 'error' = 'info', excludeModules: string[] = []) {
const levels = ['debug', 'info', 'warn', 'error'];
const index = levels.indexOf(logLevel);
this.on('logger', (level, moduleName, args) => {
let importance = levels.indexOf(level);
if (importance === -1) importance = 0;
if (importance < index) return;
if (excludeModules.includes(moduleName)) return;
let logFunc = console.debug;
if (level === 'info') logFunc = console.info;
else if (level === 'error') logFunc = console.error;
else if (level === 'warn') logFunc = console.warn;
logFunc(level.toUpperCase(), `[${moduleName}]`, ...args);
});
return this;
}
/** Logs informational Eris events to Dexare's logger event. */
logErisEvents() {
if (this._erisEventsLogged) return this;
this._erisEventsLogged = true;
this.on('ready', () => this.emit('logger', 'info', 'eris', ['All shards ready.']));
this.on('disconnect', () => this.emit('logger', 'info', 'eris', ['All shards disconnected.']));
this.on('reconnecting', () => this.emit('logger', 'info', 'eris', ['Reconnecting...']));
this.on('debug', (message, id) => this.emit('logger', 'debug', 'eris', [message], { id }));
this.on('warn', (message, id) => this.emit('logger', 'warn', 'eris', [message], { id }));
this.on('error', (error, id) => this.emit('logger', 'error', 'eris', [error], { id }));
this.on('connect', (id) => this.emit('logger', 'info', 'eris', ['Shard connected.'], { id }));
this.on('hello', (trace, id) =>
this.emit('logger', 'debug', 'eris', ['Shard recieved hello.'], {
id,
trace
})
);
this.on('shardReady', (id) => this.emit('logger', 'info', 'eris', ['Shard ready.'], { id }));
this.on('shardResume', (id) => this.emit('logger', 'warn', 'eris', ['Shard resumed.'], { id }));
this.on('shardDisconnect', (error, id) =>
this.emit('logger', 'warn', 'eris', ['Shard disconnected.', error], {
id
})
);
return this;
}
/**
* Register an event.
* @param event The event to register
* @param listener The event listener
*/
on<E extends keyof DexareEvents>(event: E, listener: DexareEvents[E]) {
if (typeof event === 'string' && !this._hookedEvents.includes(event) && ErisEventNames.includes(event)) {
// @ts-ignore
this.bot.on(event, (...args: any[]) => this.emit(event, ...args));
this._hookedEvents.push(event);
}
return super.on(event, listener);
}
/**
* Creates a promise that resolves on the next event
* @param event The event to wait for
*/
waitTill(event: keyof DexareEvents) {
return new Promise((resolve) => this.once(event, resolve));
}
/** Connects and logs in to Discord. */
async connect() {
await this.events.emitAsync('beforeConnect');
await this.data.start();
await this.bot.connect();
await this.events.emitAsync('afterConnect');
}
/** Disconnects the bot. */
async disconnect(reconnect: boolean | 'auto' = false) {
await this.events.emitAsync('beforeDisconnect', reconnect);
await this.data.stop();
this.bot.disconnect({ reconnect });
await this.events.emitAsync('afterDisconnect', reconnect);
}
/**
* Start typing in a channel
* @param channelID The channel's ID to start typing in
*/
async startTyping(channelID: string) {
if (this.isTyping(channelID)) return;
await this.bot.sendChannelTyping(channelID);
this._typingIntervals.set(
channelID,
setInterval(() => {
this.bot.sendChannelTyping(channelID).catch(() => this.stopTyping(channelID));
}, 5000)
);
}
/**
* Whether the bot is currently typing in a channel.
* @param channelID The channel ID to check for
*/
isTyping(channelID: string) {
return this._typingIntervals.has(channelID);
}
/**
* Stops typing in a channel.
* @param channelID The channel's ID to stop typing in
*/
stopTyping(channelID: string) {
if (!this.isTyping(channelID)) return;
const interval = this._typingIntervals.get(channelID)!;
clearInterval(interval);
this._typingIntervals.delete(channelID);
}
/** @hidden */
private _resolveModule(moduleObject: any) {
if (typeof moduleObject === 'function') moduleObject = new moduleObject(this);
else if (typeof moduleObject.default === 'function') moduleObject = new moduleObject.default(this);
if (typeof moduleObject.load !== 'function')
throw new Error(`Invalid module object to load: ${moduleObject}`);
return moduleObject as DexareModule<this>;
}
/** @hidden */
private _getLoadOrder(modules: DexareModule<any>[]) {
const loadOrder: string[] = [];
const insert = (mod: DexareModule<any>) => {
if (mod.options.requires && mod.options.requires.length)
mod.options.requires.forEach((modName) => {
const dep = modules.find((mod) => mod.options.name === modName) || this.modules.get(modName);
if (!dep)
throw new Error(
`Module '${mod.options.name}' requires dependency '${modName}' which does not exist!`
);
if (!this.modules.has(modName)) insert(dep);
});
if (!loadOrder.includes(mod.options.name)) loadOrder.push(mod.options.name);
};
modules.forEach((mod) => insert(mod));
return loadOrder;
}
private _log(level: string, ...args: any[]) {
this.emit('logger', level, 'dexare', args);
}
}