mirror of
https://github.com/keanuplayz/TravBot-v3.git
synced 2024-08-15 02:33:12 +00:00
Moved command handler code to Onion Lasers
This commit is contained in:
parent
6243570eb3
commit
b3ce4e5134
57 changed files with 183 additions and 2014 deletions
|
@ -1,3 +1,9 @@
|
|||
# 3.2.2
|
||||
- Moved command handler code to [Onion Lasers](https://github.com/WatDuhHekBro/OnionLasers)
|
||||
- Reworked `poll`
|
||||
- Extended stream notifications feature
|
||||
- Fixed various bugs
|
||||
|
||||
# 3.2.1
|
||||
- `vaporwave`: Transforms input into full-width text
|
||||
- `eco post`: A play on `eco get`
|
||||
|
|
|
@ -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.
|
|
@ -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.
|
||||
|
|
169
docs/Overview.md
169
docs/Overview.md
|
@ -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 "..";
|
||||
|
|
94
package-lock.json
generated
94
package-lock.json
generated
|
@ -20,6 +20,7 @@
|
|||
"mathjs": "^9.3.0",
|
||||
"moment": "^2.29.1",
|
||||
"ms": "^2.1.3",
|
||||
"onion-lasers": "^1.0.0",
|
||||
"relevant-urban": "^2.0.0",
|
||||
"translate-google": "^1.4.3",
|
||||
"weather-js": "^2.0.0"
|
||||
|
@ -39,6 +40,9 @@
|
|||
"ts-jest": "^26.4.4",
|
||||
"tsc-watch": "^4.2.9",
|
||||
"typescript": "^3.9.7"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "^2.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
|
@ -2275,18 +2279,18 @@
|
|||
}
|
||||
},
|
||||
"node_modules/discord.js": {
|
||||
"version": "12.5.1",
|
||||
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-12.5.1.tgz",
|
||||
"integrity": "sha512-VwZkVaUAIOB9mKdca0I5MefPMTQJTNg0qdgi1huF3iwsFwJ0L5s/Y69AQe+iPmjuV6j9rtKoG0Ta0n9vgEIL6w==",
|
||||
"version": "12.5.3",
|
||||
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-12.5.3.tgz",
|
||||
"integrity": "sha512-D3nkOa/pCkNyn6jLZnAiJApw2N9XrIsXUAdThf01i7yrEuqUmDGc7/CexVWwEcgbQR97XQ+mcnqJpmJ/92B4Aw==",
|
||||
"dependencies": {
|
||||
"@discordjs/collection": "^0.1.6",
|
||||
"@discordjs/form-data": "^3.0.1",
|
||||
"abort-controller": "^3.0.0",
|
||||
"node-fetch": "^2.6.1",
|
||||
"prism-media": "^1.2.2",
|
||||
"prism-media": "^1.2.9",
|
||||
"setimmediate": "^1.0.5",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"ws": "^7.3.1"
|
||||
"ws": "^7.4.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
|
@ -2925,6 +2929,19 @@
|
|||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
|
||||
|
@ -5388,6 +5405,15 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/onion-lasers": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/onion-lasers/-/onion-lasers-1.0.0.tgz",
|
||||
"integrity": "sha512-vVpywipeVUBMffvhxTeq8i+kuIO3zV+t2RQcPFo7XiUjfARR6Kq4CGyPhi9EhVXmylzwSDH+HzBzxZcdfajqng==",
|
||||
"dependencies": {
|
||||
"discord.js": "^12.5.3",
|
||||
"glob": "^7.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz",
|
||||
|
@ -5678,9 +5704,29 @@
|
|||
}
|
||||
},
|
||||
"node_modules/prism-media": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.2.3.tgz",
|
||||
"integrity": "sha512-fSrR66n0l6roW9Rx4rSLMyTPTjRTiXy5RVqDOurACQ6si1rKHHKDU5gwBJoCsIV0R3o9gi+K50akl/qyw1C74A=="
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.2.9.tgz",
|
||||
"integrity": "sha512-UHCYuqHipbTR1ZsXr5eg4JUmHER8Ss4YEb9Azn+9zzJ7/jlTtD1h0lc4g6tNx3eMlB8Mp6bfll0LPMAV4R6r3Q==",
|
||||
"peerDependencies": {
|
||||
"@discordjs/opus": "^0.5.0",
|
||||
"ffmpeg-static": "^4.2.7 || ^3.0.0 || ^2.4.0",
|
||||
"node-opus": "^0.3.3",
|
||||
"opusscript": "^0.0.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@discordjs/opus": {
|
||||
"optional": true
|
||||
},
|
||||
"ffmpeg-static": {
|
||||
"optional": true
|
||||
},
|
||||
"node-opus": {
|
||||
"optional": true
|
||||
},
|
||||
"opusscript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
|
@ -9922,18 +9968,18 @@
|
|||
"dev": true
|
||||
},
|
||||
"discord.js": {
|
||||
"version": "12.5.1",
|
||||
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-12.5.1.tgz",
|
||||
"integrity": "sha512-VwZkVaUAIOB9mKdca0I5MefPMTQJTNg0qdgi1huF3iwsFwJ0L5s/Y69AQe+iPmjuV6j9rtKoG0Ta0n9vgEIL6w==",
|
||||
"version": "12.5.3",
|
||||
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-12.5.3.tgz",
|
||||
"integrity": "sha512-D3nkOa/pCkNyn6jLZnAiJApw2N9XrIsXUAdThf01i7yrEuqUmDGc7/CexVWwEcgbQR97XQ+mcnqJpmJ/92B4Aw==",
|
||||
"requires": {
|
||||
"@discordjs/collection": "^0.1.6",
|
||||
"@discordjs/form-data": "^3.0.1",
|
||||
"abort-controller": "^3.0.0",
|
||||
"node-fetch": "^2.6.1",
|
||||
"prism-media": "^1.2.2",
|
||||
"prism-media": "^1.2.9",
|
||||
"setimmediate": "^1.0.5",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"ws": "^7.3.1"
|
||||
"ws": "^7.4.4"
|
||||
}
|
||||
},
|
||||
"discord.js-lavalink-lib": {
|
||||
|
@ -10437,6 +10483,12 @@
|
|||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
|
||||
},
|
||||
"fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"optional": true
|
||||
},
|
||||
"function-bind": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
|
||||
|
@ -12372,6 +12424,15 @@
|
|||
"mimic-fn": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"onion-lasers": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/onion-lasers/-/onion-lasers-1.0.0.tgz",
|
||||
"integrity": "sha512-vVpywipeVUBMffvhxTeq8i+kuIO3zV+t2RQcPFo7XiUjfARR6Kq4CGyPhi9EhVXmylzwSDH+HzBzxZcdfajqng==",
|
||||
"requires": {
|
||||
"discord.js": "^12.5.3",
|
||||
"glob": "^7.1.6"
|
||||
}
|
||||
},
|
||||
"optionator": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz",
|
||||
|
@ -12583,9 +12644,10 @@
|
|||
}
|
||||
},
|
||||
"prism-media": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.2.3.tgz",
|
||||
"integrity": "sha512-fSrR66n0l6roW9Rx4rSLMyTPTjRTiXy5RVqDOurACQ6si1rKHHKDU5gwBJoCsIV0R3o9gi+K50akl/qyw1C74A=="
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.2.9.tgz",
|
||||
"integrity": "sha512-UHCYuqHipbTR1ZsXr5eg4JUmHER8Ss4YEb9Azn+9zzJ7/jlTtD1h0lc4g6tNx3eMlB8Mp6bfll0LPMAV4R6r3Q==",
|
||||
"requires": {}
|
||||
},
|
||||
"process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
|
|
|
@ -23,6 +23,7 @@
|
|||
"mathjs": "^9.3.0",
|
||||
"moment": "^2.29.1",
|
||||
"ms": "^2.1.3",
|
||||
"onion-lasers": "^1.0.0",
|
||||
"relevant-urban": "^2.0.0",
|
||||
"translate-google": "^1.4.3",
|
||||
"weather-js": "^2.0.0"
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {NamedCommand, RestCommand} from "../../core";
|
||||
import {NamedCommand, RestCommand} from "onion-lasers";
|
||||
import {random} from "../../lib";
|
||||
|
||||
const responses = [
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import {User} from "discord.js";
|
||||
import {Command, NamedCommand} from "../../core";
|
||||
import {Command, NamedCommand} from "onion-lasers";
|
||||
import {random, parseVars} from "../../lib";
|
||||
|
||||
const cookies = [
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {Command, NamedCommand, getMemberByName, RestCommand} from "../../core";
|
||||
import {Command, NamedCommand, getMemberByName, RestCommand} from "onion-lasers";
|
||||
import {isAuthorized, getMoneyEmbed} from "./modules/eco-utils";
|
||||
import {DailyCommand, PayCommand, GuildCommand, LeaderboardCommand} from "./modules/eco-core";
|
||||
import {BuyCommand, ShopCommand} from "./modules/eco-shop";
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {NamedCommand, RestCommand} from "../../core";
|
||||
import {NamedCommand, RestCommand} from "onion-lasers";
|
||||
import figlet from "figlet";
|
||||
|
||||
export default new NamedCommand({
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {NamedCommand} from "../../core";
|
||||
import {NamedCommand} from "onion-lasers";
|
||||
|
||||
export default new NamedCommand({
|
||||
description: "Insult TravBot! >:D",
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {NamedCommand, CHANNEL_TYPE} from "../../core";
|
||||
import {NamedCommand, CHANNEL_TYPE} from "onion-lasers";
|
||||
|
||||
export default new NamedCommand({
|
||||
description: "Chooses someone to love.",
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {Command, NamedCommand, confirm, poll} from "../../../core";
|
||||
import {Command, NamedCommand, confirm, poll} from "onion-lasers";
|
||||
import {pluralise} from "../../../lib";
|
||||
import {Storage} from "../../../structures";
|
||||
import {isAuthorized, getMoneyEmbed} from "./eco-utils";
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {Command, getMemberByName, NamedCommand, confirm, RestCommand} from "../../../core";
|
||||
import {Command, getMemberByName, NamedCommand, confirm, RestCommand} from "onion-lasers";
|
||||
import {pluralise} from "../../../lib";
|
||||
import {Storage} from "../../../structures";
|
||||
import {isAuthorized, getMoneyEmbed, getSendEmbed, ECO_EMBED_COLOR} from "./eco-utils";
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {Command, NamedCommand} from "../../../core";
|
||||
import {Command, NamedCommand} from "onion-lasers";
|
||||
import {Storage} from "../../../structures";
|
||||
import {isAuthorized, getMoneyEmbed} from "./eco-utils";
|
||||
import {User} from "discord.js";
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {Command, NamedCommand, paginate, RestCommand} from "../../../core";
|
||||
import {Command, NamedCommand, paginate, RestCommand} from "onion-lasers";
|
||||
import {pluralise, split} from "../../../lib";
|
||||
import {Storage, getPrefix} from "../../../structures";
|
||||
import {isAuthorized, ECO_EMBED_COLOR} from "./eco-utils";
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import {URL} from "url";
|
||||
import {Command, NamedCommand} from "../../core";
|
||||
import {Command, NamedCommand} from "onion-lasers";
|
||||
import {getContent} from "../../lib";
|
||||
|
||||
const endpoints: {sfw: {[key: string]: string}} = {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {NamedCommand} from "../../core";
|
||||
import {NamedCommand} from "onion-lasers";
|
||||
import {random} from "../../lib";
|
||||
|
||||
const responses = [
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {NamedCommand, RestCommand} from "../../core";
|
||||
import {NamedCommand, RestCommand} from "onion-lasers";
|
||||
import {getContent} from "../../lib";
|
||||
import {URL} from "url";
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {NamedCommand} from "../../core";
|
||||
import {NamedCommand} from "onion-lasers";
|
||||
|
||||
export default new NamedCommand({
|
||||
description: "Initiates a celebratory stream from the bot.",
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import {MessageEmbed, Message, User} from "discord.js";
|
||||
import {NamedCommand, RestCommand, poll, CHANNEL_TYPE, SendFunction, Command} from "../../core";
|
||||
import {NamedCommand, RestCommand, poll, CHANNEL_TYPE, SendFunction, Command} from "onion-lasers";
|
||||
import {pluralise} from "../../lib";
|
||||
|
||||
export default new NamedCommand({
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {Command, NamedCommand} from "../../core";
|
||||
import {Command, NamedCommand} from "onion-lasers";
|
||||
import {Random} from "../../lib";
|
||||
|
||||
export default new NamedCommand({
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {NamedCommand, RestCommand} from "../../core";
|
||||
import {NamedCommand, RestCommand} from "onion-lasers";
|
||||
|
||||
const letters: {[letter: string]: string[]} = {
|
||||
a: "aáàảãạâấầẩẫậăắằẳẵặ".split(""),
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {NamedCommand, RestCommand} from "../../core";
|
||||
import {NamedCommand, RestCommand} from "onion-lasers";
|
||||
import {MessageEmbed} from "discord.js";
|
||||
import urban from "relevant-urban";
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {NamedCommand, RestCommand} from "../../core";
|
||||
import {NamedCommand, RestCommand} from "onion-lasers";
|
||||
|
||||
const vaporwave = (() => {
|
||||
const map = new Map<string, string>();
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {NamedCommand, RestCommand} from "../../core";
|
||||
import {NamedCommand, RestCommand} from "onion-lasers";
|
||||
import {MessageEmbed} from "discord.js";
|
||||
import {find} from "weather-js";
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import {User} from "discord.js";
|
||||
import {Command, NamedCommand, getMemberByName, CHANNEL_TYPE, RestCommand} from "../../core";
|
||||
import {Command, NamedCommand, getMemberByName, CHANNEL_TYPE, RestCommand} from "onion-lasers";
|
||||
|
||||
// Quotes must be used here or the numbers will change
|
||||
const registry: {[id: string]: string} = {
|
||||
|
|
|
@ -1,15 +1,7 @@
|
|||
import {
|
||||
Command,
|
||||
NamedCommand,
|
||||
botHasPermission,
|
||||
getPermissionLevel,
|
||||
getPermissionName,
|
||||
CHANNEL_TYPE,
|
||||
RestCommand
|
||||
} from "../../core";
|
||||
import {Command, NamedCommand, getPermissionLevel, getPermissionName, CHANNEL_TYPE, RestCommand} from "onion-lasers";
|
||||
import {clean} from "../../lib";
|
||||
import {Config, Storage} from "../../structures";
|
||||
import {Permissions, TextChannel, User, Role} from "discord.js";
|
||||
import {Permissions, TextChannel, User, Role, Channel} from "discord.js";
|
||||
import {logs} from "../../modules/globals";
|
||||
|
||||
function getLogBuffer(type: string) {
|
||||
|
@ -111,10 +103,15 @@ export default new NamedCommand({
|
|||
id: "channel",
|
||||
channel: new Command({
|
||||
async run({send, guild, args}) {
|
||||
const result = args[0] as TextChannel;
|
||||
const result = args[0] as Channel;
|
||||
|
||||
if (result instanceof TextChannel) {
|
||||
Storage.getGuild(guild!.id).welcomeChannel = result.id;
|
||||
Storage.save();
|
||||
send(`Successfully set this server's welcome channel to ${result}.`);
|
||||
} else {
|
||||
send(`\`${result.id}\` is not a valid text channel!`);
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
@ -156,10 +153,15 @@ export default new NamedCommand({
|
|||
id: "channel",
|
||||
channel: new Command({
|
||||
async run({send, guild, args}) {
|
||||
const result = args[0] as TextChannel;
|
||||
const result = args[0] as Channel;
|
||||
|
||||
if (result instanceof TextChannel) {
|
||||
Storage.getGuild(guild!.id).streamingChannel = result.id;
|
||||
Storage.save();
|
||||
send(`Successfully set this server's stream notifications channel to ${result}.`);
|
||||
} else {
|
||||
send(`\`${result.id}\` is not a valid text channel!`);
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
@ -250,7 +252,7 @@ export default new NamedCommand({
|
|||
channelType: CHANNEL_TYPE.GUILD,
|
||||
async run({send, message, channel, guild, client}) {
|
||||
// It's probably better to go through the bot's own messages instead of calling bulkDelete which requires MANAGE_MESSAGES.
|
||||
if (botHasPermission(guild, Permissions.FLAGS.MANAGE_MESSAGES)) {
|
||||
if (guild!.me?.hasPermission(Permissions.FLAGS.MANAGE_MESSAGES)) {
|
||||
message.delete();
|
||||
const msgs = await channel.messages.fetch({
|
||||
limit: 100
|
||||
|
@ -310,7 +312,7 @@ export default new NamedCommand({
|
|||
any: new RestCommand({
|
||||
async run({send, message, guild, combined}) {
|
||||
await guild!.me?.setNickname(combined);
|
||||
if (botHasPermission(guild, Permissions.FLAGS.MANAGE_MESSAGES)) message.delete({timeout: 5000});
|
||||
if (guild!.me?.hasPermission(Permissions.FLAGS.MANAGE_MESSAGES)) message.delete({timeout: 5000});
|
||||
send(`Nickname set to \`${combined}\``).then((m) => m.delete({timeout: 5000}));
|
||||
}
|
||||
})
|
||||
|
@ -363,10 +365,15 @@ export default new NamedCommand({
|
|||
},
|
||||
channel: new Command({
|
||||
async run({send, args}) {
|
||||
const targetChannel = args[0] as TextChannel;
|
||||
const targetChannel = args[0] as Channel;
|
||||
|
||||
if (targetChannel instanceof TextChannel) {
|
||||
Config.systemLogsChannel = targetChannel.id;
|
||||
Config.save();
|
||||
send(`Successfully set ${targetChannel} as the system logs channel.`);
|
||||
} else {
|
||||
send(`\`${targetChannel.id}\` is not a valid text channel!`);
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
@ -6,7 +6,7 @@ import {
|
|||
getCommandList,
|
||||
getCommandInfo,
|
||||
paginate
|
||||
} from "../../core";
|
||||
} from "onion-lasers";
|
||||
import {requireAllCasesHandledFor} from "../../lib";
|
||||
import {MessageEmbed} from "discord.js";
|
||||
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
import {Command, NamedCommand, RestCommand} from "../core";
|
||||
|
||||
export default new NamedCommand({
|
||||
async run({send, message, channel, guild, author, member, client, args}) {
|
||||
// code
|
||||
}
|
||||
});
|
|
@ -1,4 +1,4 @@
|
|||
import {NamedCommand, RestCommand} from "../../core";
|
||||
import {NamedCommand, RestCommand} from "onion-lasers";
|
||||
import * as math from "mathjs";
|
||||
import {MessageEmbed} from "discord.js";
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {NamedCommand} from "../../core";
|
||||
import {NamedCommand} from "onion-lasers";
|
||||
|
||||
export default new NamedCommand({
|
||||
description: "Gives you the Github link.",
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {NamedCommand, RestCommand} from "../../core";
|
||||
import {NamedCommand, RestCommand} from "onion-lasers";
|
||||
|
||||
export default new NamedCommand({
|
||||
description: "Renames current voice channel.",
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {NamedCommand, RestCommand} from "../../core";
|
||||
import {NamedCommand, RestCommand} from "onion-lasers";
|
||||
import {URL} from "url";
|
||||
import {getContent} from "../../lib";
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {NamedCommand, RestCommand} from "../../core";
|
||||
import {NamedCommand, RestCommand} from "onion-lasers";
|
||||
import {processEmoteQueryFormatted} from "./modules/emote-utils";
|
||||
|
||||
export default new NamedCommand({
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import {MessageEmbed, version as djsversion, Guild, User, GuildMember} from "discord.js";
|
||||
import ms from "ms";
|
||||
import os from "os";
|
||||
import {Command, NamedCommand, getMemberByName, CHANNEL_TYPE, getGuildByName, RestCommand} from "../../core";
|
||||
import {Command, NamedCommand, getMemberByName, CHANNEL_TYPE, getGuildByName, RestCommand} from "onion-lasers";
|
||||
import {formatBytes, trimArray} from "../../lib";
|
||||
import {verificationLevels, filterLevels, regions} from "../../defs/info";
|
||||
import moment, {utc} from "moment";
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {Command, NamedCommand} from "../../core";
|
||||
import {Command, NamedCommand} from "onion-lasers";
|
||||
|
||||
export default new NamedCommand({
|
||||
description: "Gives you the invite link.",
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import {GuildEmoji, MessageEmbed, User} from "discord.js";
|
||||
import {NamedCommand, RestCommand, paginate, SendFunction} from "../../core";
|
||||
import {NamedCommand, RestCommand, paginate, SendFunction} from "onion-lasers";
|
||||
import {split} from "../../lib";
|
||||
import vm from "vm";
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {NamedCommand, RestCommand} from "../../core";
|
||||
import {NamedCommand, RestCommand} from "onion-lasers";
|
||||
import {Message, Channel, TextChannel} from "discord.js";
|
||||
import {processEmoteQueryArray} from "./modules/emote-utils";
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {NamedCommand, RestCommand} from "../../core";
|
||||
import {NamedCommand, RestCommand} from "onion-lasers";
|
||||
|
||||
export default new NamedCommand({
|
||||
description: "Repeats your message.",
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {NamedCommand, CHANNEL_TYPE} from "../../core";
|
||||
import {NamedCommand, CHANNEL_TYPE} from "onion-lasers";
|
||||
import {pluralise} from "../../lib";
|
||||
import moment from "moment";
|
||||
import {Collection, TextChannel} from "discord.js";
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {Command, NamedCommand} from "../../core";
|
||||
import {Command, NamedCommand} from "onion-lasers";
|
||||
import * as https from "https";
|
||||
|
||||
export default new NamedCommand({
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {NamedCommand, RestCommand} from "../../core";
|
||||
import {NamedCommand, RestCommand} from "onion-lasers";
|
||||
import {streamList} from "../../modules/streamNotifications";
|
||||
import {Storage} from "../../structures";
|
||||
|
||||
|
|
|
@ -1,4 +1,12 @@
|
|||
import {Command, NamedCommand, askForReply, confirm, askMultipleChoice, getMemberByName, RestCommand} from "../../core";
|
||||
import {
|
||||
Command,
|
||||
NamedCommand,
|
||||
askForReply,
|
||||
confirm,
|
||||
askMultipleChoice,
|
||||
getMemberByName,
|
||||
RestCommand
|
||||
} from "onion-lasers";
|
||||
import {Storage} from "../../structures";
|
||||
import {User} from "discord.js";
|
||||
import moment from "moment";
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {NamedCommand, RestCommand} from "../../core";
|
||||
import {NamedCommand, RestCommand} from "onion-lasers";
|
||||
import moment from "moment";
|
||||
import {Storage} from "../../structures";
|
||||
import {MessageEmbed} from "discord.js";
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {Command, NamedCommand, RestCommand} from "../../core";
|
||||
import {Command, NamedCommand, RestCommand} from "onion-lasers";
|
||||
import translate from "translate-google";
|
||||
|
||||
export default new NamedCommand({
|
||||
|
|
|
@ -1,751 +0,0 @@
|
|||
import {
|
||||
Collection,
|
||||
Client,
|
||||
Message,
|
||||
TextChannel,
|
||||
DMChannel,
|
||||
NewsChannel,
|
||||
Guild,
|
||||
User,
|
||||
GuildMember,
|
||||
GuildChannel,
|
||||
Channel
|
||||
} from "discord.js";
|
||||
import {getChannelByID, getGuildByID, getMessageByID, getUserByID, SendFunction} from "./libd";
|
||||
import {hasPermission, getPermissionLevel, getPermissionName} from "./permissions";
|
||||
import {getPrefix} from "./interface";
|
||||
import {parseVars, requireAllCasesHandledFor} from "../lib";
|
||||
|
||||
/**
|
||||
* ===[ Command Types ]===
|
||||
* SUBCOMMAND - Any specifically-defined keywords / string literals.
|
||||
* CHANNEL - <#...>
|
||||
* ROLE - <@&...>
|
||||
* EMOTE - <::ID> (The previous two values, animated and emote name respectively, do not matter at all for finding the emote.)
|
||||
* MESSAGE - Available by using the built-in "Copy Message Link" or "Copy ID" buttons. https://discordapp.com/channels/<Guild ID>/<Channel ID>/<Message ID> or <Channel ID>-<Message ID> (automatically searches all guilds for the channel ID).
|
||||
* USER - <@...> and <@!...>
|
||||
* ID - Any number with 17-19 digits. Only used as a redirect to another subcommand type.
|
||||
* NUMBER - Any valid number via the Number() function, except for NaN and Infinity (because those can really mess with the program).
|
||||
* ANY - Generic argument case.
|
||||
* NONE - No subcommands exist.
|
||||
*/
|
||||
|
||||
// RegEx patterns used for identifying/extracting each type from a string argument.
|
||||
// The reason why \d{17,} is used is because the max safe number for JS numbers is 16 characters when stringified (decimal). Beyond that are IDs.
|
||||
const patterns = {
|
||||
channel: /^<#(\d{17,})>$/,
|
||||
role: /^<@&(\d{17,})>$/,
|
||||
emote: /^<a?:.*?:(\d{17,})>$/,
|
||||
// The message type won't include <username>#<tag>. At that point, you may as well just use a search usernames function. Even then, tags would only be taken into account to differentiate different users with identical usernames.
|
||||
messageLink: /^https?:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/channels\/(?:\d{17,}|@me)\/(\d{17,})\/(\d{17,})$/,
|
||||
messagePair: /^(\d{17,})-(\d{17,})$/,
|
||||
user: /^<@!?(\d{17,})>$/,
|
||||
id: /^(\d{17,})$/
|
||||
};
|
||||
|
||||
// Maybe add a guild redirect... somehow?
|
||||
type ID = "channel" | "role" | "emote" | "message" | "user" | "guild";
|
||||
|
||||
// Callbacks don't work with discriminated unions:
|
||||
// - https://github.com/microsoft/TypeScript/issues/41759
|
||||
// - https://github.com/microsoft/TypeScript/issues/35769
|
||||
// Therefore, there won't by any type narrowing on channel or guild of CommandMenu until this is fixed.
|
||||
// Otherwise, you'd have to define channelType for every single subcommand, which would get very tedious.
|
||||
// Just use type assertions when you specify a channel type.
|
||||
export enum CHANNEL_TYPE {
|
||||
ANY,
|
||||
GUILD,
|
||||
DM
|
||||
}
|
||||
|
||||
interface CommandMenu {
|
||||
readonly args: any[];
|
||||
readonly client: Client;
|
||||
readonly message: Message;
|
||||
readonly channel: TextChannel | DMChannel | NewsChannel;
|
||||
readonly guild: Guild | null;
|
||||
readonly author: User;
|
||||
// According to the documentation, a message can be part of a guild while also not having a
|
||||
// member object for the author. This will happen if the author of a message left the guild.
|
||||
readonly member: GuildMember | null;
|
||||
readonly send: SendFunction;
|
||||
}
|
||||
|
||||
interface CommandOptionsBase {
|
||||
readonly description?: string;
|
||||
readonly usage?: string;
|
||||
readonly permission?: number;
|
||||
readonly nsfw?: boolean;
|
||||
readonly channelType?: CHANNEL_TYPE;
|
||||
}
|
||||
|
||||
// Also, contrary to what you might think, channel pings do still work in DM channels.
|
||||
// Role pings, maybe not, but it's not a big deal.
|
||||
interface CommandOptions extends CommandOptionsBase {
|
||||
readonly run?: (($: CommandMenu) => Promise<any>) | string;
|
||||
readonly subcommands?: {[key: string]: NamedCommand};
|
||||
readonly channel?: Command;
|
||||
readonly role?: Command;
|
||||
readonly emote?: Command;
|
||||
readonly message?: Command;
|
||||
readonly user?: Command;
|
||||
readonly guild?: Command; // Only available if an ID is set to reroute to it.
|
||||
readonly id?: ID;
|
||||
readonly number?: Command;
|
||||
readonly any?: Command | RestCommand;
|
||||
}
|
||||
|
||||
interface NamedCommandOptions extends CommandOptions {
|
||||
readonly aliases?: string[];
|
||||
readonly nameOverride?: string;
|
||||
}
|
||||
|
||||
interface RestCommandOptions extends CommandOptionsBase {
|
||||
readonly run?: (($: CommandMenu & {readonly combined: string}) => Promise<any>) | string;
|
||||
}
|
||||
|
||||
interface ExecuteCommandMetadata {
|
||||
readonly header: string;
|
||||
readonly args: string[];
|
||||
permission: number;
|
||||
nsfw: boolean;
|
||||
channelType: CHANNEL_TYPE;
|
||||
symbolicArgs: string[]; // i.e. <channel> instead of <#...>
|
||||
}
|
||||
|
||||
export interface CommandInfo {
|
||||
readonly type: "info";
|
||||
readonly command: BaseCommand;
|
||||
readonly subcommandInfo: Collection<string, BaseCommand>;
|
||||
readonly keyedSubcommandInfo: Collection<string, BaseCommand>;
|
||||
readonly permission: number;
|
||||
readonly nsfw: boolean;
|
||||
readonly channelType: CHANNEL_TYPE;
|
||||
readonly args: string[];
|
||||
readonly header: string;
|
||||
}
|
||||
|
||||
interface CommandInfoError {
|
||||
readonly type: "error";
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
interface CommandInfoMetadata {
|
||||
permission: number;
|
||||
nsfw: boolean;
|
||||
channelType: CHANNEL_TYPE;
|
||||
args: string[];
|
||||
usage: string;
|
||||
readonly originalArgs: string[];
|
||||
readonly header: string;
|
||||
}
|
||||
|
||||
// An isolated command of just the metadata.
|
||||
abstract class BaseCommand {
|
||||
public readonly description: string;
|
||||
public readonly usage: string;
|
||||
public readonly permission: number; // -1 (default) indicates to inherit, 0 is the lowest rank, 1 is second lowest rank, and so on.
|
||||
public readonly nsfw: boolean | null; // null (default) indicates to inherit
|
||||
public readonly channelType: CHANNEL_TYPE | null; // null (default) indicates to inherit
|
||||
|
||||
constructor(options?: CommandOptionsBase) {
|
||||
this.description = options?.description || "No description.";
|
||||
this.usage = options?.usage ?? "";
|
||||
this.permission = options?.permission ?? -1;
|
||||
this.nsfw = options?.nsfw ?? null;
|
||||
this.channelType = options?.channelType ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
// Each Command instance represents a block that links other Command instances under it.
|
||||
export class Command extends BaseCommand {
|
||||
// The execute and subcommand properties are restricted to the class because subcommand recursion could easily break when manually handled.
|
||||
// The class will handle checking for null fields.
|
||||
private run: (($: CommandMenu) => Promise<any>) | string;
|
||||
private readonly subcommands: Collection<string, NamedCommand>; // This is the final data structure you'll actually use to work with the commands the aliases point to.
|
||||
private channel: Command | null;
|
||||
private role: Command | null;
|
||||
private emote: Command | null;
|
||||
private message: Command | null;
|
||||
private user: Command | null;
|
||||
private guild: Command | null;
|
||||
private id: Command | null;
|
||||
private idType: ID | null;
|
||||
private number: Command | null;
|
||||
private any: Command | RestCommand | null;
|
||||
|
||||
constructor(options?: CommandOptions) {
|
||||
super(options);
|
||||
this.run = options?.run || "No action was set on this command!";
|
||||
this.subcommands = new Collection(); // Populate this collection after setting subcommands.
|
||||
this.channel = options?.channel || null;
|
||||
this.role = options?.role || null;
|
||||
this.emote = options?.emote || null;
|
||||
this.message = options?.message || null;
|
||||
this.user = options?.user || null;
|
||||
this.guild = options?.guild || null;
|
||||
this.id = null;
|
||||
this.idType = options?.id || null;
|
||||
this.number = options?.number || null;
|
||||
this.any = options?.any || null;
|
||||
|
||||
if (options)
|
||||
switch (options.id) {
|
||||
case "channel":
|
||||
this.id = this.channel;
|
||||
break;
|
||||
case "role":
|
||||
this.id = this.role;
|
||||
break;
|
||||
case "emote":
|
||||
this.id = this.emote;
|
||||
break;
|
||||
case "message":
|
||||
this.id = this.message;
|
||||
break;
|
||||
case "user":
|
||||
this.id = this.user;
|
||||
break;
|
||||
case "guild":
|
||||
this.id = this.guild;
|
||||
break;
|
||||
case undefined:
|
||||
break;
|
||||
default:
|
||||
requireAllCasesHandledFor(options.id);
|
||||
}
|
||||
|
||||
if (options?.subcommands) {
|
||||
const baseSubcommands = Object.keys(options.subcommands);
|
||||
|
||||
// Loop once to set the base subcommands.
|
||||
for (const name in options.subcommands) this.subcommands.set(name, options.subcommands[name]);
|
||||
|
||||
// Then loop again to make aliases point to the base subcommands and warn if something's not right.
|
||||
// This shouldn't be a problem because I'm hoping that JS stores these as references that point to the same object.
|
||||
for (const name in options.subcommands) {
|
||||
const subcmd = options.subcommands[name];
|
||||
subcmd.name = name;
|
||||
const aliases = subcmd.aliases;
|
||||
|
||||
for (const alias of aliases) {
|
||||
if (baseSubcommands.includes(alias))
|
||||
console.warn(
|
||||
`"${alias}" in subcommand "${name}" was attempted to be declared as an alias but it already exists in the base commands! (Look at the next "Loading Command" line to see which command is affected.)`
|
||||
);
|
||||
else if (this.subcommands.has(alias))
|
||||
console.warn(
|
||||
`Duplicate alias "${alias}" at subcommand "${name}"! (Look at the next "Loading Command" line to see which command is affected.)`
|
||||
);
|
||||
else this.subcommands.set(alias, subcmd);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Go through the arguments provided and find the right subcommand, then execute with the given arguments.
|
||||
// Will return null if it successfully executes, string if there's an error (to let the user know what it is).
|
||||
//
|
||||
// Calls the resulting subcommand's execute method in order to make more modular code, basically pushing the chain of execution to the subcommand.
|
||||
// For example, a numeric subcommand would accept args of [4] then execute on it.
|
||||
//
|
||||
// Because each Command instance is isolated from others, it becomes practically impossible to predict the total amount of subcommands when isolating the code to handle each individual layer of recursion.
|
||||
// Therefore, if a Command is declared as a rest type, any typed args that come at the end must be handled manually.
|
||||
public async execute(args: string[], menu: CommandMenu, metadata: ExecuteCommandMetadata): Promise<string | null> {
|
||||
// Update inherited properties if the current command specifies a property.
|
||||
// In case there are no initial arguments, these should go first so that it can register.
|
||||
if (this.permission !== -1) metadata.permission = this.permission;
|
||||
if (this.nsfw !== null) metadata.nsfw = this.nsfw;
|
||||
if (this.channelType !== null) metadata.channelType = this.channelType;
|
||||
|
||||
// Take off the leftmost argument from the list.
|
||||
const param = args.shift();
|
||||
|
||||
// If there are no arguments left, execute the current command. Otherwise, continue on.
|
||||
if (param === undefined) {
|
||||
const error = canExecute(menu, metadata);
|
||||
if (error) return error;
|
||||
|
||||
if (typeof this.run === "string") {
|
||||
// Although I *could* add an option in the launcher to attach arbitrary variables to this var string...
|
||||
// I'll just leave it like this, because instead of using var strings for user stuff, you could just make "run" a template string.
|
||||
await menu.send(
|
||||
parseVars(
|
||||
this.run,
|
||||
{
|
||||
author: menu.author.toString(),
|
||||
prefix: getPrefix(menu.guild),
|
||||
command: `${metadata.header} ${metadata.symbolicArgs.join(", ")}`
|
||||
},
|
||||
"???"
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// Then capture any potential errors.
|
||||
try {
|
||||
await this.run(menu);
|
||||
} catch (error) {
|
||||
const errorMessage = error.stack ?? error;
|
||||
console.error(`Command Error: ${metadata.header} (${metadata.args.join(", ")})\n${errorMessage}`);
|
||||
|
||||
return `There was an error while trying to execute that command!\`\`\`${errorMessage}\`\`\``;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resolve the value of the current command's argument (adding it to the resolved args),
|
||||
// then pass the thread of execution to whichever subcommand is valid (if any).
|
||||
const isMessageLink = patterns.messageLink.test(param);
|
||||
const isMessagePair = patterns.messagePair.test(param);
|
||||
|
||||
if (this.subcommands.has(param)) {
|
||||
metadata.symbolicArgs.push(param);
|
||||
return this.subcommands.get(param)!.execute(args, menu, metadata);
|
||||
} else if (this.channel && patterns.channel.test(param)) {
|
||||
const id = patterns.channel.exec(param)![1];
|
||||
const channel = await getChannelByID(id);
|
||||
|
||||
if (typeof channel !== "string") {
|
||||
if (channel instanceof TextChannel || channel instanceof DMChannel) {
|
||||
metadata.symbolicArgs.push("<channel>");
|
||||
menu.args.push(channel);
|
||||
return this.channel.execute(args, menu, metadata);
|
||||
} else {
|
||||
return `\`${id}\` is not a valid text channel!`;
|
||||
}
|
||||
} else {
|
||||
return channel;
|
||||
}
|
||||
} else if (this.role && patterns.role.test(param)) {
|
||||
const id = patterns.role.exec(param)![1];
|
||||
|
||||
if (!menu.guild) {
|
||||
return "You can't use role parameters in DM channels!";
|
||||
}
|
||||
|
||||
const role = menu.guild.roles.cache.get(id);
|
||||
|
||||
if (role) {
|
||||
metadata.symbolicArgs.push("<role>");
|
||||
menu.args.push(role);
|
||||
return this.role.execute(args, menu, metadata);
|
||||
} else {
|
||||
return `\`${id}\` is not a valid role in this server!`;
|
||||
}
|
||||
} else if (this.emote && patterns.emote.test(param)) {
|
||||
const id = patterns.emote.exec(param)![1];
|
||||
const emote = menu.client.emojis.cache.get(id);
|
||||
|
||||
if (emote) {
|
||||
metadata.symbolicArgs.push("<emote>");
|
||||
menu.args.push(emote);
|
||||
return this.emote.execute(args, menu, metadata);
|
||||
} else {
|
||||
return `\`${id}\` isn't a valid emote!`;
|
||||
}
|
||||
} else if (this.message && (isMessageLink || isMessagePair)) {
|
||||
let channelID = "";
|
||||
let messageID = "";
|
||||
|
||||
if (isMessageLink) {
|
||||
const result = patterns.messageLink.exec(param)!;
|
||||
channelID = result[1];
|
||||
messageID = result[2];
|
||||
} else if (isMessagePair) {
|
||||
const result = patterns.messagePair.exec(param)!;
|
||||
channelID = result[1];
|
||||
messageID = result[2];
|
||||
}
|
||||
|
||||
const message = await getMessageByID(channelID, messageID);
|
||||
|
||||
if (typeof message !== "string") {
|
||||
metadata.symbolicArgs.push("<message>");
|
||||
menu.args.push(message);
|
||||
return this.message.execute(args, menu, metadata);
|
||||
} else {
|
||||
return message;
|
||||
}
|
||||
} else if (this.user && patterns.user.test(param)) {
|
||||
const id = patterns.user.exec(param)![1];
|
||||
const user = await getUserByID(id);
|
||||
|
||||
if (typeof user !== "string") {
|
||||
metadata.symbolicArgs.push("<user>");
|
||||
menu.args.push(user);
|
||||
return this.user.execute(args, menu, metadata);
|
||||
} else {
|
||||
return user;
|
||||
}
|
||||
} else if (this.id && this.idType && patterns.id.test(param)) {
|
||||
metadata.symbolicArgs.push("<id>");
|
||||
const id = patterns.id.exec(param)![1];
|
||||
|
||||
// Probably modularize the findXByY code in general in libd.
|
||||
// Because this part is pretty much a whole bunch of copy pastes.
|
||||
switch (this.idType) {
|
||||
case "channel":
|
||||
const channel = await getChannelByID(id);
|
||||
|
||||
if (typeof channel !== "string") {
|
||||
if (channel instanceof TextChannel || channel instanceof DMChannel) {
|
||||
metadata.symbolicArgs.push("<channel>");
|
||||
menu.args.push(channel);
|
||||
return this.id.execute(args, menu, metadata);
|
||||
} else {
|
||||
return `\`${id}\` is not a valid text channel!`;
|
||||
}
|
||||
} else {
|
||||
return channel;
|
||||
}
|
||||
case "role":
|
||||
if (!menu.guild) {
|
||||
return "You can't use role parameters in DM channels!";
|
||||
}
|
||||
|
||||
const role = menu.guild.roles.cache.get(id);
|
||||
|
||||
if (role) {
|
||||
menu.args.push(role);
|
||||
return this.id.execute(args, menu, metadata);
|
||||
} else {
|
||||
return `\`${id}\` isn't a valid role in this server!`;
|
||||
}
|
||||
case "emote":
|
||||
const emote = menu.client.emojis.cache.get(id);
|
||||
|
||||
if (emote) {
|
||||
menu.args.push(emote);
|
||||
return this.id.execute(args, menu, metadata);
|
||||
} else {
|
||||
return `\`${id}\` isn't a valid emote!`;
|
||||
}
|
||||
case "message":
|
||||
const message = await getMessageByID(menu.channel, id);
|
||||
|
||||
if (typeof message !== "string") {
|
||||
menu.args.push(message);
|
||||
return this.id.execute(args, menu, metadata);
|
||||
} else {
|
||||
return message;
|
||||
}
|
||||
case "user":
|
||||
const user = await getUserByID(id);
|
||||
|
||||
if (typeof user !== "string") {
|
||||
menu.args.push(user);
|
||||
return this.id.execute(args, menu, metadata);
|
||||
} else {
|
||||
return user;
|
||||
}
|
||||
case "guild":
|
||||
const guild = getGuildByID(id);
|
||||
|
||||
if (typeof guild !== "string") {
|
||||
menu.args.push(guild);
|
||||
return this.id.execute(args, menu, metadata);
|
||||
} else {
|
||||
return guild;
|
||||
}
|
||||
default:
|
||||
requireAllCasesHandledFor(this.idType);
|
||||
}
|
||||
} else if (this.number && !Number.isNaN(Number(param)) && param !== "Infinity" && param !== "-Infinity") {
|
||||
metadata.symbolicArgs.push("<number>");
|
||||
menu.args.push(Number(param));
|
||||
return this.number.execute(args, menu, metadata);
|
||||
} else if (this.any instanceof Command) {
|
||||
metadata.symbolicArgs.push("<any>");
|
||||
menu.args.push(param);
|
||||
return this.any.execute(args, menu, metadata);
|
||||
} else if (this.any instanceof RestCommand) {
|
||||
metadata.symbolicArgs.push("<...>");
|
||||
args.unshift(param);
|
||||
menu.args.push(...args);
|
||||
return this.any.execute(args.join(" "), menu, metadata);
|
||||
} else {
|
||||
metadata.symbolicArgs.push(`"${param}"`);
|
||||
return `No valid command sequence matching \`${metadata.header} ${metadata.symbolicArgs.join(
|
||||
" "
|
||||
)}\` found.`;
|
||||
}
|
||||
|
||||
// Note: Do NOT add a return statement here. In case one of the other sections is missing
|
||||
// a return statement, there'll be a compile error to catch that.
|
||||
}
|
||||
|
||||
// What this does is resolve the resulting subcommand as well as the inherited properties and the available subcommands.
|
||||
public resolveInfo(args: string[], header: string): CommandInfo | CommandInfoError {
|
||||
return this.resolveInfoInternal(args, {
|
||||
permission: 0,
|
||||
nsfw: false,
|
||||
channelType: CHANNEL_TYPE.ANY,
|
||||
header,
|
||||
args: [],
|
||||
usage: "",
|
||||
originalArgs: [...args]
|
||||
});
|
||||
}
|
||||
|
||||
private resolveInfoInternal(args: string[], metadata: CommandInfoMetadata): CommandInfo | CommandInfoError {
|
||||
// Update inherited properties if the current command specifies a property.
|
||||
// In case there are no initial arguments, these should go first so that it can register.
|
||||
if (this.permission !== -1) metadata.permission = this.permission;
|
||||
if (this.nsfw !== null) metadata.nsfw = this.nsfw;
|
||||
if (this.channelType !== null) metadata.channelType = this.channelType;
|
||||
if (this.usage !== "") metadata.usage = this.usage;
|
||||
|
||||
// Take off the leftmost argument from the list.
|
||||
const param = args.shift();
|
||||
|
||||
// If there are no arguments left, return the data or an error message.
|
||||
if (param === undefined) {
|
||||
const keyedSubcommandInfo = new Collection<string, BaseCommand>();
|
||||
const subcommandInfo = new Collection<string, BaseCommand>();
|
||||
|
||||
// Get all the subcommands of the current command but without aliases.
|
||||
for (const [tag, command] of this.subcommands.entries()) {
|
||||
// Don't capture duplicates generated from aliases.
|
||||
if (tag === command.name) {
|
||||
keyedSubcommandInfo.set(tag, command);
|
||||
}
|
||||
}
|
||||
|
||||
// Then get all the generic subcommands.
|
||||
if (this.channel) subcommandInfo.set("<channel>", this.channel);
|
||||
if (this.role) subcommandInfo.set("<role>", this.role);
|
||||
if (this.emote) subcommandInfo.set("<emote>", this.emote);
|
||||
if (this.message) subcommandInfo.set("<message>", this.message);
|
||||
if (this.user) subcommandInfo.set("<user>", this.user);
|
||||
if (this.id) subcommandInfo.set(`<id = <${this.idType}>>`, this.id);
|
||||
if (this.number) subcommandInfo.set("<number>", this.number);
|
||||
|
||||
// The special case for a possible rest command.
|
||||
if (this.any) {
|
||||
if (this.any instanceof Command) subcommandInfo.set("<any>", this.any);
|
||||
else subcommandInfo.set("<...>", this.any);
|
||||
}
|
||||
|
||||
return {
|
||||
type: "info",
|
||||
command: this,
|
||||
keyedSubcommandInfo,
|
||||
subcommandInfo,
|
||||
...metadata
|
||||
};
|
||||
}
|
||||
|
||||
const invalidSubcommandGenerator: () => CommandInfoError = () => ({
|
||||
type: "error",
|
||||
message: `No subcommand found by the argument list: \`${metadata.originalArgs.join(" ")}\``
|
||||
});
|
||||
|
||||
// Then test if anything fits any hardcoded values, otherwise check if it's a valid keyed subcommand.
|
||||
if (param === "<channel>") {
|
||||
if (this.channel) {
|
||||
metadata.args.push("<channel>");
|
||||
return this.channel.resolveInfoInternal(args, metadata);
|
||||
} else {
|
||||
return invalidSubcommandGenerator();
|
||||
}
|
||||
} else if (param === "<role>") {
|
||||
if (this.role) {
|
||||
metadata.args.push("<role>");
|
||||
return this.role.resolveInfoInternal(args, metadata);
|
||||
} else {
|
||||
return invalidSubcommandGenerator();
|
||||
}
|
||||
} else if (param === "<emote>") {
|
||||
if (this.emote) {
|
||||
metadata.args.push("<emote>");
|
||||
return this.emote.resolveInfoInternal(args, metadata);
|
||||
} else {
|
||||
return invalidSubcommandGenerator();
|
||||
}
|
||||
} else if (param === "<message>") {
|
||||
if (this.message) {
|
||||
metadata.args.push("<message>");
|
||||
return this.message.resolveInfoInternal(args, metadata);
|
||||
} else {
|
||||
return invalidSubcommandGenerator();
|
||||
}
|
||||
} else if (param === "<user>") {
|
||||
if (this.user) {
|
||||
metadata.args.push("<user>");
|
||||
return this.user.resolveInfoInternal(args, metadata);
|
||||
} else {
|
||||
return invalidSubcommandGenerator();
|
||||
}
|
||||
} else if (param === "<id>") {
|
||||
if (this.id) {
|
||||
metadata.args.push(`<id = <${this.idType}>>`);
|
||||
return this.id.resolveInfoInternal(args, metadata);
|
||||
} else {
|
||||
return invalidSubcommandGenerator();
|
||||
}
|
||||
} else if (param === "<number>") {
|
||||
if (this.number) {
|
||||
metadata.args.push("<number>");
|
||||
return this.number.resolveInfoInternal(args, metadata);
|
||||
} else {
|
||||
return invalidSubcommandGenerator();
|
||||
}
|
||||
} else if (param === "<any>") {
|
||||
if (this.any instanceof Command) {
|
||||
metadata.args.push("<any>");
|
||||
return this.any.resolveInfoInternal(args, metadata);
|
||||
} else {
|
||||
return invalidSubcommandGenerator();
|
||||
}
|
||||
} else if (param === "<...>") {
|
||||
if (this.any instanceof RestCommand) {
|
||||
metadata.args.push("<...>");
|
||||
return this.any.resolveInfoFinale(metadata);
|
||||
} else {
|
||||
return invalidSubcommandGenerator();
|
||||
}
|
||||
} else if (this.subcommands?.has(param)) {
|
||||
metadata.args.push(param);
|
||||
return this.subcommands.get(param)!.resolveInfoInternal(args, metadata);
|
||||
} else {
|
||||
return invalidSubcommandGenerator();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class NamedCommand extends Command {
|
||||
public readonly aliases: string[]; // This is to keep the array intact for parent Command instances to use. It'll also be used when loading top-level aliases.
|
||||
private originalCommandName: string | null; // If the command is an alias, what's the original name?
|
||||
|
||||
constructor(options?: NamedCommandOptions) {
|
||||
super(options);
|
||||
this.aliases = options?.aliases || [];
|
||||
// The name override exists in case a user wants to bypass filename restrictions.
|
||||
this.originalCommandName = options?.nameOverride ?? null;
|
||||
}
|
||||
|
||||
public get name(): string {
|
||||
if (this.originalCommandName === null) throw new Error("originalCommandName must be set before accessing it!");
|
||||
else return this.originalCommandName;
|
||||
}
|
||||
|
||||
public set name(value: string) {
|
||||
if (this.originalCommandName !== null)
|
||||
throw new Error(`originalCommandName cannot be set twice! Attempted to set the value to "${value}".`);
|
||||
else this.originalCommandName = value;
|
||||
}
|
||||
|
||||
public isNameSet(): boolean {
|
||||
return this.originalCommandName !== null;
|
||||
}
|
||||
}
|
||||
|
||||
// RestCommand is a declarative version of the common "any: args.join(' ')" pattern, basically the Command version of a rest parameter.
|
||||
// This way, you avoid having extra subcommands when using this pattern.
|
||||
// I'm probably not going to add a transformer function (a callback to automatically handle stuff like searching for usernames).
|
||||
// I don't think the effort to figure this part out via generics or something is worth it.
|
||||
export class RestCommand extends BaseCommand {
|
||||
private run: (($: CommandMenu & {readonly combined: string}) => Promise<any>) | string;
|
||||
|
||||
constructor(options?: RestCommandOptions) {
|
||||
super(options);
|
||||
this.run = options?.run || "No action was set on this command!";
|
||||
}
|
||||
|
||||
public async execute(
|
||||
combined: string,
|
||||
menu: CommandMenu,
|
||||
metadata: ExecuteCommandMetadata
|
||||
): Promise<string | null> {
|
||||
// Update inherited properties if the current command specifies a property.
|
||||
// In case there are no initial arguments, these should go first so that it can register.
|
||||
if (this.permission !== -1) metadata.permission = this.permission;
|
||||
if (this.nsfw !== null) metadata.nsfw = this.nsfw;
|
||||
if (this.channelType !== null) metadata.channelType = this.channelType;
|
||||
|
||||
const error = canExecute(menu, metadata);
|
||||
if (error) return error;
|
||||
|
||||
if (typeof this.run === "string") {
|
||||
// Although I *could* add an option in the launcher to attach arbitrary variables to this var string...
|
||||
// I'll just leave it like this, because instead of using var strings for user stuff, you could just make "run" a template string.
|
||||
await menu.send(
|
||||
parseVars(
|
||||
this.run,
|
||||
{
|
||||
author: menu.author.toString(),
|
||||
prefix: getPrefix(menu.guild),
|
||||
command: `${metadata.header} ${metadata.symbolicArgs.join(", ")}`
|
||||
},
|
||||
"???"
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// Then capture any potential errors.
|
||||
try {
|
||||
// Args will still be kept intact. A common pattern is popping some parameters off the end then doing some branching.
|
||||
// That way, you can still declaratively mark an argument list as continuing while also handling the individual args.
|
||||
await this.run({...menu, args: menu.args, combined});
|
||||
} catch (error) {
|
||||
const errorMessage = error.stack ?? error;
|
||||
console.error(`Command Error: ${metadata.header} (${metadata.args.join(", ")})\n${errorMessage}`);
|
||||
|
||||
return `There was an error while trying to execute that command!\`\`\`${errorMessage}\`\`\``;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public resolveInfoFinale(metadata: CommandInfoMetadata): CommandInfo {
|
||||
if (this.permission !== -1) metadata.permission = this.permission;
|
||||
if (this.nsfw !== null) metadata.nsfw = this.nsfw;
|
||||
if (this.channelType !== null) metadata.channelType = this.channelType;
|
||||
if (this.usage !== "") metadata.usage = this.usage;
|
||||
|
||||
return {
|
||||
type: "info",
|
||||
command: this,
|
||||
keyedSubcommandInfo: new Collection<string, BaseCommand>(),
|
||||
subcommandInfo: new Collection<string, BaseCommand>(),
|
||||
...metadata
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// See if there is anything that'll prevent the user from executing the command.
|
||||
// Returns null if successful, otherwise returns a message with the error.
|
||||
function canExecute(menu: CommandMenu, metadata: ExecuteCommandMetadata): string | null {
|
||||
// 1. Does this command specify a required channel type? If so, does the channel type match?
|
||||
if (
|
||||
metadata.channelType === CHANNEL_TYPE.GUILD &&
|
||||
(!(menu.channel instanceof GuildChannel) || menu.guild === null || menu.member === null)
|
||||
) {
|
||||
return "This command must be executed in a server.";
|
||||
} else if (
|
||||
metadata.channelType === CHANNEL_TYPE.DM &&
|
||||
(menu.channel.type !== "dm" || menu.guild !== null || menu.member !== null)
|
||||
) {
|
||||
return "This command must be executed as a direct message.";
|
||||
}
|
||||
|
||||
// 2. Is this an NSFW command where the channel prevents such use? (DM channels bypass this requirement.)
|
||||
if (metadata.nsfw && menu.channel.type !== "dm" && !menu.channel.nsfw) {
|
||||
return "This command must be executed in either an NSFW channel or as a direct message.";
|
||||
}
|
||||
|
||||
// 3. Does the user have permission to execute the command?
|
||||
if (!hasPermission(menu.author, menu.member, metadata.permission)) {
|
||||
const userPermLevel = getPermissionLevel(menu.author, menu.member);
|
||||
|
||||
return `You don't have access to this command! Your permission level is \`${getPermissionName(
|
||||
userPermLevel
|
||||
)}\` (${userPermLevel}), but this command requires a permission level of \`${getPermissionName(
|
||||
metadata.permission
|
||||
)}\` (${metadata.permission}).`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
import {Client, Permissions, Message, MessageReaction, User, PartialUser} from "discord.js";
|
||||
import {botHasPermission} from "./libd";
|
||||
|
||||
// A list of message ID and callback pairs. You get the emote name and ID of the user reacting.
|
||||
// This will handle removing reactions automatically (if the bot has the right permission).
|
||||
export const reactEventListeners = new Map<string, (reaction: MessageReaction, user: User | PartialUser) => void>();
|
||||
export const emptyReactEventListeners = new Map<string, () => void>();
|
||||
|
||||
// A list of "channel-message" and callback pairs. Also, I imagine that the callback will be much more maintainable when discord.js v13 comes out with a dedicated message.referencedMessage property.
|
||||
export const replyEventListeners = new Map<string, (message: Message) => void>();
|
||||
|
||||
export function attachEventListenersToClient(client: Client) {
|
||||
client.on("messageReactionAdd", (reaction, user) => {
|
||||
// The reason this is inside the call is because it's possible to switch a user's permissions halfway and suddenly throw an error.
|
||||
// This will dynamically adjust for that, switching modes depending on whether it currently has the "Manage Messages" permission.
|
||||
const canDeleteEmotes = botHasPermission(reaction.message.guild, Permissions.FLAGS.MANAGE_MESSAGES);
|
||||
reactEventListeners.get(reaction.message.id)?.(reaction, user);
|
||||
if (canDeleteEmotes && !user.partial) reaction.users.remove(user);
|
||||
});
|
||||
|
||||
client.on("messageReactionRemove", (reaction, user) => {
|
||||
const canDeleteEmotes = botHasPermission(reaction.message.guild, Permissions.FLAGS.MANAGE_MESSAGES);
|
||||
if (!canDeleteEmotes) reactEventListeners.get(reaction.message.id)?.(reaction, user);
|
||||
});
|
||||
|
||||
client.on("messageReactionRemoveAll", (message) => {
|
||||
reactEventListeners.delete(message.id);
|
||||
emptyReactEventListeners.get(message.id)?.();
|
||||
emptyReactEventListeners.delete(message.id);
|
||||
});
|
||||
|
||||
client.on("message", (message) => {
|
||||
// If there's an inline reply, fire off that event listener (if it exists).
|
||||
if (message.reference) {
|
||||
const reference = message.reference;
|
||||
replyEventListeners.get(`${reference.channelID}-${reference.messageID}`)?.(message);
|
||||
}
|
||||
});
|
||||
}
|
|
@ -1,138 +0,0 @@
|
|||
import {Client, Permissions, Message, TextChannel, DMChannel, NewsChannel} from "discord.js";
|
||||
import {getPrefix, loadableCommands} from "./interface";
|
||||
|
||||
// For custom message events that want to cancel the command handler on certain conditions.
|
||||
const interceptRules: ((message: Message) => boolean)[] = [(message) => message.author.bot];
|
||||
|
||||
export function addInterceptRule(handler: (message: Message) => boolean) {
|
||||
interceptRules.push(handler);
|
||||
}
|
||||
|
||||
const lastCommandInfo: {
|
||||
header: string;
|
||||
args: string[];
|
||||
channel: TextChannel | DMChannel | NewsChannel | null;
|
||||
} = {
|
||||
header: "N/A",
|
||||
args: [],
|
||||
channel: null
|
||||
};
|
||||
|
||||
const defaultMetadata = {
|
||||
permission: 0,
|
||||
nsfw: false,
|
||||
channelType: 0 // CHANNEL_TYPE.ANY, apparently isn't initialized at this point yet
|
||||
};
|
||||
|
||||
// Note: client.user is only undefined before the bot logs in, so by this point, client.user cannot be undefined.
|
||||
// Note: guild.available will never need to be checked because the message starts in either a DM channel or an already-available guild.
|
||||
export function attachMessageHandlerToClient(client: Client) {
|
||||
client.on("message", async (message) => {
|
||||
for (const shouldIntercept of interceptRules) {
|
||||
if (shouldIntercept(message)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const commands = await loadableCommands;
|
||||
const {author, channel, content, guild, member} = message;
|
||||
const send = channel.send.bind(channel);
|
||||
const text = content;
|
||||
const menu = {
|
||||
author,
|
||||
channel,
|
||||
client,
|
||||
guild,
|
||||
member,
|
||||
message,
|
||||
args: [],
|
||||
send
|
||||
};
|
||||
|
||||
// Execute a dedicated block for messages in DM channels.
|
||||
if (channel.type === "dm") {
|
||||
// In a DM channel, simply forget about the prefix and execute any message as a command.
|
||||
const [header, ...args] = text.split(/ +/);
|
||||
|
||||
if (commands.has(header)) {
|
||||
const command = commands.get(header)!;
|
||||
|
||||
// Set last command info in case of unhandled rejections.
|
||||
lastCommandInfo.header = header;
|
||||
lastCommandInfo.args = [...args];
|
||||
lastCommandInfo.channel = channel;
|
||||
|
||||
// Send the arguments to the command to resolve and execute.
|
||||
const result = await command.execute(args, menu, {
|
||||
header,
|
||||
args: [...args],
|
||||
...defaultMetadata,
|
||||
symbolicArgs: []
|
||||
});
|
||||
|
||||
// If something went wrong, let the user know (like if they don't have permission to use a command).
|
||||
if (result) {
|
||||
send(result);
|
||||
}
|
||||
} else {
|
||||
send(
|
||||
`I couldn't find the command or alias that starts with \`${header}\`. To see the list of commands, type \`help\``
|
||||
);
|
||||
}
|
||||
}
|
||||
// Continue if the bot has permission to send messages in this channel.
|
||||
else if (channel.permissionsFor(client.user!)!.has(Permissions.FLAGS.SEND_MESSAGES)) {
|
||||
const prefix = getPrefix(guild);
|
||||
|
||||
// First, test if the message is just a ping to the bot.
|
||||
if (new RegExp(`^<@!?${client.user!.id}>$`).test(text)) {
|
||||
send(`${author}, my prefix on this server is \`${prefix}\`.`);
|
||||
}
|
||||
// Then check if it's a normal command.
|
||||
else if (text.startsWith(prefix)) {
|
||||
const [header, ...args] = text.substring(prefix.length).split(/ +/);
|
||||
|
||||
if (commands.has(header)) {
|
||||
const command = commands.get(header)!;
|
||||
|
||||
// Set last command info in case of unhandled rejections.
|
||||
lastCommandInfo.header = header;
|
||||
lastCommandInfo.args = [...args];
|
||||
lastCommandInfo.channel = channel;
|
||||
|
||||
// Send the arguments to the command to resolve and execute.
|
||||
const result = await command.execute(args, menu, {
|
||||
header,
|
||||
args: [...args],
|
||||
...defaultMetadata,
|
||||
symbolicArgs: []
|
||||
});
|
||||
|
||||
// If something went wrong, let the user know (like if they don't have permission to use a command).
|
||||
if (result) {
|
||||
send(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Otherwise, let the sender know that the bot doesn't have permission to send messages.
|
||||
else {
|
||||
author.send(
|
||||
`I don't have permission to send messages in ${channel}. ${
|
||||
member!.hasPermission(Permissions.FLAGS.ADMINISTRATOR)
|
||||
? "Because you're a server admin, you have the ability to change that channel's permissions to match if that's what you intended."
|
||||
: "Try using a different channel or contacting a server admin to change permissions of that channel if you think something's wrong."
|
||||
}`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
process.on("unhandledRejection", (reason: any) => {
|
||||
if (reason?.name === "DiscordAPIError") {
|
||||
console.error(`Command Error: ${lastCommandInfo.header} (${lastCommandInfo.args.join(", ")})\n${reason.stack}`);
|
||||
lastCommandInfo.channel?.send(
|
||||
`There was an error while trying to execute that command!\`\`\`${reason.stack}\`\`\``
|
||||
);
|
||||
}
|
||||
});
|
|
@ -1,7 +0,0 @@
|
|||
// Onion Lasers Command Handler //
|
||||
export {Command, NamedCommand, RestCommand, CHANNEL_TYPE} from "./command";
|
||||
export {addInterceptRule} from "./handler";
|
||||
export {launch} from "./interface";
|
||||
export * from "./libd";
|
||||
export {getCommandList, getCommandInfo} from "./loader";
|
||||
export {hasPermission, getPermissionLevel, getPermissionName} from "./permissions";
|
|
@ -1,55 +0,0 @@
|
|||
import {Collection, Client, User, GuildMember, Guild} from "discord.js";
|
||||
import {attachMessageHandlerToClient} from "./handler";
|
||||
import {attachEventListenersToClient} from "./eventListeners";
|
||||
import {NamedCommand} from "./command";
|
||||
import {loadCommands} from "./loader";
|
||||
|
||||
interface PermissionLevel {
|
||||
name: string;
|
||||
check: (user: User, member: GuildMember | null) => boolean;
|
||||
}
|
||||
|
||||
type PrefixResolver = (guild: Guild | null) => string;
|
||||
type CategoryTransformer = (text: string) => string;
|
||||
|
||||
// One potential option is to let the user customize system messages such as "This command must be executed in a guild."
|
||||
// I decided not to do that because I don't think it'll be worth the trouble.
|
||||
interface LaunchSettings {
|
||||
permissionLevels?: PermissionLevel[];
|
||||
getPrefix?: PrefixResolver;
|
||||
categoryTransformer?: CategoryTransformer;
|
||||
}
|
||||
|
||||
// One alternative to putting everything in launch(client, ...) is to create an object then set each individual aspect, such as OnionCore.setPermissions(...).
|
||||
// That way, you can split different pieces of logic into different files, then do OnionCore.launch(client).
|
||||
// Additionally, each method would return the object so multiple methods could be chained, such as OnionCore.setPermissions(...).setPrefixResolver(...).launch(client).
|
||||
// I decided to not do this because creating a class then having a bunch of boilerplate around it just wouldn't really be worth it.
|
||||
// commandsDirectory requires an absolute path to work, so use __dirname.
|
||||
export async function launch(newClient: Client, commandsDirectory: string, settings?: LaunchSettings) {
|
||||
// Core Launch Parameters //
|
||||
client.destroy(); // Release any resources/connections being used by the placeholder client.
|
||||
client = newClient;
|
||||
loadableCommands = loadCommands(commandsDirectory);
|
||||
attachMessageHandlerToClient(newClient);
|
||||
attachEventListenersToClient(newClient);
|
||||
|
||||
// Additional Configuration //
|
||||
if (settings?.permissionLevels) {
|
||||
if (settings.permissionLevels.length > 0) permissionLevels = settings.permissionLevels;
|
||||
else console.warn("permissionLevels must have at least one element to work!");
|
||||
}
|
||||
if (settings?.getPrefix) getPrefix = settings.getPrefix;
|
||||
if (settings?.categoryTransformer) categoryTransformer = settings.categoryTransformer;
|
||||
}
|
||||
|
||||
// Placeholder until properly loaded by the user.
|
||||
export let loadableCommands = (async () => new Collection<string, NamedCommand>())();
|
||||
export let client = new Client();
|
||||
export let permissionLevels: PermissionLevel[] = [
|
||||
{
|
||||
name: "User",
|
||||
check: () => true
|
||||
}
|
||||
];
|
||||
export let getPrefix: PrefixResolver = () => ".";
|
||||
export let categoryTransformer: CategoryTransformer = (text) => text;
|
390
src/core/libd.ts
390
src/core/libd.ts
|
@ -1,390 +0,0 @@
|
|||
// Library for Discord-specific functions
|
||||
import {
|
||||
Message,
|
||||
Guild,
|
||||
GuildMember,
|
||||
TextChannel,
|
||||
DMChannel,
|
||||
NewsChannel,
|
||||
MessageOptions,
|
||||
Channel,
|
||||
GuildChannel,
|
||||
User,
|
||||
APIMessageContentResolvable,
|
||||
MessageAdditions,
|
||||
SplitOptions,
|
||||
APIMessage,
|
||||
StringResolvable,
|
||||
EmojiIdentifierResolvable,
|
||||
MessageReaction,
|
||||
PartialUser
|
||||
} from "discord.js";
|
||||
import {reactEventListeners, emptyReactEventListeners, replyEventListeners} from "./eventListeners";
|
||||
import {client} from "./interface";
|
||||
|
||||
export type SingleMessageOptions = MessageOptions & {split?: false};
|
||||
|
||||
export type SendFunction = ((
|
||||
content: APIMessageContentResolvable | (MessageOptions & {split?: false}) | MessageAdditions
|
||||
) => Promise<Message>) &
|
||||
((options: MessageOptions & {split: true | SplitOptions}) => Promise<Message[]>) &
|
||||
((options: MessageOptions | APIMessage) => Promise<Message | Message[]>) &
|
||||
((content: StringResolvable, options: (MessageOptions & {split?: false}) | MessageAdditions) => Promise<Message>) &
|
||||
((content: StringResolvable, options: MessageOptions & {split: true | SplitOptions}) => Promise<Message[]>) &
|
||||
((content: StringResolvable, options: MessageOptions) => Promise<Message | Message[]>);
|
||||
|
||||
interface PaginateOptions {
|
||||
multiPageSize?: number;
|
||||
idleTimeout?: number;
|
||||
}
|
||||
|
||||
// Pagination function that allows for customization via a callback.
|
||||
// Define your own pages outside the function because this only manages the actual turning of pages.
|
||||
/**
|
||||
* Takes a message and some additional parameters and makes a reaction page with it. All the pagination logic is taken care of but nothing more, the page index is returned and you have to send a callback to do something with it.
|
||||
*
|
||||
* Returns the page number the user left off on in case you want to implement a return to page function.
|
||||
*/
|
||||
export function paginate(
|
||||
send: SendFunction,
|
||||
listenTo: string | null,
|
||||
totalPages: number,
|
||||
onTurnPage: (page: number, hasMultiplePages: boolean) => SingleMessageOptions,
|
||||
options?: PaginateOptions
|
||||
): Promise<number> {
|
||||
if (totalPages < 1) throw new Error(`totalPages on paginate() must be 1 or more, ${totalPages} given.`);
|
||||
|
||||
return new Promise(async (resolve) => {
|
||||
const hasMultiplePages = totalPages > 1;
|
||||
const message = await send(onTurnPage(0, hasMultiplePages));
|
||||
|
||||
if (hasMultiplePages) {
|
||||
const multiPageSize = options?.multiPageSize ?? 5;
|
||||
const idleTimeout = options?.idleTimeout ?? 60000;
|
||||
let page = 0;
|
||||
|
||||
const turn = (amount: number) => {
|
||||
page += amount;
|
||||
|
||||
if (page >= totalPages) {
|
||||
page %= totalPages;
|
||||
} else if (page < 0) {
|
||||
// Assuming 3 total pages, it's a bit tricker, but if we just take the modulo of the absolute value (|page| % total), we get (1 2 0 ...), and we just need the pattern (2 1 0 ...). It needs to reverse order except for when it's 0. I want to find a better solution, but for the time being... total - (|page| % total) unless (|page| % total) = 0, then return 0.
|
||||
const flattened = Math.abs(page) % totalPages;
|
||||
if (flattened !== 0) page = totalPages - flattened;
|
||||
}
|
||||
|
||||
message.edit(onTurnPage(page, true));
|
||||
};
|
||||
|
||||
let stack: {[emote: string]: number} = {
|
||||
"⬅️": -1,
|
||||
"➡️": 1
|
||||
};
|
||||
|
||||
if (totalPages > multiPageSize) {
|
||||
stack = {
|
||||
"⏪": -multiPageSize,
|
||||
...stack,
|
||||
"⏩": multiPageSize
|
||||
};
|
||||
}
|
||||
|
||||
const handle = (reaction: MessageReaction, user: User | PartialUser) => {
|
||||
if (user.id === listenTo || (listenTo === null && user.id !== client.user!.id)) {
|
||||
// Turn the page
|
||||
const emote = reaction.emoji.name;
|
||||
if (emote in stack) turn(stack[emote]);
|
||||
|
||||
// Reset the timer
|
||||
client.clearTimeout(timeout);
|
||||
timeout = client.setTimeout(destroy, idleTimeout);
|
||||
}
|
||||
};
|
||||
|
||||
// When time's up, remove the bot's own reactions.
|
||||
const destroy = () => {
|
||||
reactEventListeners.delete(message.id);
|
||||
for (const emote of message.reactions.cache.values()) emote.users.remove(message.author);
|
||||
resolve(page);
|
||||
};
|
||||
|
||||
// Start the reactions and call the handler.
|
||||
reactInOrder(message, Object.keys(stack));
|
||||
reactEventListeners.set(message.id, handle);
|
||||
emptyReactEventListeners.set(message.id, destroy);
|
||||
let timeout = client.setTimeout(destroy, idleTimeout);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function poll(message: Message, emotes: string[], duration = 60000): Promise<{[emote: string]: number}> {
|
||||
if (emotes.length === 0) throw new Error("poll() was called without any emotes.");
|
||||
|
||||
reactInOrder(message, emotes);
|
||||
const reactions = await message.awaitReactions(
|
||||
(reaction: MessageReaction) => emotes.includes(reaction.emoji.name),
|
||||
{time: duration}
|
||||
);
|
||||
const reactionsByCount: {[emote: string]: number} = {};
|
||||
|
||||
for (const emote of emotes) {
|
||||
const reaction = reactions.get(emote);
|
||||
|
||||
if (reaction) {
|
||||
const hasBot = reaction.users.cache.has(client.user!.id); // Apparently, reaction.me doesn't work properly.
|
||||
|
||||
if (reaction.count !== null) {
|
||||
const difference = hasBot ? 1 : 0;
|
||||
reactionsByCount[emote] = reaction.count - difference;
|
||||
} else {
|
||||
reactionsByCount[emote] = 0;
|
||||
}
|
||||
} else {
|
||||
reactionsByCount[emote] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return reactionsByCount;
|
||||
}
|
||||
|
||||
export function confirm(message: Message, senderID: string, timeout = 30000): Promise<boolean | null> {
|
||||
return generateOneTimePrompt(
|
||||
message,
|
||||
{
|
||||
"✅": true,
|
||||
"❌": false
|
||||
},
|
||||
senderID,
|
||||
timeout
|
||||
);
|
||||
}
|
||||
|
||||
// This MUST be split into an array. These emojis are made up of several characters each, adding up to 29 in length.
|
||||
const multiNumbers = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟"];
|
||||
|
||||
// This will bring up an option to let the user choose between one option out of many.
|
||||
// This definitely needs a single callback alternative, because using the numerical version isn't actually that uncommon of a pattern.
|
||||
export async function askMultipleChoice(
|
||||
message: Message,
|
||||
senderID: string,
|
||||
choices: number,
|
||||
timeout = 90000
|
||||
): Promise<number | null> {
|
||||
if (choices > multiNumbers.length)
|
||||
throw new Error(
|
||||
`askMultipleChoice only accepts up to ${multiNumbers.length} options, ${choices} was provided.`
|
||||
);
|
||||
const numbers: {[emote: string]: number} = {};
|
||||
for (let i = 0; i < choices; i++) numbers[multiNumbers[i]] = i;
|
||||
return generateOneTimePrompt(message, numbers, senderID, timeout);
|
||||
}
|
||||
|
||||
// Asks the user for some input using the inline reply feature. The message here is a message you send beforehand.
|
||||
// If the reply is rejected, reply with an error message (when stable support comes from discord.js).
|
||||
export function askForReply(message: Message, listenTo: string, timeout?: number): Promise<Message | null> {
|
||||
return new Promise((resolve) => {
|
||||
const referenceID = `${message.channel.id}-${message.id}`;
|
||||
|
||||
replyEventListeners.set(referenceID, (reply) => {
|
||||
if (reply.author.id === listenTo) {
|
||||
message.delete();
|
||||
replyEventListeners.delete(referenceID);
|
||||
resolve(reply);
|
||||
}
|
||||
});
|
||||
|
||||
if (timeout) {
|
||||
client.setTimeout(() => {
|
||||
if (!message.deleted) message.delete();
|
||||
replyEventListeners.delete(referenceID);
|
||||
resolve(null);
|
||||
}, timeout);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Returns null if timed out, otherwise, returns the value.
|
||||
export function generateOneTimePrompt<T>(
|
||||
message: Message,
|
||||
stack: {[emote: string]: T},
|
||||
listenTo: string | null = null,
|
||||
duration = 60000
|
||||
): Promise<T | null> {
|
||||
return new Promise(async (resolve) => {
|
||||
// First, start reacting to the message in order.
|
||||
reactInOrder(message, Object.keys(stack));
|
||||
|
||||
// Then setup the reaction listener in parallel.
|
||||
await message.awaitReactions(
|
||||
(reaction: MessageReaction, user: User) => {
|
||||
if (user.id === listenTo || listenTo === null) {
|
||||
const emote = reaction.emoji.name;
|
||||
|
||||
if (emote in stack) {
|
||||
resolve(stack[emote]);
|
||||
message.delete();
|
||||
}
|
||||
}
|
||||
|
||||
// CollectorFilter requires a boolean to be returned.
|
||||
// My guess is that the return value of awaitReactions can be altered by making a boolean filter.
|
||||
// However, because that's not my concern with this command, I don't have to worry about it.
|
||||
// May as well just set it to false because I'm not concerned with collecting any reactions.
|
||||
return false;
|
||||
},
|
||||
{time: duration}
|
||||
);
|
||||
|
||||
if (!message.deleted) {
|
||||
message.delete();
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Start a parallel chain of ordered reactions, allowing a collector to end early.
|
||||
// Check if the collector ended early by seeing if the message is already deleted.
|
||||
// Though apparently, message.deleted doesn't seem to update fast enough, so just put a try catch block on message.react().
|
||||
async function reactInOrder(message: Message, emotes: EmojiIdentifierResolvable[]): Promise<void> {
|
||||
for (const emote of emotes) {
|
||||
try {
|
||||
await message.react(emote);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests if a bot has a certain permission in a specified guild.
|
||||
*/
|
||||
export function botHasPermission(guild: Guild | null, permission: number): boolean {
|
||||
return !!guild?.me?.hasPermission(permission);
|
||||
}
|
||||
|
||||
// For "get x by y" methods:
|
||||
// Caching: All guilds, channels, and roles are fully cached, while the caches for messages, users, and members aren't complete.
|
||||
// It's more reliable to get users/members by fetching their IDs. fetch() will searching through the cache anyway.
|
||||
// For guilds, do an extra check to make sure there isn't an outage (guild.available).
|
||||
|
||||
export function getGuildByID(id: string): Guild | string {
|
||||
const guild = client.guilds.cache.get(id);
|
||||
|
||||
if (guild) {
|
||||
if (guild.available) return guild;
|
||||
else return `The guild \`${guild.name}\` (ID: \`${id}\`) is unavailable due to an outage.`;
|
||||
} else {
|
||||
return `No guild found by the ID of \`${id}\`!`;
|
||||
}
|
||||
}
|
||||
|
||||
export function getGuildByName(name: string): Guild | string {
|
||||
const query = name.toLowerCase();
|
||||
const guild = client.guilds.cache.find((guild) => guild.name.toLowerCase().includes(query));
|
||||
|
||||
if (guild) {
|
||||
if (guild.available) return guild;
|
||||
else return `The guild \`${guild.name}\` (ID: \`${guild.id}\`) is unavailable due to an outage.`;
|
||||
} else {
|
||||
return `No guild found by the name of \`${name}\`!`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getChannelByID(id: string): Promise<Channel | string> {
|
||||
try {
|
||||
return await client.channels.fetch(id);
|
||||
} catch {
|
||||
return `No channel found by the ID of \`${id}\`!`;
|
||||
}
|
||||
}
|
||||
|
||||
// Only go through the cached channels (non-DM channels). Plus, searching DM channels by name wouldn't really make sense, nor do they have names to search anyway.
|
||||
export function getChannelByName(name: string): GuildChannel | string {
|
||||
const query = name.toLowerCase();
|
||||
const channel = client.channels.cache.find(
|
||||
(channel) => channel instanceof GuildChannel && channel.name.toLowerCase().includes(query)
|
||||
) as GuildChannel | undefined;
|
||||
if (channel) return channel;
|
||||
else return `No channel found by the name of \`${name}\`!`;
|
||||
}
|
||||
|
||||
export async function getMessageByID(
|
||||
channel: TextChannel | DMChannel | NewsChannel | string,
|
||||
id: string
|
||||
): Promise<Message | string> {
|
||||
if (typeof channel === "string") {
|
||||
const targetChannel = await getChannelByID(channel);
|
||||
if (targetChannel instanceof TextChannel || targetChannel instanceof DMChannel) channel = targetChannel;
|
||||
else if (targetChannel instanceof Channel) return `\`${id}\` isn't a valid text-based channel!`;
|
||||
else return targetChannel;
|
||||
}
|
||||
|
||||
try {
|
||||
return await channel.messages.fetch(id);
|
||||
} catch {
|
||||
return `\`${id}\` isn't a valid message of the channel ${channel}!`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserByID(id: string): Promise<User | string> {
|
||||
try {
|
||||
return await client.users.fetch(id);
|
||||
} catch {
|
||||
return `No user found by the ID of \`${id}\`!`;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check tags (if provided) to narrow down users.
|
||||
export function getUserByName(name: string): User | string {
|
||||
let query = name.toLowerCase();
|
||||
const tagMatch = /^(.+?)#(\d{4})$/.exec(name);
|
||||
let tag: string | null = null;
|
||||
|
||||
if (tagMatch) {
|
||||
query = tagMatch[1].toLowerCase();
|
||||
tag = tagMatch[2];
|
||||
}
|
||||
|
||||
const user = client.users.cache.find((user) => {
|
||||
const hasUsernameMatch = user.username.toLowerCase().includes(query);
|
||||
if (tag) return hasUsernameMatch && user.discriminator === tag;
|
||||
else return hasUsernameMatch;
|
||||
});
|
||||
|
||||
if (user) return user;
|
||||
else return `No user found by the name of \`${name}\`!`;
|
||||
}
|
||||
|
||||
export async function getMemberByID(guild: Guild, id: string): Promise<GuildMember | string> {
|
||||
try {
|
||||
return await guild.members.fetch(id);
|
||||
} catch {
|
||||
return `No member found by the ID of \`${id}\`!`;
|
||||
}
|
||||
}
|
||||
|
||||
// First checks if a member can be found by that nickname, then check if a member can be found by that username.
|
||||
export async function getMemberByName(guild: Guild, name: string): Promise<GuildMember | string> {
|
||||
const member = (
|
||||
await guild.members.fetch({
|
||||
query: name,
|
||||
limit: 1
|
||||
})
|
||||
).first();
|
||||
|
||||
// Search by username if no member is found, then resolve the user into a member if possible.
|
||||
if (member) {
|
||||
return member;
|
||||
} else {
|
||||
const user = getUserByName(name);
|
||||
|
||||
if (user instanceof User) {
|
||||
const member = guild.members.resolve(user);
|
||||
if (member) return member;
|
||||
else return `The user \`${user.tag}\` isn't in this guild!`;
|
||||
} else {
|
||||
return `No member found by the name of \`${name}\`!`;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,148 +0,0 @@
|
|||
import {Collection} from "discord.js";
|
||||
import glob from "glob";
|
||||
import path from "path";
|
||||
import {NamedCommand, CommandInfo} from "./command";
|
||||
import {loadableCommands, categoryTransformer} from "./interface";
|
||||
|
||||
// Internally, it'll keep its original capitalization. It's up to you to convert it to title case when you make a help command.
|
||||
const categories = new Collection<string, string[]>();
|
||||
|
||||
// This will go through all the .js files and import them. Because the import has to be .js (and cannot be .ts), there's no need for a custom filename checker in the launch settings.
|
||||
// This will avoid the problems of being a node module by requiring absolute imports, which the user will pass in as a launch parameter.
|
||||
export async function loadCommands(commandsDir: string): Promise<Collection<string, NamedCommand>> {
|
||||
// Add a trailing separator so that the reduced filename list will reliably cut off the starting part.
|
||||
// "C:/some/path/to/commands" --> "C:/some/path/to/commands/" (and likewise for \)
|
||||
commandsDir = path.normalize(commandsDir);
|
||||
if (!commandsDir.endsWith(path.sep)) commandsDir += path.sep;
|
||||
|
||||
const commands = new Collection<string, NamedCommand>();
|
||||
// Include all .ts files recursively in "src/commands/".
|
||||
const files = await globP(path.join(commandsDir, "**", "*.js")); // This stage filters out source maps (.js.map).
|
||||
// Because glob will use / regardless of platform, the following regex pattern can rely on / being the case.
|
||||
const filesClean = files.map((filename) => filename.substring(commandsDir.length));
|
||||
// Extract the usable parts from commands directory if:
|
||||
// - The path is 1 to 2 subdirectories (a or a/b, not a/b/c)
|
||||
// - Any leading directory isn't "modules"
|
||||
// - The filename doesn't end in .test.js (for jest testing)
|
||||
// - The filename cannot be the hardcoded top-level "template.js", reserved for generating templates
|
||||
const pattern = /^(?!template\.js)(?!modules\/)(\w+(?:\/\w+)?)(?:test\.)?\.js$/;
|
||||
const lists: {[category: string]: string[]} = {};
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const match = pattern.exec(filesClean[i]);
|
||||
if (!match) continue;
|
||||
const commandID = match[1]; // e.g. "utilities/info"
|
||||
const slashIndex = commandID.indexOf("/");
|
||||
const isMiscCommand = slashIndex !== -1;
|
||||
const category = isMiscCommand ? commandID.substring(0, slashIndex) : "miscellaneous";
|
||||
const commandName = isMiscCommand ? commandID.substring(slashIndex + 1) : commandID; // e.g. "info"
|
||||
|
||||
// This try-catch block MUST be here or Node.js' dynamic require() will silently fail.
|
||||
try {
|
||||
// If the dynamic import works, it must be an object at the very least. Then, just test to see if it's a proper instance.
|
||||
const command = (await import(files[i])).default as unknown;
|
||||
|
||||
if (command instanceof NamedCommand) {
|
||||
const isNameOverridden = command.isNameSet();
|
||||
if (!isNameOverridden) command.name = commandName;
|
||||
const header = command.name;
|
||||
|
||||
if (commands.has(header)) {
|
||||
console.warn(
|
||||
`Command "${header}" already exists! Make sure to make each command uniquely identifiable across categories!`
|
||||
);
|
||||
} else {
|
||||
commands.set(header, command);
|
||||
}
|
||||
|
||||
for (const alias of command.aliases) {
|
||||
if (commands.has(alias)) {
|
||||
console.warn(
|
||||
`Top-level alias "${alias}" from command "${commandID}" already exists either as a command or alias!`
|
||||
);
|
||||
} else {
|
||||
commands.set(alias, command);
|
||||
}
|
||||
}
|
||||
|
||||
if (!(category in lists)) lists[category] = [];
|
||||
lists[category].push(header);
|
||||
|
||||
if (isNameOverridden) console.log(`Loaded Command: "${commandID}" as "${header}"`);
|
||||
else console.log(`Loaded Command: ${commandID}`);
|
||||
} else {
|
||||
console.warn(`Command "${commandID}" has no default export which is a NamedCommand instance!`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
for (const category in lists) {
|
||||
categories.set(category, lists[category]);
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
function globP(path: string) {
|
||||
return new Promise<string[]>((resolve, reject) => {
|
||||
glob(path, (error, files) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(files);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of categories and their associated commands.
|
||||
*/
|
||||
export async function getCommandList(): Promise<Collection<string, NamedCommand[]>> {
|
||||
const list = new Collection<string, NamedCommand[]>();
|
||||
const commands = await loadableCommands;
|
||||
|
||||
for (const [category, headers] of categories) {
|
||||
const commandList: NamedCommand[] = [];
|
||||
for (const header of headers.filter((header) => header !== "test")) commandList.push(commands.get(header)!);
|
||||
// Ignore empty categories like "miscellaneous" (if it's empty).
|
||||
if (commandList.length > 0) list.set(categoryTransformer(category), commandList);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a command based on the arguments given.
|
||||
* - Returns a string if there was an error.
|
||||
* - Returns a CommandInfo/category tuple if it was a success.
|
||||
*/
|
||||
export async function getCommandInfo(args: string[]): Promise<[CommandInfo, string] | string> {
|
||||
// Use getCommandList() instead if you're just getting the list of all commands.
|
||||
if (args.length === 0) return "No arguments were provided!";
|
||||
|
||||
// Setup the root command
|
||||
const commands = await loadableCommands;
|
||||
let header = args.shift()!;
|
||||
const command = commands.get(header);
|
||||
if (!command || header === "test") return `No command found by the name \`${header}\`.`;
|
||||
if (!(command instanceof NamedCommand)) return "Command is not a proper instance of NamedCommand.";
|
||||
// If it's an alias, set the header to the original command name.
|
||||
if (command.name) header = command.name;
|
||||
|
||||
// Search categories
|
||||
let category = "Unknown";
|
||||
for (const [referenceCategory, headers] of categories) {
|
||||
if (headers.includes(header)) {
|
||||
category = categoryTransformer(referenceCategory);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Gather info
|
||||
const result = command.resolveInfo(args, header);
|
||||
if (result.type === "error") return result.message;
|
||||
else return [result, category];
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
// Contains all the permission roles and checking functions.
|
||||
import {User, GuildMember} from "discord.js";
|
||||
import {permissionLevels} from "./interface";
|
||||
|
||||
/**
|
||||
* Checks if a `Member` has a certain permission.
|
||||
*/
|
||||
export function hasPermission(user: User, member: GuildMember | null, permission: number): boolean {
|
||||
if (permissionLevels.length === 0) return true;
|
||||
for (let i = permissionLevels.length - 1; i >= permission; i--)
|
||||
if (permissionLevels[i].check(user, member)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a `Member`'s permission level according to the permissions enum defined in the file.
|
||||
*/
|
||||
export function getPermissionLevel(user: User, member: GuildMember | null): number {
|
||||
for (let i = permissionLevels.length - 1; i >= 0; i--) if (permissionLevels[i].check(user, member)) return i;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function getPermissionName(level: number) {
|
||||
if (level > permissionLevels.length || level < 0 || permissionLevels.length === 0) return "N/A";
|
||||
else return permissionLevels[level].name;
|
||||
}
|
|
@ -6,7 +6,7 @@ import path from "path";
|
|||
// This of course won't actually do anything until the setup process is complete and it logs in.
|
||||
export const client = new Client();
|
||||
|
||||
import {launch} from "./core";
|
||||
import {launch} from "onion-lasers";
|
||||
import setup from "./modules/setup";
|
||||
import {Config, getPrefix} from "./structures";
|
||||
import {toTitleCase} from "./lib";
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import {client} from "../index";
|
||||
import {MessageEmbed} from "discord.js";
|
||||
import {getPrefix} from "../structures";
|
||||
import {getMessageByID} from "../core";
|
||||
import {getMessageByID} from "onion-lasers";
|
||||
|
||||
client.on("message", async (message) => {
|
||||
// Only execute if the message is from a user and isn't a command.
|
||||
|
|
|
@ -1,19 +1,8 @@
|
|||
import {existsSync as exists, readFileSync as read, writeFile as write} from "fs";
|
||||
import {existsSync as exists} from "fs";
|
||||
import inquirer from "inquirer";
|
||||
import Storage, {generateHandler} from "./storage";
|
||||
import Storage from "./storage";
|
||||
import {Config} from "../structures";
|
||||
|
||||
// The template should be built with a reductionist mentality.
|
||||
// Provide everything the user needs and then let them remove whatever they want.
|
||||
// That way, they aren't focusing on what's missing, but rather what they need for their command.
|
||||
if (IS_DEV_MODE && !exists("src/commands/test.ts")) {
|
||||
write(
|
||||
"src/commands/test.ts",
|
||||
read("src/commands/template.ts"),
|
||||
generateHandler('"test.ts" (testing/template command) successfully generated.')
|
||||
);
|
||||
}
|
||||
|
||||
// A generic process handler is set to catch unhandled rejections other than the ones from Lavalink and Discord.
|
||||
process.on("unhandledRejection", (reason: any) => {
|
||||
const isLavalinkError = reason?.code === "ECONNREFUSED";
|
||||
|
|
Loading…
Reference in a new issue