Skip to content

Commit ee85d64

Browse files
authored
Add class to encapsulate event listeners (#29)
* Add basic event ilstener class stuffs * Get some events working properly * move in all the eris events * Add non-Eris events to EventListener typings * Basic EventListener support in Client * Support loading events from files, method renaming * EventListener: add missing filename prop, add docs * Add support for reloading event listener files * Update EventListener signatures for Eris 0.13.x * Start renaming PartialCommandContext * EventListener functions get context arg added * finish renaming eventcontext * Split out message listener, add option to disable * Rename option to ...Listener instead of ...Handler * Don't duplicate event listeners on reloadFiles * add support for once-only event listeners * Add getter for event name * lock eris used for build to 0.13
1 parent 3dfe564 commit ee85d64

File tree

7 files changed

+258
-75
lines changed

7 files changed

+258
-75
lines changed

.github/workflows/main.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
# Direct dependencies
1818
yarn install
1919
# Peer dependencies
20-
npm i --no-save eris
20+
npm i --no-save eris@0.13
2121
2222
- name: Build Typescript
2323
run: |

src/Client.ts

Lines changed: 145 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
import fs from 'fs';
44
import path from 'path';
55
import * as Eris from 'eris';
6-
import {Command} from './Yuuko';
7-
// TODO: PartialCommandContext is only used in this file, should be defined here
8-
import {CommandRequirements, PartialCommandContext, CommandContext} from './Command';
6+
import {Command, CommandRequirements, CommandContext} from './Command';
7+
import {EventListener, EventContext} from './EventListener';
8+
import defaultMessageListener from './defaultMessageListener';
99
import {Resolved, Resolves, makeArray} from './util';
1010

1111
/** The options passed to the client constructor. Includes Eris options. */
@@ -31,6 +31,15 @@ export interface ClientOptions extends Eris.ClientOptions {
3131
* for debugging, probably shouldn't be used in production.
3232
*/
3333
ignoreGlobalRequirements?: boolean;
34+
35+
/**
36+
* If true, the client does not respond to commands by default, and the user
37+
* must register their own `messageCreate` listener, which can call
38+
* `processCommand` to perform command handling at an arbitrary point during
39+
* the handler's execution
40+
*/
41+
disableDefaultMessageListener?: boolean;
42+
3443
}
3544

3645
/** Information returned from the API about the bot's OAuth application. */
@@ -42,7 +51,7 @@ export interface ClientOAuthApplication extends Resolved<ReturnType<Client['getO
4251
// A function that takes a message and a context argument and returns a prefix,
4352
// an array of prefixes, or void.
4453
export interface PrefixFunction {
45-
(msg: Eris.Message, ctx: PartialCommandContext): Resolves<string | string[] | null | undefined>;
54+
(msg: Eris.Message, ctx: EventContext): Resolves<string | string[] | null | undefined>;
4655
}
4756

4857
/** The client. */
@@ -75,9 +84,20 @@ export class Client extends Eris.Client implements ClientOptions {
7584
*/
7685
ignoreGlobalRequirements: boolean = false;
7786

87+
/**
88+
* If true, the client does not respond to commands by default, and the user
89+
* must register their own `messageCreate` listener, which can call
90+
* `processCommand` to perform command handling at an arbitrary point during
91+
* the handler's execution
92+
*/
93+
disableDefaultMessageListener: boolean = false;
94+
7895
/** A list of all loaded commands. */
7996
commands: Command[] = [];
8097

98+
/** A list of all registered event listeners. */
99+
events: EventListener[] = [];
100+
81101
/**
82102
* The default command, executed if `allowMention` is true and the bot is
83103
* pinged without a command
@@ -119,14 +139,22 @@ export class Client extends Eris.Client implements ClientOptions {
119139
if (options.allowMention !== undefined) this.allowMention = options.allowMention;
120140
if (options.ignoreBots !== undefined) this.ignoreBots = options.ignoreBots;
121141
if (options.ignoreGlobalRequirements !== undefined) this.ignoreGlobalRequirements = options.ignoreGlobalRequirements;
142+
if (options.disableDefaultMessageListener !== undefined) this.disableDefaultMessageListener = options.disableDefaultMessageListener;
122143

123144
// Warn if we're using an empty prefix
124145
if (this.prefix === '') {
125146
process.emitWarning('prefx is an empty string; bot will not require a prefix to run commands');
126147
}
127148

128-
// Register the message event listener
129-
this.on('messageCreate', this.handleMessage);
149+
// Register the default message listener unless it's disabled
150+
if (!this.disableDefaultMessageListener) {
151+
this.addEvent(defaultMessageListener);
152+
}
153+
}
154+
155+
/** Returns an EventContext object with all the current context */
156+
get eventContext (): EventContext {
157+
return Object.assign({client: this}, this.contextAdditions);
130158
}
131159

132160
/**
@@ -152,53 +180,54 @@ export class Client extends Eris.Client implements ClientOptions {
152180
return !!this.listeners(name).length;
153181
}
154182

155-
/** Given a message, see if there is a command and process it if so. */
156-
private async handleMessage (msg: Eris.Message): Promise<void> {
157-
if (!msg.author) return; // this is a bug and shouldn't really happen
158-
if (this.ignoreBots && msg.author.bot) return;
159-
160-
// Construct a partial context (without prefix or command name)
161-
const partialContext: PartialCommandContext = Object.assign({
162-
client: this,
163-
}, this.contextAdditions);
183+
/** Returns the command as a list of parsed strings, or null if it's not a valid command */
184+
async hasCommand (message: Eris.Message): Promise<[string, string, ...string[]] | null> {
164185
// Is the message properly prefixed? If not, we can ignore it
165-
const matchResult = await this.splitPrefixFromContent(msg, partialContext);
166-
if (!matchResult) return;
186+
const matchResult = await this.splitPrefixFromContent(message);
187+
if (!matchResult) return null;
188+
167189
// It is! We can
168190
const [prefix, content] = matchResult;
169191
// If there is no content past the prefix, we don't have a command
170192
if (!content) {
171193
// But a lone mention will trigger the default command instead
172-
if (!prefix || !prefix.match(this.mentionPrefixRegExp!)) return;
173-
const defaultCommand = this.defaultCommand;
174-
if (!defaultCommand) return;
175-
defaultCommand.execute(msg, [], Object.assign({
176-
client: this,
177-
prefix,
178-
}, this.contextAdditions));
179-
return;
194+
if (!prefix || !prefix.match(this.mentionPrefixRegExp!)) return null;
195+
return [prefix, ''];
180196
}
181-
// Separate command name from arguments and find command object
197+
182198
const args = content.split(' ');
183199
let commandName = args.shift();
184-
if (commandName === undefined) return;
200+
if (commandName === undefined) return null;
185201
if (!this.caseSensitiveCommands) commandName = commandName.toLowerCase();
202+
return [prefix, commandName, ...args];
203+
}
204+
205+
/**
206+
* Given a message, tries to parse a command from it. If it is a command,
207+
* executes it and returns `true`; otherwise, returns `false`.
208+
*/
209+
async processCommand (msg): Promise<boolean> {
210+
const commandInfo = await this.hasCommand(msg);
211+
if (!commandInfo) return false;
212+
const [prefix, commandName, ...args] = commandInfo;
186213

187214
const command = this.commandForName(commandName);
188215
// Construct a full context object now that we have all the info
189216
const fullContext: CommandContext = Object.assign({
190217
prefix,
191218
commandName,
192-
}, partialContext);
219+
}, this.eventContext);
220+
193221
// If the message has command but that command is not found
194222
if (!command) {
195223
this.emit('invalidCommand', msg, args, fullContext);
196-
return;
224+
return false;
197225
}
198226
// Do the things
199227
this.emit('preCommand', command, msg, args, fullContext);
200228
const executed = await command.execute(msg, args, fullContext);
201229
if (executed) this.emit('postCommand', command, msg, args, fullContext);
230+
return true;
202231
}
203232

204233
/** Adds things to the context objects the client sends. */
@@ -228,12 +257,31 @@ export class Client extends Eris.Client implements ClientOptions {
228257
return this;
229258
}
230259

260+
/** Register an EventListener class instance to the client. */
261+
addEvent (eventListener: EventListener): this {
262+
this.events.push(eventListener);
263+
// The actual function registered as a listener calls the instance's
264+
// registered function with the context object as the last parameter. We
265+
// store it as a property of the listener so it can be removed later (if
266+
// the instance was registered via `addDir`/`addFile`, then it will need
267+
// to be removed when calling `reloadFiles`).
268+
eventListener.computedListener = (...args) => {
269+
eventListener.args[1](...args, this.eventContext);
270+
};
271+
if (eventListener.once) {
272+
this.once(eventListener.args[0], eventListener.computedListener);
273+
} else {
274+
this.on(eventListener.args[0], eventListener.computedListener);
275+
}
276+
return this;
277+
}
278+
231279
/**
232280
* Load the files in a directory and attempt to add a command from each.
233281
* Searches recursively through directories, but ignores files and nested
234282
* directories whose names begin with a period.
235283
*/
236-
addCommandDir (dirname: string): this {
284+
addDir (dirname: string): this {
237285
// Synchronous calls are fine with this method because it's only called
238286
// on init
239287
// eslint-disable-next-line no-sync
@@ -248,37 +296,40 @@ export class Client extends Eris.Client implements ClientOptions {
248296
// eslint-disable-next-line no-sync
249297
const info = fs.statSync(filepath);
250298
if (info && info.isDirectory()) {
251-
this.addCommandDir(filepath);
299+
this.addDir(filepath);
252300
} else {
253301
// Add files only if they can be required
254302
for (const extension of Object.keys(require.extensions)) {
255303
if (filepath.endsWith(extension)) {
256-
this.addCommandFile(filepath);
304+
this.addFile(filepath);
257305
}
258306
}
259307
}
260308
}
261309
return this;
262310
}
263311

264-
/** Add a command exported from a file. */
265-
// TODO: support exporting multiple commands?
266-
addCommandFile (filename: string): this {
312+
/** Add a command or event exported from a file. */
313+
// TODO: support exporting multiple components?
314+
addFile (filename: string): this {
315+
// Clear require cache so we always get a fresh copy
267316
delete require.cache[filename];
268-
// JS files are expected to use `module.exports = new Command(...);`
269-
// TS files are expected to use `export default new Command(...);`
270317
// eslint-disable-next-line global-require
271-
let command = require(filename);
272-
if (command.default instanceof Command) {
318+
let thing = require(filename);
319+
if (thing.default) {
273320
// Use object.assign to preserve other exports
274321
// TODO: this kinda breaks typescript but it's fine
275-
command = Object.assign(command.default, command);
276-
delete command.default;
277-
} else if (!(command instanceof Command)) {
278-
throw new TypeError(`File ${filename} does not export a command`);
322+
thing = Object.assign(thing.default, thing);
323+
delete thing.default;
324+
}
325+
thing.filename = filename;
326+
if (thing instanceof Command) {
327+
this.addCommand(thing);
328+
} else if (thing instanceof EventListener) {
329+
this.addEvent(thing);
330+
} else {
331+
throw new TypeError('Exported value is not a command or event listener');
279332
}
280-
command.filename = filename;
281-
this.addCommand(command);
282333
return this;
283334
}
284335

@@ -294,30 +345,59 @@ export class Client extends Eris.Client implements ClientOptions {
294345
}
295346

296347
/**
297-
* Reloads all commands that were loaded via `addCommandFile` and
298-
* `addCommandDir`. Useful for development to hot-reload commands as you
299-
* work on them.
348+
* Reloads all commands and events that were loaded via from files. Useful
349+
* for development to hot-reload components as you work on them.
300350
*/
301-
reloadCommands (): this {
302-
// Iterates over the list backwards to avoid overwriting indexes (this
303-
// rewrites the list in reverse order, but we don't care)
304-
let i = this.commands.length;
305-
while (i--) {
306-
const command = this.commands[i];
307-
if (command.filename) {
308-
this.commands.splice(i, 1);
309-
this.addCommandFile(command.filename);
351+
reloadFiles (): this {
352+
for (const list of [this.commands, this.events]) {
353+
// Iterate over the lists backwards to avoid overwriting indexes (this
354+
// rewrites the lists in reverse order, but we don't care)
355+
let i = list.length;
356+
while (i--) {
357+
const thing = list[i];
358+
if (thing instanceof EventListener && thing.computedListener) {
359+
this.removeListener(thing.args[0], thing.computedListener);
360+
}
361+
if (thing.filename) {
362+
list.splice(i, 1);
363+
this.addFile(thing.filename);
364+
}
310365
}
311366
}
312367
return this;
313368
}
314369

370+
/**
371+
* Alias for `addDir`.
372+
* @deprecated
373+
*/
374+
addCommandDir (dirname: string): this {
375+
return this.addDir(dirname);
376+
}
377+
378+
/**
379+
* Alias for `addFile`.
380+
* @deprecated
381+
*/
382+
addCommandFile (filename: string): this {
383+
return this.addFile(filename);
384+
}
385+
386+
/**
387+
* Alias for `reloadFiles()`.
388+
* @deprecated
389+
*/
390+
reloadCommands (): this {
391+
return this.reloadFiles();
392+
}
393+
315394
/**
316395
* Checks the list of registered commands and returns one whch is known by a
317396
* given name.
318397
*/
319398
commandForName (name: string): Command | null {
320-
return this.commands.find(c => c.names.includes(name)) || null;
399+
if (this.caseSensitiveCommands) return this.commands.find(c => c.names.includes(name)) || null;
400+
return this.commands.find(c => c.names.some(n => n.toLowerCase() === name.toLowerCase())) || null;
321401
}
322402

323403
/**
@@ -336,8 +416,9 @@ export class Client extends Eris.Client implements ClientOptions {
336416
return this;
337417
}
338418

339-
async getPrefixesForMessage (msg, ctx) {
340-
const prefixes = this.prefixFunction && await this.prefixFunction(msg, ctx);
419+
async getPrefixesForMessage (msg) {
420+
// TODO inlining this context creation is bleh
421+
const prefixes = this.prefixFunction && await this.prefixFunction(msg, this.eventContext);
341422
if (prefixes == null) {
342423
// If we have no custom function or it returned nothing, use default
343424
return [this.prefix];
@@ -352,8 +433,8 @@ export class Client extends Eris.Client implements ClientOptions {
352433
// @param {Eris.Message} msg The message to process
353434
// @returns {Array<String|null>} An array `[prefix, rest]` if the message
354435
// matches the prefix, or `[null, null]` if not
355-
async splitPrefixFromContent (msg: Eris.Message, ctx: PartialCommandContext): Promise<[string, string] | null> {
356-
const prefixes = await this.getPrefixesForMessage(msg, ctx);
436+
async splitPrefixFromContent (msg: Eris.Message): Promise<[string, string] | null> {
437+
const prefixes = await this.getPrefixesForMessage(msg);
357438

358439
// Traditional prefix checking
359440
for (const prefix of prefixes) {
@@ -386,7 +467,7 @@ export class Client extends Eris.Client implements ClientOptions {
386467
}
387468
}
388469

389-
interface YuukoEvents<T> extends Eris.ClientEvents<T> {
470+
export interface ClientEvents<T> extends Eris.ClientEvents<T> {
390471
/**
391472
* @event
392473
* Fired when a command is loaded.
@@ -424,5 +505,5 @@ interface YuukoEvents<T> extends Eris.ClientEvents<T> {
424505
}
425506

426507
export declare interface Client extends Eris.Client {
427-
on: YuukoEvents<this>;
508+
on: ClientEvents<this>;
428509
}

src/Command.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/** @module Yuuko */
22

33
import * as Eris from 'eris';
4-
import { Client } from './Yuuko';
4+
import { EventContext } from './Yuuko';
55
import { makeArray } from './util';
66

77
/** Check if requirements are met. */
@@ -87,15 +87,8 @@ export interface CommandRequirements {
8787
custom?(msg: Eris.Message, args: string[], ctx: CommandContext): boolean | Promise<boolean>;
8888
}
8989

90-
/** An object containing context information for processing a command. */
91-
export interface PartialCommandContext {
92-
/** The client that received the message. */
93-
client: Client;
94-
/** Other keys can be added as necessary by Client#extendContext. */
95-
[key: string]: any;
96-
}
9790
/** An object containing context information for a command's execution. */
98-
export interface CommandContext extends PartialCommandContext {
91+
export interface CommandContext extends EventContext {
9992
/** The prefix used to call the command. */
10093
prefix: string;
10194
/** The name or alias used to call the command. */

0 commit comments

Comments
 (0)