Moved command handler code to Onion Lasers

This commit is contained in:
WatDuhHekBro 2021-04-13 07:38:52 -05:00
parent 6243570eb3
commit b3ce4e5134
57 changed files with 183 additions and 2014 deletions

View file

@ -1,129 +0,0 @@
# Using the Command Class
## any[] Parameters For Subcommand Run
Unless there's some sort of TypeScript wizardry to solve this, the `args` parameter in the subcommand type will have to be `any[]` because it's simply too context-dependent to statically figure it out.
- Each subcommand is its own layer which doesn't know about parent commands at compile-time.
- Subcommands can be split into different files for code maintainability.
- Even though the last argument is able to be strongly-typed, if you have multiple parameters, you'd essentially only get static benefits for one of the arguments, and you wouldn't even know the location of that one argument.
- Overall, it's just easier to use your best judgement then use type assertions.
## Channel Type Type Guards
As of right now, it's currently not feasible to implement type guards for channel types. [Discriminated unions with a default parameter don't work with callbacks.](https://github.com/microsoft/TypeScript/issues/41759) In order to implement type guards, the `channelType` parameter would have to be required, making each command layer quite tedious.
So instead, use non-null assertions when setting the `channelType`. For example:
```ts
import {Command, NamedCommand, CHANNEL_TYPE} from "../core";
import {TextChannel} from "discord.js";
export default new NamedCommand({
channelType: CHANNEL_TYPE.GUILD,
async run({message, channel, guild, author, member, client, args}) {
console.log(guild!.name);
console.log(member!.nickname);
console.log((channel as TextChannel).name !== "dm");
}
});
```
```ts
import {Command, NamedCommand, CHANNEL_TYPE} from "../core";
import {DMChannel} from "discord.js";
export default new NamedCommand({
channelType: CHANNEL_TYPE.DM,
async run({message, channel, guild, author, member, client, args}) {
console.log(guild === null);
console.log(member === null);
console.log((channel as DMChannel).type === "dm");
}
});
```
The three guarantees are whether or not `guild` will be `null`, whether or not `member` will be `null`, and the type of `channel`.
*Take note that `member` can still be `null` even in a guild (for example, if you target a message by someone who left), `member` cannot be `null` here because the `message` being sent must be by someone who is in the guild by this point.*
## Uneven Return Paths
`Command.run` doesn't use the return values for anything, so it's safe to do `return channel.send(...)` to merge those two statements. However, you'll come across an error: `Not all code paths return a value.`
There are several ways to resolve this issue:
- Split all `return channel.send(...)` statements to `{channel.send(...); return;}`
- Set an explicit any return type in the function header: `async run(...): Promise<any> {`
- Add an extra `return` statement at the end of each path
## Type Guards
The `Command` class is implemented in a certain way to provide type guards which reduce unnecessary properties at compile-time rather than warning the user at runtime.
- The reason `NamedCommand` (which extends `Command`) exists is to provide a type guard for `aliases`. After all, `aliases` doesn't really make sense for generic subcommand types - how would you handle an alias for a type that accepts a number for example?
- The `endpoint` property changes what other properties are available via a discriminated union. If `endpoint` is `true`, no subcommands of any type can be defined. After all, it wouldn't make sense logically.
## Boolean Types
Boolean subcommand types won't be implemented:
- Since there are only two values, why not just put it under `subcommands`?
- If boolean types were to be implemented, how many different types of input would have to be considered? `yes`/`no`, `y`/`n`, `true`/`false`, `1`/`0`, etc.
## Hex and Octal Number Types
For common use cases, there wouldn't be a need to go accept numbers of different bases. The only time it would be applicable is if there was some sort of base converter command, and even then, it'd be better to just implement custom logic.
## User Mention + Search by Username Type
While it's a pretty common pattern, it's probably a bit too specific for the `Command` class itself. Instead, this pattern will be comprised of two subcommands: A `user` type and an `any` type.
# The Command Handler
## The Scope of the Command Handler
What this does:
- Provides the `Command`/`NamedCommand` classes.
- Dynamically loads commands and attaches runtime metadata.
- Provides utility functions specific to Discord to make certain patterns of commands less tedious to implement.
What this doesn't do:
- Manage the general file system or serialization/deserialization of data.
- Provide general utility functions.
- Provide any Discord-related functionality besides strictly command handling.
## Client Creation
Creating the client is beyond the scope of the command handler and will not be abstracted away. Instead, the user will simply attach the command handler to the client to initialize it.
- This makes it so if a user wants to specify their own `ClientOptions` when instantiating the client, it's less troublesome to implement.
- The user can export the client and use it throughout different parts of their code.
## Bot-Specific Mentions
Pinging the bot will display the current guild prefix. The bot mention will not serve as an alternate prefix.
- When talking about a bot, the bot might be pinged to show who it is. It could be in the middle (so don't listen for a prefix anywhere) or it could be at the start (so only listen to a standalone ping).
- It likely isn't a common use case to ping the bot. The only time it would really shine is in the event two bots have a prefix conflict, but the command that changes prefixes can simply add a parameter to deal with that case. For example, instead of `@bot set prefix <prefix>`, you'd use `set prefix <prefix> @bot`.
## Direct Messages
When direct messaging a bot, no prefixes will be used at all because it's assumed that you're executing a command. Because the only people allowed is the user and the bot, NSFW-only commands can also be executed here.
## Permission Setup
Because the command handler provides no specific permission set, it's up to the user to come up with functions to add permissions as well as create the enum that assigns permissions.
- The `permission` property of a `Command` instance is `-1` by default, which means to inherit the permission level from the parent command. If you want, you can create your enum like this: `enum Permissions {INHERIT = -1, USER, ADMIN}`, where `Permissions.USER = 0` and `Permissions.ADMIN = 1`.
# Miscellaneous
## Static Event Loading
While dynamic loading fits very well with commands, it was more or less clunky design to try and make events fit the same model:
- There are no restrictions when it comes to command names, and the name of the file will determine the name of the command, which avoids repetition. Events on the other hand involved lots of boilerplate to get static types back.
- Since there can be multiple listeners per event, large event files can be split up into more organized blocks.
- Likewise, small event listeners which span multiple events can be grouped together like `channelCreate` and `channelDelete`, showing the relation in one single file rather than splitting them up just because they're two different events.
## Testing
For TravBot, there'll be two types of tests: standard unit tests and manual integration tests.
- Standard unit tests are executed only on isolated functions and are part of the pre-commit hook.
- Somehow, including the bot in an import chain will cause the system to crash (same error message as [this](https://stackoverflow.com/questions/66102858/discord-clientuser-is-not-a-constructor)). That's why the integration tests are manually done. There would be a list of inputs and outputs to check of each command for tests while simultaneously serving as a help menu with examples of all possible inputs/outputs for others to see.
- An idea which will not be implemented is prompting the user for inputs during the tests. This is no better than manual tests, worse actually, because if this had to run before each commit, it'd quickly become a nightmare.
- Maybe take some ideas from something like [this](https://github.com/stuyy/jest-unit-tests-demo) in the future to get tests to properly work.
- Another possibility is to use `client.emit(...)` then mock the `message.channel.send(...)` function which would listen if the input is correct.

View file

@ -2,11 +2,8 @@
- [Structure](#structure)
- [Version Numbers](#version-numbers)
- [Message Subcommand Type](#message-subcommand-type)
- [Command Menu](#command-menu)
- [Command Metadata](#command-metadata)
- [Command Var String](#command-var-string)
- [Utility Functions](#utility-functions)
- [Testing](#testing)
# Structure
@ -37,71 +34,8 @@ Because versions are assigned to batches of changes rather than single changes (
*Note: This system doesn't retroactively apply to TravBot-v2, which is why this version naming system won't make sense for v2's changelog.*
# Message Subcommand Type
- `https://discord.com/channels/<id>/<id>/<id>` comes from the `Copy Message Link` button.
- `<id>-<id>` comes from holding `Shift` on the desktop application and clicking on the `Copy ID` button.
# Command Menu
- `args`: A list of arguments in the command. It's relative to the subcommand, so if you do `$test this 5`, `5` becomes `$.args[0]` if `this` is a subcommand. Args are already converted, so a `number` subcommand would return a number rather than a string.
- `client`: `message.client`
- `message`: `message`
- `channel`: `message.channel`
- `guild`: `message.guild`
- `author`: `message.author`
- `member`: `message.member`
# Command Metadata
- `description`: The command description that'll appear in the help menu.
- `endpoint`: Whether or not any arguments are allowed after the command.
- `usage`: Defines a custom usage when showing the command in the help menu.
- `permission`: *(Inherits)* -1 (default) indicates to inherit, 0 is the lowest rank, 1 is second lowest rank, and so on.
- `nsfw`: *(Inherits)* Whether or not the command is restricted to NSFW channels and DM channels.
- `channelType`: *(Inherits)* Whether the command is restricted to guild channels, DM channels, or has no restriction. Uses the `CHANNEL_TYPE` enum provided by the command handler.
# Command Var String
- `%author%` - A user mention of the person who called the command.
- `%prefix%` - The prefix of the current guild.
- `%command%` - The command's execution path up to the current subcommand.
# Utility Functions
## [src/core (libd)](../src/core/libd.ts) - Utility functions specific for working with Discord
`paginate()`
```ts
const pages = ["one", "two", "three"];
paginate(send, author.id, pages.length, page => {
return {content: pages[page]};
});
```
`poll()`
```ts
const results = await poll(await send("Do you agree with this decision?"), ["✅", "❌"]);
results["✅"]; // number
results["❌"]; // number
```
`confirm()`
```ts
const result = await confirm(await send("Are you sure you want to delete this?"), author.id); // boolean | null
```
`askMultipleChoice()`
```ts
const result = await askMultipleChoice(await send("Which of the following numbers is your favorite?"), author.id, 4, 10000); // number (0 to 3) | null
```
`askForReply()`
```ts
const reply = await askForReply(await send("What is your favorite thing to do?"), author.id, 10000); // Message | null
```
## [src/lib](../src/lib.ts) - General utility functions
- `parseArgs()`: Turns `call test "args with spaces" "even more spaces"` into `["call", "test", "args with spaces", "even more spaces"]`, inspired by the command line.
@ -115,3 +49,12 @@ const reply = await askForReply(await send("What is your favorite thing to do?")
- `toTitleCase()`: Capitalizes the first letter of each word. `toTitleCase("this is some text")` = `"This Is Some Text"`.
- `random()`: Returns a random element from an array. `random([1,2,3])` could be any one of those elements.
- `split()`: Splits an array into different arrays by a specified length. `split([1,2,3,4,5,6,7,8,9,10], 3)` = `[[1,2,3],[4,5,6],[7,8,9],[10]]`.
# Testing
For TravBot, there'll be two types of tests: standard unit tests and manual integration tests.
- Standard unit tests are executed only on isolated functions and are part of the pre-commit hook.
- Somehow, including the bot in an import chain will cause the system to crash (same error message as [this](https://stackoverflow.com/questions/66102858/discord-clientuser-is-not-a-constructor)). That's why the integration tests are manually done. There would be a list of inputs and outputs to check of each command for tests while simultaneously serving as a help menu with examples of all possible inputs/outputs for others to see.
- An idea which will not be implemented is prompting the user for inputs during the tests. This is no better than manual tests, worse actually, because if this had to run before each commit, it'd quickly become a nightmare.
- Maybe take some ideas from something like [this](https://github.com/stuyy/jest-unit-tests-demo) in the future to get tests to properly work.
- Another possibility is to use `client.emit(...)` then mock the `message.channel.send(...)` function which would listen if the input is correct.

View file

@ -7,7 +7,7 @@
# Introduction
This is a brief overview that'll describe where and how to add new features to TravBot. For more details on specific functions, head over to the [documentation](Documentation.md). Assume the prefix for all of these examples is `$`.
This is a brief overview that'll describe where and how to add new features to TravBot. For more details on specific functions, head over to the [documentation](Documentation.md). TravBot uses the [Onion Lasers Command Handler](https://github.com/WatDuhHekBro/OnionLasers) to load and setup commands. Also, if you ever want to see the definition of a function or its surrounding types and you're using VSCode, put your cursor at the word you want to go to and press `[F12]`.
# Setting up the development environment
@ -20,175 +20,18 @@ This is a brief overview that'll describe where and how to add new features to T
# Adding a new command
To add a new command, go to `src/commands` and either copy the [template](../src/commands/template.ts) or rename the auto-generated test file (`../src/commands/test.ts`). For reference, this is the barebones requirement for a command file.
## The very basics of a command
To add a new command, go to `src/commands` and create a new `.ts` file named as the command name. Then, use and expand upon the following template.
```ts
import {NamedCommand} from "../core";
export default new NamedCommand();
```
To make something actually happen when the command is run however, you implement the `run` property.
```ts
import {Command, NamedCommand} from "../core";
import {Command, NamedCommand, RestCommand} from "onion-lasers";
export default new NamedCommand({
async run({message, channel, guild, author, member, client, args}) {
channel.send("test");
}
async run({send, message, channel, guild, author, member, client, args}) {
// code
}
});
```
### Quick note on the run property
You can also enter a string for the `run` property which will send a message with that string specified ([you can also specify some variables in that string](Documentation.md#command-var-string)). The above is functionally equivalent to the below.
```ts
import {Command, NamedCommand} from "../core";
export default new NamedCommand({
run: "test"
});
```
## Introducing subcommands
Where this command handler really shines though is from its subcommands feature. You can filter and parse argument lists in a declarative manner.
```ts
import {Command, NamedCommand} from "../core";
export default new NamedCommand({
user: new Command({
async run({message, channel, guild, author, member, client, args}) {
const user = args[0];
}
})
});
```
Here, . For example, if this file was named `test.ts`, `$test <@237359961842253835>` would get the user by the ID `237359961842253835` into `args[0]` as a [User](https://discord.js.org/#/docs/main/stable/class/User) object. `$test experiment` would run as if you just called `$test` *(given that [endpoint](Documentation.md#command-metadata) isn't set to `true`)*.
If you want, you can typecast the argument to be more strongly typed, because the type of `args` is `any[]`. *([See why if you're curious.](DesignDecisions.md#any[]-parameters-for-subcommand-run))*
```ts
import {Command, NamedCommand} from "../core";
import {User} from "discord.js";
export default new NamedCommand({
user: new Command({
async run({message, channel, guild, author, member, client, args}) {
const user = args[0] as User;
}
})
});
```
## Keyed subcommands
For keyed subcommands, you would instead use a `NamedCommand`.
```ts
import {Command, NamedCommand} from "../core";
export default new NamedCommand({
run: "one",
subcommands: {
bread: new NamedCommand({
run: "two"
})
}
});
```
If the file was named `cat.ts`:
- `$cat` would output `one`
- `$cat bread` would output `two`
Only `bread` in this case would lead to `two` being the output, which is different from the generic subcommand types in previous examples.
You get an additional property with `NamedCommand`s: `aliases`. That means you can define aliases not only for top-level commands, but also every layer of subcommands.
```ts
import {Command, NamedCommand} from "../core";
export default new NamedCommand({
aliases: ["potato"],
subcommands: {
slice: new NamedCommand({
aliases: ["pear"]
})
}
});
```
For example, if this file was named `plant.ts`, the following would work:
- `$plant`
- `$potato`
- `$plant slice`
- `$plant pear`
- `$potato slice`
- `$potato pear`
## Metadata / Command Properties
You can also specify metadata for commands by adding additional properties. Some of these properties are per-command while others are inherited.
```ts
import {Command, NamedCommand} from "../core";
export default new NamedCommand({
description: "desc one",
subcommands: {
pineapple: new NamedCommand({
//...
})
}
});
```
`description` is an example of a per-command property (which is used in a help command). If the file was named `siege.ts`:
- The description of `$siege` would be `desc one`.
- There wouldn't be a description for `$siege pineapple`.
This is in contrast to inherited properties.
```ts
import {Command, NamedCommand, CHANNEL_TYPE} from "../core";
export default new NamedCommand({
channelType: CHANNEL_TYPE.GUILD,
subcommands: {
pineapple: new NamedCommand({
//...
})
}
});
```
Here, the property `channelType` would spread to all subcommands unless a subcommand defines it. Using the above example, the `channelType` for both `$siege` and `$siege pineapple` would be `CHANNEL_TYPE.GUILD`.
*To get a full list of metadata properties, see the [documentation](Documentation.md#command-menu).*
## Utility Functions
You'll have to import these manually, however it's in the same import list as `Command` and `NamedCommand`.
```ts
import {Command, NamedCommand, paginate} from "../core";
export default new NamedCommand({
async run({message, channel, guild, author, member, client, args}) {
paginate(/* enter your code here */);
}
});
```
*To get a full list of utility functions, see the [documentation](Documentation.md#utility-functions).*
# Adding a new non-command feature
If the feature you want to add isn't specifically a command, or the change you're making involves adding event listeners, go to `src/modules` and create a file. Code written here won't be automatically loaded, so make sure to open [src/index.ts](../src/index.ts) and add an import statement at the bottom.
@ -201,7 +44,7 @@ This will just run whatever code is in there.
## Listening for events
Rather than have an `events` folder which contains dynamically loaded events, you add an event listener directly via `client.on("...", () => {})`. *([See why if you're curious.](DesignDecisions.md#static-event-loading))* The client can be imported from the index file.
Rather than have an `events` folder which contains dynamically loaded events, you add an event listener directly via `client.on("...", () => {})`. *([See why if you're curious.](https://github.com/WatDuhHekBro/OnionLasers/blob/master/README.md#static-event-loading))* The client can be imported from the index file.
```ts
import {client} from "..";