3
3
import fs from 'fs' ;
4
4
import path from 'path' ;
5
5
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 ' ;
9
9
import { Resolved , Resolves , makeArray } from './util' ;
10
10
11
11
/** The options passed to the client constructor. Includes Eris options. */
@@ -31,6 +31,15 @@ export interface ClientOptions extends Eris.ClientOptions {
31
31
* for debugging, probably shouldn't be used in production.
32
32
*/
33
33
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
+
34
43
}
35
44
36
45
/** Information returned from the API about the bot's OAuth application. */
@@ -42,7 +51,7 @@ export interface ClientOAuthApplication extends Resolved<ReturnType<Client['getO
42
51
// A function that takes a message and a context argument and returns a prefix,
43
52
// an array of prefixes, or void.
44
53
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 > ;
46
55
}
47
56
48
57
/** The client. */
@@ -75,9 +84,20 @@ export class Client extends Eris.Client implements ClientOptions {
75
84
*/
76
85
ignoreGlobalRequirements : boolean = false ;
77
86
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
+
78
95
/** A list of all loaded commands. */
79
96
commands : Command [ ] = [ ] ;
80
97
98
+ /** A list of all registered event listeners. */
99
+ events : EventListener [ ] = [ ] ;
100
+
81
101
/**
82
102
* The default command, executed if `allowMention` is true and the bot is
83
103
* pinged without a command
@@ -119,14 +139,22 @@ export class Client extends Eris.Client implements ClientOptions {
119
139
if ( options . allowMention !== undefined ) this . allowMention = options . allowMention ;
120
140
if ( options . ignoreBots !== undefined ) this . ignoreBots = options . ignoreBots ;
121
141
if ( options . ignoreGlobalRequirements !== undefined ) this . ignoreGlobalRequirements = options . ignoreGlobalRequirements ;
142
+ if ( options . disableDefaultMessageListener !== undefined ) this . disableDefaultMessageListener = options . disableDefaultMessageListener ;
122
143
123
144
// Warn if we're using an empty prefix
124
145
if ( this . prefix === '' ) {
125
146
process . emitWarning ( 'prefx is an empty string; bot will not require a prefix to run commands' ) ;
126
147
}
127
148
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 ) ;
130
158
}
131
159
132
160
/**
@@ -152,53 +180,54 @@ export class Client extends Eris.Client implements ClientOptions {
152
180
return ! ! this . listeners ( name ) . length ;
153
181
}
154
182
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 > {
164
185
// 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
+
167
189
// It is! We can
168
190
const [ prefix , content ] = matchResult ;
169
191
// If there is no content past the prefix, we don't have a command
170
192
if ( ! content ) {
171
193
// 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 , '' ] ;
180
196
}
181
- // Separate command name from arguments and find command object
197
+
182
198
const args = content . split ( ' ' ) ;
183
199
let commandName = args . shift ( ) ;
184
- if ( commandName === undefined ) return ;
200
+ if ( commandName === undefined ) return null ;
185
201
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 ;
186
213
187
214
const command = this . commandForName ( commandName ) ;
188
215
// Construct a full context object now that we have all the info
189
216
const fullContext : CommandContext = Object . assign ( {
190
217
prefix,
191
218
commandName,
192
- } , partialContext ) ;
219
+ } , this . eventContext ) ;
220
+
193
221
// If the message has command but that command is not found
194
222
if ( ! command ) {
195
223
this . emit ( 'invalidCommand' , msg , args , fullContext ) ;
196
- return ;
224
+ return false ;
197
225
}
198
226
// Do the things
199
227
this . emit ( 'preCommand' , command , msg , args , fullContext ) ;
200
228
const executed = await command . execute ( msg , args , fullContext ) ;
201
229
if ( executed ) this . emit ( 'postCommand' , command , msg , args , fullContext ) ;
230
+ return true ;
202
231
}
203
232
204
233
/** Adds things to the context objects the client sends. */
@@ -228,12 +257,31 @@ export class Client extends Eris.Client implements ClientOptions {
228
257
return this ;
229
258
}
230
259
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
+
231
279
/**
232
280
* Load the files in a directory and attempt to add a command from each.
233
281
* Searches recursively through directories, but ignores files and nested
234
282
* directories whose names begin with a period.
235
283
*/
236
- addCommandDir ( dirname : string ) : this {
284
+ addDir ( dirname : string ) : this {
237
285
// Synchronous calls are fine with this method because it's only called
238
286
// on init
239
287
// eslint-disable-next-line no-sync
@@ -248,37 +296,40 @@ export class Client extends Eris.Client implements ClientOptions {
248
296
// eslint-disable-next-line no-sync
249
297
const info = fs . statSync ( filepath ) ;
250
298
if ( info && info . isDirectory ( ) ) {
251
- this . addCommandDir ( filepath ) ;
299
+ this . addDir ( filepath ) ;
252
300
} else {
253
301
// Add files only if they can be required
254
302
for ( const extension of Object . keys ( require . extensions ) ) {
255
303
if ( filepath . endsWith ( extension ) ) {
256
- this . addCommandFile ( filepath ) ;
304
+ this . addFile ( filepath ) ;
257
305
}
258
306
}
259
307
}
260
308
}
261
309
return this ;
262
310
}
263
311
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
267
316
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(...);`
270
317
// 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 ) {
273
320
// Use object.assign to preserve other exports
274
321
// 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' ) ;
279
332
}
280
- command . filename = filename ;
281
- this . addCommand ( command ) ;
282
333
return this ;
283
334
}
284
335
@@ -294,30 +345,59 @@ export class Client extends Eris.Client implements ClientOptions {
294
345
}
295
346
296
347
/**
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.
300
350
*/
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
+ }
310
365
}
311
366
}
312
367
return this ;
313
368
}
314
369
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
+
315
394
/**
316
395
* Checks the list of registered commands and returns one whch is known by a
317
396
* given name.
318
397
*/
319
398
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 ;
321
401
}
322
402
323
403
/**
@@ -336,8 +416,9 @@ export class Client extends Eris.Client implements ClientOptions {
336
416
return this ;
337
417
}
338
418
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 ) ;
341
422
if ( prefixes == null ) {
342
423
// If we have no custom function or it returned nothing, use default
343
424
return [ this . prefix ] ;
@@ -352,8 +433,8 @@ export class Client extends Eris.Client implements ClientOptions {
352
433
// @param {Eris.Message } msg The message to process
353
434
// @returns {Array<String|null> } An array `[prefix, rest]` if the message
354
435
// 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 ) ;
357
438
358
439
// Traditional prefix checking
359
440
for ( const prefix of prefixes ) {
@@ -386,7 +467,7 @@ export class Client extends Eris.Client implements ClientOptions {
386
467
}
387
468
}
388
469
389
- interface YuukoEvents < T > extends Eris . ClientEvents < T > {
470
+ export interface ClientEvents < T > extends Eris . ClientEvents < T > {
390
471
/**
391
472
* @event
392
473
* Fired when a command is loaded.
@@ -424,5 +505,5 @@ interface YuukoEvents<T> extends Eris.ClientEvents<T> {
424
505
}
425
506
426
507
export declare interface Client extends Eris . Client {
427
- on : YuukoEvents < this> ;
508
+ on : ClientEvents < this> ;
428
509
}
0 commit comments