Merge pull request #28 from keanuplayz/experimental-core-rollout

This commit is contained in:
Keanu Timmermans 2021-04-09 15:30:07 +02:00 committed by GitHub
commit 2465bb6324
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
108 changed files with 13802 additions and 4490 deletions

3
.gitignore vendored
View File

@ -1,7 +1,6 @@
# Specific to this repository
dist/
data/*
!data/endpoints.json
data/
tmp/
test*
!test/

1
.husky/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
_

6
.husky/pre-commit Normal file
View File

@ -0,0 +1,6 @@
#!/bin/sh
. "$(dirname $0)/_/husky.sh"
npm test
npm run format
git add -A

View File

@ -2,6 +2,7 @@
.dockerignore
.gitignore
.prettierignore
.husky/
Dockerfile
LICENSE
@ -9,6 +10,7 @@ LICENSE
dist/
data/
docs/
*.md
tmp/
test*
!test/

238
CHANGELOG.md Normal file
View File

@ -0,0 +1,238 @@
# 3.2.0 - Internal refactor, more subcommand types, and more command type guards (2021-??-??)
- The custom logger changed: `$.log` no longer exists, it's just `console.log`. Now you don't have to do `import $ from "../core/lib"` at the top of every file that uses the custom logger.
- Utility functions are no longer attached to the command menu. Stuff like `$.paginate()` and `$(5).pluralise()` instead need to be imported and used as regular functions.
- The `paginate` function was reworked to reduce the amount of repetition you had to do.
- Events are no longer loaded dynamically. What you do is `import "./some-file"` which will run the code in there, attaching the event to the client. Since events are no longer bound to certain files, you can keep them more organized:
- Since there can be multiple listeners per event, large event files can be split up into more organized blocks.
- You can also group together related events like `channelCreate` and `channelDelete` and show the relation in one single file rather than splitting them up just because they're two different events.
- Lots of files were moved around:
- The `core` folder represents the command handler and is pretty much treated as if it was an external module. That means that instead of importing different items from each file, you'd import it from its index file (which is shortened to `import {} from ../core`). My hope is to move this section to its own module eventually™.
- Other `core` files that were more or less specific to the bot were moved outside, either at the top-level or into `modules`. This includes stuff like the library file containing utility functions as well as structures for storing/loading data. Since they're at the top now, there's less typing involved in importing them (`../lib` instead of `../core/lib` and so on).
- Commands are still dynamically loaded. This won't change.
- Added more type guards to the `Command` class, reducing the amount of unused properties there are.
- If a command has `endpoint: true` specified, it'll now prevent adding subcommands at compile-time rather than relying on runtime warnings.
- Added a `NamedCommand` subclass on top-level commands (default exports) as well as keyed subcommands (basically the ones with a hardcoded value). `NamedCommand`s have access to `aliases`. Having `aliases` on something like a numeric subcommand (ie `$test 5`) doesn't really make sense.
- Added more features to the `Command` class as well:
- You can now restrict certain commands to Guild-only channels or DM-only channels. Unfortunately, there's a bug in TypeScript where callbacks don't get affected by discriminated unions. So for now, if you set a command's channel type, just do a non-null assertion on `guild` and a `TextChannel` assertion for `channel` (and vice versa).
- A command can now be designated as NSFW-only.
- Added more subcommand types:
- Channel: `<#...>`
- Role: `<@&...>`
- Emote: `<a:some_name:ID>`
- Message: `https://discordapp.com/channels/<Guild ID>/<Channel ID>/<Message ID>` or `<Channel ID>-<Message ID>` from the "Copy Message Link" and "Copy ID" (shift) buttons.
- ID: Any Discord ID. In order to use this, you have to specify which subcommand type you want to redirect it to. For example, to replicate the old behavior with plain IDs being converted to user IDs, you first implement user `user: new Command(...)` then do `id: "user"`.
- Some changes to subcommands:
- User: `<@...>` and `<@!...>` - Its default state is more restricted. It no longer accepts standalone IDs by default.
- You'll notice in a lot of commands as well as the template that properties are destructured. While using `$` will work just fine, having `{message, channel, guild}` will let you access properties using `channel` instead of `$.channel` and so on.
- Direct messaging the bot now listens for commands. You don't need a prefix when doing this, it's assumed you're running a command.
- Command invocations are no longer logged every single time. Now the catch block shows the command used and the arguments, and unhandled rejections related to Discord are captured too, showing the same information.
- I added Husky and I think I've got its pre-commit hook to work. If this goes as expected, the formatter should be called every commit so there aren't any more formatting commits.
- Internally, the core message handler and the `Command` class(es) are very de-spaghettified compared to before. Its methods are a lot more modular now.
- Retroactively added version history for TravBot-v3.
- Revised documentation.
# 3.1.10 (2021-04-06)
- Ported the rest of features from TravBot-v2
- Prototyped stream notifications
- Added eco bet command
- Added several entries to the `whois` list
- Added functionality for reacting to in-line replies
# 3.1.9 (2021-03-28)
- Stops deleting the `emote` invocation
- Added channel lock for `eco`
- Listens for CheeseBot's "Remember to drink water!" message and reacts with 🚱
- Added message quoting
- Added sandboxed regex query to `lsemotes` with timeout
- Added `info guild` for other guilds
# 3.1.8 - Introduce a terrible hack to reduce memory usage and a few other less significant changes (2021-02-16)
- Add the titular hack™ aka "pulling CC modding on discord.js".
- Reduce the usage of caches where possible (don't remember whether I eliminated all of them or not; note that guild members, roles and emojis can be assumed to be always cached if the guild object is available), especially in the info command (because I have effectively broken the automatic members cache with the titular hack). Also get rid of calls to `BaseManager#resolve` to make cache lookups explicit.
- Get rid of usages of `@ts-ignore` (never do this, or I'll kill you!!!).
- Enable sourcemaps for seeing the source code lines in the error stack traces.
- Raise the target JS edition to ES2019 since Node.js installed on the deployment machine is version 15.x anyway.
# 3.1.7 - Added time command for user-submitted timezones (2021-01-25)
# 3.1.6 - Added emote dumper (2021-01-03)
# 3.1.5 - Attempting to fix the emote name resolution (2020-12-20)
This is an attempt at fixing some notable problems with the `emote` and `react` commands, including the following:
- `leaSMUG` would resolve to `leaSmug` (should be fixed by taking capitalization into account and making an offset number from each difference)
- `leaCheese` would resolve to `leaCheeseAngry` (should be fixed by taking length into account in its heuristics)
# 3.1.4 - New formatter settings, hotfix, and feature (2020-12-16)
- Added `eco monday`
- Fixed `eval` command
- Ported `eco guild`
- Added more message linking options to `react`
- Added formatter
- Fixed error prevention in `neko` command
# 3.1.3 - Ported the eco command (2020-12-15)
- Ported the `eco` command, as well as `eco shop` and `eco buy`
- Public Rollout
# 3.1.2 - Added music functionality and ported more commands (2020-10-24)
- Added music functionality via Lavalink
- Ported the following commands:
- `lsemotes`
- `shorten`
- `eval`
- `info bot`
- `admin clear`
- `cookie`
- `neko`
- `ok`
- `owoify`
- `desc`
- `react`
- `say`
- Bug fixes to `info guild`
# 3.1.1 - Began the porting process (2020-09-11)
- Ported the following commands:
- `info`
- `8ball`
- `poll`
- `lsemotes`
- `scanemotes`
- Ported the following commands to `admin`:
- `status`
- `purge`
- `nick`
- `guilds`
- `activity`
- Added pinging bot for prefix and var string prefix
- Added removing emotes in paginate if possible
- Added command aliases
- Added CodeQL
- Modularized finding members by their username
- Added documentation
- Added Docker support
# 3.1.0 - Restructured the project according to CrossExchange (2020-07-26)
Ported over CrossExchange v1.0.1 and added several additional features:
- Command Categories: This follows suit of [the pre-rework command structure](https://github.com/keanuplayz/TravBot-v3/tree/pre-typescript/src/Commands), where you have categories (now `utility` which gets capitalized to `Utility` for example) and miscellaneous commands.
- `subcommands` will be a reserved directory name to allow you to split up big command files into smaller ones. `commands/subcommands` is ignored as does `commands/utility/subcommands`.
- The way you'd work with splitting up these commands is that instead of doing `export default new Command(...)`, you would instead do:
- `subcommands/part1.ts`: `export default new Command(...)`
- `main.ts`: `import sub from "./subcommands/part1"; const a = new Command(...); a.attach("layer", sub); export default a;`
- Command Permissions: These permissions will work with the recursive structure as well because it'd be useful to section off different subcommands into different permissions. For example, everyone has access to `.money` but if you want to add `.money set <user> <amount>` (better for organization), you'd simply assign a property to `.money set` and it'd affect everything below it unless overridden. See `admin.ts` for an example on how this works.
- Dynamically-Loaded Events: All events now read from the `events` folder. If you want to access the client, you can do so by importing it from the index. (`import {client} from "../index";`)
# 3.0.0 - Brainstormed first structure (2020-07-08)
- Adds folder-separated command categories.
- Adding commands now involves instantiating classes rather than exporting a function with some settings.
- Adds structures for better organization of commonly used classes like `Command` and `Event`.
# 2.8.4 - Reworked the react command (2020-09-05)
- `react` is now a fully versatile command for helping you react to other messages with non-server emotes.
- Now properly reacts to the previous message (bug fix).
- Provides you the option to react to any number of messages before your message (3 messages above yours for example).
- Renamed guild ID to message ID for clarity's sake.
- Now removes the bot's own reaction after a few seconds to make the reaction count more accurate.
- Now lets you react with multiple messages in a row.
- Now reacts with ❓ if no reactions were found at all (see below).
- `emote`:
- Is now case-sensitive again (because there are too many name conflicts).
- Accepts multiple emotes for tiled emotes.
- Now reacts to your message with ❓ instead of `None of those emote names were valid!` so that the bot doesn't spam the chat if you can't find the right emote (because you'll still be able to delete your messages).
- `thonk` now stores the last specified phrase so you can repeat a phrase with different diacritics.
# 2.8.3 - The ultimate meme (2020-08-08)
# 2.8.2 (2020-07-01)
- Added a changelog.
- Added an extra instruction to the readme's installation.
- Made commands utilize the existing `Array.random()` function.
- Removed concatenation when using template strings.
- Added `Number.pluralise()` for convenient pluralization.
- Reworked the `neko` command.
- Made `whoami` sync up with `whois` by using the same config.
- Fixed a bug with `emote` where it wouldn't find any upper case emotes and made it more lenient to just include any emote (so you don't have to remember the exact emote name).
- Moved lists and gathering shop items outside of `exports.run()` so that it initializes once during the bot's initialization (or when reloaded) rather than every time the command is called.
# 2.8.1 - Modularized eco shop and eco buy (2020-06-30)
- Fixed scanemotes sometimes not displaying all emotes. This was an issue of not accounting for whether an emote was animated or not.
- Modularized `eco shop` and `eco buy`. Shop items are now in the `shop` subfolder and `eco shop` now separates every 5 shop items into separate pages automatically.
# 2.8.0 - Added graphical welcome setting (2020-06-29)
- Adds a new option to the `set` and `conf` commands, allowing you to enable an image being sent as a welcome.
# 2.7.1 - Added eco buy laser bridge and reworked scanemotes (2020-06-28)
- `eco buy laser bridge` - Added a shop item. Buy what is technically a laser bridge. Costs 3 Mons.
- `insult` - Now pings the user who activated it.
- `scanemotes` - Reworked the command after a test run in a big server.
- Merged the unsorted and sorted emote listings into one section. The unsorted emotes pretty much had a random order as it was pretty much which emote was added first as the search went on, so nothing's gone there. `#1 :emote: x 20 - 30.532% (Bots: 132)`
- Bumped the cooldown from 1 hour to 1 day.
- An updated progress meter which now stays on a single channel at a time because it's no longer asynchronous. This progress bar also works with Discord's rate limits. `Searching channel ___... (___ messages scanned, x/y channels scanned)`
- Now includes all emotes in a server, even if they haven't been used.
- Now properly counts emote usage for reactions (whether or not a bot reacted to a message)
# 2.7.0 - Added percentages to scanemotes (2020-06-26)
## Major Changes
- Added an hour long cooldown to `scanemotes` per server because it's a very memory-intensive task to search through every single message.
- Added a second list of emotes to `scanemotes`, sorting by percentage of users-only usage.
- Added a function to the client's common functions to generate a page users can turn.
## Minor Changes
- `avatar`
- Now has proper error handling when searching by mention and ID.
- No longer pings the user, it just sends the image link by itself.
- `eco`
- Merges `sender.id + message.guild.id` into `compositeID` since it's so frequently used.
- Bug Fix: If you have exactly 1 Mon and you pay someone 1 Mon, they'll get 1 more Mon and you'll still have 1 Mon because the 0 coerces to false resetting your money, because JS soft comparison. Fixed by using the "in" operator instead.
- Uses else ifs to make the command marginally faster.
- Now properly handles mentions and extracting the user ID from them in the `pay` subcommand.
- Added a message that occurs when the user tries to buy an item that doesn't exist.
- Added an `insult` command which will have the bot type out the navy seals copypasta for a minute.
- Modified the `invite` command to auto-generate a link based on the current bot client ID rather than having it be hardcoded to TravBot specifically.
- Added error checking to `scanemotes` so users aren't left in the dark if something happens.
- On big servers, `scanemotes` should now have emotes actually show up.
# 2.6.1 - Hotfix: Scan emotes no longer requires admin (2020-06-22)
- Fixed the `scanemotes` command to no longer require admin permissions. This was due to an oversight: There can be channels which the bot doesn't have access to, ie private channels. You have to check if the bot has access to a channel because the filter will gather all text-based channels regardless. Admin permissions overrides all restrictions, which is why it only worked with admin permissions.
- Entering a username in the `avatar` command unsuccessfully will now send a message in chat.
# 2.6.0 - Added the ability to get other users' avatars and see emote usage (2020-06-19)
- You can now scan the current guild for emote usage, collecting all emotes used in messages and reactions. (example below)
![2020-06-19 04_08_22-Window](https://user-images.githubusercontent.com/44940783/85116219-98a69280-b1e2-11ea-9246-b8f5ff2537ea.png)
- You can now get other avatars by providing an ID (works even when the bot doesn't share the same server as that user), username, or by pinging them.
- Included the fix for `serverinfo`.
# 2.5.3 (2020-06-16)
- Changed default prefix for setup.
- Enhanced `react` command. New optional guildID arg.
- Added message logger.
- Fixed calc permission error.
- Removed `delete` command.
- Added ignored and notified channels to logger.
- Added images to logger. Added author to logger.
- Emote command is now not case-sensitive.
# 2.5.2 - Bug fixes to the "eco" command (2020-06-01)
- Now prevents users from sending negative amounts of money to others (minimum of 1 Mon).
- Also prevents users from sending decimal amounts.
- Fixes a potentially wrong substring for user IDs.
- Now requires an argument when using the "desc" command.
# 2.5.1 (2020-05-18)
- Added shop functionality to eco.
- Fixed faulty guild check.
- Attempt at fixing emote for eco cute.
- Pluralised "mon" for eco handhold.
- Added `translate` command.
# 2.5.0 - Added the "pay" sub-command to "eco" (2020-05-09)
# 2.4.1 (2020-04-18)
- Added Procfile.
- Updated whoami's keys.
- Rewrote `desc` command.
# 2.4.0 - Implemented music system (2020-04-11)
- VC Rename command
- Travis CI configuration
- Music system
- Dependency updates

View File

@ -1,6 +1,24 @@
# TravBot-v3
Fourth revision of TravBot, version number 3.0.0.
<p align="center">
<img src="https://i.imgur.com/l2E2Tfi.png"/>
</p>
<p align="center">
<a href="https://choosealicense.com/licenses/mit/">
<img src="https://img.shields.io/github/license/keanuplayz/travbot-v3" alt="License">
</a>
<a href="https://github.com/keanuplayz/TravBot-v3/blob/master/CHANGELOG.md">
<img src="https://img.shields.io/github/package-json/v/keanuplayz/travbot-v3" alt="Version">
</a>
<a href="https://github.com/keanuplayz/TravBot-v3/blob/typescript/CHANGELOG.md">
<img src="https://img.shields.io/github/package-json/v/keanuplayz/travbot-v3/typescript" alt="Version (Dev)">
</a>
<a href="https://discord.js.org/">
<img src="https://img.shields.io/github/package-json/dependency-version/keanuplayz/travbot-v3/discord.js" alt="Discord.js Version">
</a>
</p>
Fourth revision of [TravBot](https://github.com/keanuplayz/TravBot), version number 3.0.0.
This is the repo belonging to the code of TravBot v3.
@ -8,14 +26,19 @@ This version will be the final revision of TravBot, this being the final structu
Thank you for coming on this journey with me, but it is time to put the big changes to an end.
## Installation
1. `npm install`
2. `npm run build`
3. `npm start`
## Contributing
To get information on how to contribute to this project, see the [overview](docs/Overview.md) as well as other files in the `docs` folder meant for developers.
## Special Thanks
Special thanks to:
- Lexi Sother (TravBot v2, structural overhaul. Reviewing PRs.)
- WatDuhHekBro (a _lot_ of contributions to TravBot v2)
- Zeehondie (Ideas for various commands.)
### License
Refer to the [LICENSE](https://github.com/keanuplayz/TravBot-v3/tree/master/LICENSE) file.
- Lexi Sother (TravBot v2, structural overhaul. Reviewing PRs.)
- WatDuhHekBro (a *lot* of contributions to TravBot v2)
- Zeehondie (Ideas for various commands.)

View File

@ -1,31 +0,0 @@
{
"sfw": {
"tickle": "/img/tickle",
"slap": "/img/slap",
"poke": "/img/poke",
"pat": "/img/pat",
"neko": "/img/neko",
"meow": "/img/meow",
"lizard": "/img/lizard",
"kiss": "/img/kiss",
"hug": "/img/hug",
"foxGirl": "/img/fox_girl",
"feed": "/img/feed",
"cuddle": "/img/cuddle",
"why": "/why",
"catText": "/cat",
"fact": "/fact",
"nekoGif": "/img/ngif",
"kemonomimi": "/img/kemonomimi",
"holo": "/img/holo",
"smug": "/img/smug",
"baka": "/img/baka",
"woof": "/img/woof",
"spoiler": "/spoiler",
"wallpaper": "/img/wallpaper",
"goose": "/img/goose",
"gecg": "/img/gecg",
"avatar": "/img/avatar",
"waifu": "/img/waifu"
}
}

View File

125
docs/DesignDecisions.md Normal file
View File

@ -0,0 +1,125 @@
# 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.
# The Command Handler
## The Scope of the Command Handler
What this does:
- Provides the `Command`/`NamedCommand` classes.
- Dynamically loads commands and attaches runtime metadata.
- Provides utility functions specific to Discord to make certain patterns of commands less tedious to implement.
What this doesn't do:
- Manage the general file system or serialization/deserialization of data.
- Provide general utility functions.
- Provide any Discord-related functionality besides strictly command handling.
## Client Creation
Creating the client is beyond the scope of the command handler and will not be abstracted away. Instead, the user will simply attach the command handler to the client to initialize it.
- This makes it so if a user wants to specify their own `ClientOptions` when instantiating the client, it's less troublesome to implement.
- The user can export the client and use it throughout different parts of their code.
## Bot-Specific Mentions
Pinging the bot will display the current guild prefix. The bot mention will not serve as an alternate prefix.
- When talking about a bot, the bot might be pinged to show who it is. It could be in the middle (so don't listen for a prefix anywhere) or it could be at the start (so only listen to a standalone ping).
- It likely isn't a common use case to ping the bot. The only time it would really shine is in the event two bots have a prefix conflict, but the command that changes prefixes can simply add a parameter to deal with that case. For example, instead of `@bot set prefix <prefix>`, you'd use `set prefix <prefix> @bot`.
## Direct Messages
When direct messaging a bot, no prefixes will be used at all because it's assumed that you're executing a command. Because the only people allowed is the user and the bot, NSFW-only commands can also be executed here.
## Permission Setup
Because the command handler provides no specific permission set, it's up to the user to come up with functions to add permissions as well as create the enum that assigns permissions.
- The `permission` property of a `Command` instance is `-1` by default, which means to inherit the permission level from the parent command. If you want, you can create your enum like this: `enum Permissions {INHERIT = -1, USER, ADMIN}`, where `Permissions.USER = 0` and `Permissions.ADMIN = 1`.
# Miscellaneous
## Static Event Loading
While dynamic loading fits very well with commands, it was more or less clunky design to try and make events fit the same model:
- There are no restrictions when it comes to command names, and the name of the file will determine the name of the command, which avoids repetition. Events on the other hand involved lots of boilerplate to get static types back.
- Since there can be multiple listeners per event, large event files can be split up into more organized blocks.
- Likewise, small event listeners which span multiple events can be grouped together like `channelCreate` and `channelDelete`, showing the relation in one single file rather than splitting them up just because they're two different events.
## Testing
For TravBot, there'll be two types of tests: standard unit tests and manual integration tests.
- Standard unit tests are executed only on isolated functions and are part of the pre-commit hook.
- Somehow, including the bot in an import chain will cause the system to crash (same error message as [this](https://stackoverflow.com/questions/66102858/discord-clientuser-is-not-a-constructor)). That's why the integration tests are manually done. There would be a list of inputs and outputs to check of each command for tests while simultaneously serving as a help menu with examples of all possible inputs/outputs for others to see.
- An idea which will not be implemented is prompting the user for inputs during the tests. This is no better than manual tests, worse actually, because if this had to run before each commit, it'd quickly become a nightmare.
- Maybe take some ideas from something like [this](https://github.com/stuyy/jest-unit-tests-demo) in the future to get tests to properly work.
- Another possibility is to use `client.emit(...)` then mock the `message.channel.send(...)` function which would listen if the input is correct.

View File

@ -1,286 +1,111 @@
# What this is
# Table of Contents
This is a user-friendly version of the project's structure (compared to the amalgamation that has become [specifications](Specifications.md) which is just a list of design decisions and isn't actually helpful at all for understanding the code). This will follow the line of logic that the program would run through.
# Building/Setup
- `npm run dev` runs the TypeScript compiler in watch mode, meaning that any changes you make to the code will automatically reload the bot.
- This will take all the files in `src` (where all the code is) and compile it into `dist` which is what the program actually uses.
- If there's a runtime error, `dist\commands\test.js:25:30` for example, then you have to into `dist` instead of `src`, then find the line that corresponds.
# Launching
When you start the program, it'll run the code in `index` (meaning both `src/index.ts` and `dist/index.js`, they're the same except that `dist/<...>.js` is compiled). The code in `index` will call `setup` and check if `data/config.json` exists, prompting you if it doesn't. It'll then run initialization code.
- [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)
# Structure
- `commands` contains all the commands.
- `defs` contains static definitions.
- `core` contains the foundation of the program. You won't need to worry about this unless you're modifying pre-existing behavior of the `Command` class for example or add a function to the library.
- `events` contains all the events. Again, you generally won't need to touch this unless you're listening for a new Discord event.
- `src`: Contains all the code for the bot itself. Code directly in this folder is for the starting index file as well as commonly accessed utility files.
- `core`: This is currently where the command handler is. Try to keep it as isolated as possible, it might split off to become its own module.
- `commands`: Where all the dynamically loaded commands are stored. You can use a subfolder to specify the command category. Specify a `modules` folder to create files that are ignored by the command loader.
- `modules`: This is where mostly single-purpose blocks of code go. (This is **not** the same as a `modules` folder under `commands`.)
- `defs`: Contains static definitions.
- `dist`: This is where the runnable code in `src` compiles to. (The directory structure mirrors `src`.)
- `data`: Holds all the dynamic/private data used by the bot. This folder is not meant to hold definitions.
- `docs`: Information for developers who want to contribute.
# The Command Class
# Version Numbers
A valid command file must be located in `commands` and export a default `Command` instance. Assume that we're working with `commands/money.ts`.
When a new version is ready to be declared...
- ...update the [changelog](../CHANGELOG.md).
- ...update the version numbers in [package.json](../package.json) and [package-lock.json](../package-lock.json).
```js
import Command from '../core/command';
## Naming Versions
export default new Command({
//...
Because versions are assigned to batches of changes rather than single changes (or even single commits), versioning is used a bit differently in order to avoid wasting version numbers.
`<prototype>.<major>.<minor>-<patch>`
- `<prototype>` is a defined as the overarching version group of TravBot. TravBot-v2 went by `2.x.x` and all versions of TravBot-v3 will go by `3.x.x`.
- `<major>` includes any big overhauls or revisions of the entire codebase.
- `<minor>` includes any feature additions in a specific area of the codebase.
- `<patch>` will be pretty much for any very small changes like a quick bug fix or typos. *Note: Normally, these would probably get grouped up, but if there hasn't been a proper version in a while, this will get pushed as a patch.*
*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.
# Utility Functions
## [src/core (libd)](../src/core/libd.ts) - Utility functions specific for working with Discord
`paginate()`
```ts
const pages = ['one', 'two', 'three'];
const msg = await channel.send(pages[0]);
paginate(msg, author.id, pages.length, (page) => {
msg.edit(pages[page]);
});
```
The `run` property can be either a function or a string. If it's a function, you get one parameter, `$` which represents the common library (see below). If it's a string, it's a variable string.
`prompt()`
```ts
const msg = await channel.send('Are you sure you want to delete this?');
- `%author%` pings the person who sent the message.
- `%prefix%` gets the bot's current prefix in the selected server.
```js
import Command from '../core/command';
import { CommonLibrary } from '../core/lib';
export default new Command({
run: '%author%, make sure to use the prefix! (%prefix)',
});
```
...is equal to...
```js
import Command from '../core/command';
import { CommonLibrary } from '../core/lib';
import { getPrefix } from '../core/structures';
export default new Command({
async run($: CommonLibrary): Promise<any> {
$.channel.send(
`${$.author.toString()}, make sure to use the prefix! (${getPrefix(
$.guild,
)})`,
);
},
});
```
Now here's where it gets fun. The `Command` class is a recursive structure, containing other `Command` instances as properties.
- `subcommands` is used for specific keywords for accessing a certain command. For example, `$eco pay` has a subcommand of `pay`.
```js
import Command from '../core/command';
import { CommonLibrary } from '../core/lib';
export default new Command({
subcommands: {
pay: new Command({
//...
}),
},
});
```
There's also `user` which listens for a ping or a Discord ID, `<@237359961842253835>` and `237359961842253835` respectively. The argument will be a `User` object.
```js
import Command from '../core/command';
import { CommonLibrary } from '../core/lib';
export default new Command({
user: new Command({
async run($: CommonLibrary): Promise<any> {
$.debug($.args[0].username); // "WatDuhHekBro"
},
}),
});
```
There's also `number` which checks for any number type except `Infinity`, converting the argument to a `number` type.
```js
import Command from '../core/command';
import { CommonLibrary } from '../core/lib';
export default new Command({
number: new Command({
async run($: CommonLibrary): Promise<any> {
$.debug($.args[0] + 5);
},
}),
});
```
And then there's `any` which catches everything else that doesn't fall into the above categories. The argument will be a `string`.
```js
import Command from '../core/command';
import { CommonLibrary } from '../core/lib';
export default new Command({
any: new Command({
async run($: CommonLibrary): Promise<any> {
$.debug($.args[0].toUpperCase());
},
}),
});
```
Of course, maybe you just want to get string arguments regardless, and since everything is an optional property, so you'd then just include `any` and not `subcommands`, `user`, or `number`.
## Other Properties
- `description`: The description for that specific command.
- `endpoint`: A `boolean` determining whether or not to prevent any further arguments. For example, you could prevent `$money daily too many arguments`.
- `usage`: Provide a custom usage for the help menu. Do note that this is relative to the subcommand, so the below will result in `$money pay <user> <amount>`.
```js
import Command from '../core/command';
import { CommonLibrary } from '../core/lib';
export default new Command({
subcommands: {
pay: new Command({
usage: '<user> <amount>',
}),
},
});
```
- `permission`: The permission to restrict the current command to. You can specify it for certain subcommands, useful for having `$money` be open to anyone but not `$money admin`. If it's `null` (default), the permission will inherit whatever was declared before (if any). The default value is NOT the same as `Command.PERMISSIONS.NONE`.
- `aliases`: A list of aliases (if any).
## Alternatives to Nesting
For a lot of the metadata properties like `description`, you must provide them when creating a new `Command` instance. However, you can freely modify and attach subcommands, useful for splitting a command into multiple files.
```js
import pay from "./subcommands/pay";
const cmd = new Command({
description: "Handle your money."
});
cmd.subcommands.set("pay", pay);
cmd.run = async($: CommonLibrary): Promise<any> {
$.debug($.args);
};
cmd.any = new Command({
prompt(msg, author.id, () => {
//...
});
export default cmd;
```
## Error Handling
Any errors caused when using `await` or just regular synchronous functions will be automatically caught, you don't need to worry about those. However, promises must be caught manually. For example, `$.channel.send("")` will throw an error because you can't send empty messages to Discord, but since it's a promise, it'll just fade without throwing an error. There are two ways to do this:
- `$.channel.send("").catch($.handler.bind($))`
- `$.channel.send("").catch(error => $.handler(error))`
# The Common Library
This is the container of functions available without having to import `core/lib`, usually as `$`. When accessing this from a command's `run` function, it'll also come with shortcuts to other properties.
## Custom Wrappers
- `$(5)` = `new NumberWrapper(5)`
- `$("text")` = `new StringWrapper("text")`
- `$([1,2,3])` = `new ArrayWrapper([1,2,3])`
## Custom Logger
- `$.log(...)`
- `$.warn(...)`
- `$.error(...)`
- `$.debug(...)`
- `$.ready(...)` (only meant to be used once at the start of the program)
## Convenience Functions
This modularizes certain patterns of code to make things easier.
- `$.paginate()`: 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.
```js
const pages = ['one', 'two', 'three'];
const msg = await $.channel.send(pages[0]);
$.paginate(msg, $.author.id, pages.length, (page) => {
msg.edit(pages[page]);
`callMemberByUsername()`
```ts
callMemberByUsername(message, args.join(" "), (member) => {
channel.send(`Your nickname is ${member.nickname}.`);
});
```
- `$.prompt()`: Prompts the user about a decision before following through.
## [src/lib](../src/lib.ts) - General utility functions
```js
const msg = await $.channel.send('Are you sure you want to delete this?');
$.prompt(msg, $.author.id, () => {
delete this; // Replace this with actual code.
});
```
- `$.getMemberByUsername()`: Gets a user by their username. Gets the first one then rolls with it.
- `$.callMemberByUsername()`: Convenience function to handle cases where someone isn't found by a username automatically.
```js
$.callMemberByUsername($.message, $.args.join(' '), (member) => {
$.channel.send(`Your nickname is ${member.nickname}.`);
});
```
## Dynamic Properties
These will be accessible only inside a `Command` and will change per message.
- `$.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`
# Wrappers
This is similar to modifying a primitive object's `prototype` without actually doing so.
## NumberWrapper
- `.pluralise()`: A substitute for not having to do `amount === 1 ? "singular" : "plural"`. For example, `$(x).pluralise("credit", "s")` will return `"1 credit"` and/or `"5 credits"` respectively.
- `.pluraliseSigned()`: This builds on `.pluralise()` and adds a sign at the beginning for marking changes/differences. `$(0).pluraliseSigned("credit", "s")` will return `"+0 credits"`.
## StringWrapper
- `.replaceAll()`: A non-regex alternative to replacing everything in a string. `$("test").replaceAll('t', 'z')` = `"zesz"`.
- `.toTitleCase()`: Capitalizes the first letter of each word. `$("this is some text").toTitleCase()` = `"This Is Some Text"`.
## ArrayWrapper
- `.random()`: Returns a random element from an array. `$([1,2,3]).random()` could be any one of those elements.
- `.split()`: Splits an array into different arrays by a specified length. `$([1,2,3,4,5,6,7,8,9,10]).split(3)` = `[[1,2,3],[4,5,6],[7,8,9],[10]]`.
# Other Library Functions
These do have to be manually imported, which are used more on a case-by-case basis.
- `formatTimestamp()`: Formats a `Date` object into your system's time. `YYYY-MM-DD HH:MM:SS`
- `formatUTCTimestamp()`: Formats a `Date` object into UTC time. `YYYY-MM-DD HH:MM:SS`
- `botHasPermission()`: Tests if a bot has a certain permission in a specified guild.
- `parseArgs()`: Turns `call test "args with spaces" "even more spaces"` into `["call", "test", "args with spaces", "even more spaces"]`, inspired by the command line.
- `parseVars()`: Replaces all `%` args in a string with stuff you specify. For example, you can replace all `nop` with `asm`, and `register %nop%` will turn into `register asm`. Useful for storing strings with variables in one place them accessing them in another place.
- `isType()`: Used for type-checking. Useful for testing `any` types.
- `select()`: Checks if a variable matches a certain type and uses the fallback value if not. (Warning: Type checking is based on the fallback's type. Be sure that the "type" parameter is accurate to this!)
- `Random`: An object of functions containing stuff related to randomness. `Random.num` is a random decimal, `Random.int` is a random integer, `Random.chance` takes a number ranging from `0` to `1` as a percentage. `Random.sign` takes a number and has a 50-50 chance to be negative or positive. `Random.deviation` takes a number and a magnitude and produces a random number within those confines. `(5, 2)` would produce any number between `3` and `7`.
# Other Core Functions
- `permissions::hasPermission()`: Checks if a `Member` has a certain permission.
- `permissions::getPermissionLevel()`: Gets a `Member`'s permission level according to the permissions enum defined in the file.
- `structures::getPrefix()`: Get the current prefix of the guild or the bot's prefix if none is found.
# The other core files
- `core/permissions`: Contains all the permission roles and checking functions.
- `core/structures`: Contains all the code handling dynamic JSON data. Has a one-to-one connection with each file generated, for example, `Config` which calls `super("config")` meaning it writes to `data/config.json`.
- `core/storage`: Handles most of the file system operations, all of the ones related to `data` at least.
- `pluralise()`: A substitute for not having to do `amount === 1 ? "singular" : "plural"`. For example, `pluralise(x, "credit", "s")` will return `"1 credit"` and/or `"5 credits"` respectively.
- `pluraliseSigned()`: This builds on `pluralise()` and adds a sign at the beginning for marking changes/differences. `pluraliseSigned(0, "credit", "s")` will return `"+0 credits"`.
- `replaceAll()`: A non-regex alternative to replacing everything in a string. `replaceAll("test", "t", "z")` = `"zesz"`.
- `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]]`.

View File

@ -1,20 +0,0 @@
# Getting Started
1. `npm install`
2. `npm run build`
3. `npm start`
# Getting Started (Developers)
1. `npm install`
2. `npm run dev`
3. Familiarize yourself with the [project's structure](Documentation.md).
4. Make sure to avoid using `npm run build`! This will remove all your dev dependencies (in order to reduce space used). Instead, use `npm run once` to compile and build in non-dev mode.
5. Begin developing.
## Don't forget to...
- ...update the [changelog](CHANGELOG.md) and any other necessary docs.
- ...update the version numbers in `package.json` and `package-lock.json`.
- ...make sure the test suite passes by running `npm test`.
- ...format the code by running `npm run format`.

227
docs/Overview.md Normal file
View File

@ -0,0 +1,227 @@
# Table of Contents
- [Introduction](#introduction)
- [Setting up the development environment](#setting-up-the-development-environment)
- [Adding a new command](#adding-a-new-command)
- [Adding a new non-command feature](#adding-a-new-non-command-feature)
# 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 `$`.
# Setting up the development environment
1. `npm install`
2. `npm run dev` *(runs the TypeScript compiler in watch mode, meaning any changes you make to the code will automatically reload the bot)*
*Note: Make sure to avoid using `npm run build`! This will remove all your dev dependencies (in order to reduce space used). Instead, use `npm run once` to compile and build in non-dev mode.*
*Note: If you update one of the APIs or utility functions, make sure to update the [documentation](Documentation.md).*
# 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
```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";
export default new NamedCommand({
async run({message, channel, guild, author, member, client, args}) {
channel.send("test");
}
});
```
### 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.
```ts
import "./modules/myModule";
```
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.
```ts
import {client} from "..";
client.on("message", (message) => {
//...
});
```
As long as you make sure to add that import statement in the index file itself, the event(s) will load.
**Just make sure you instantiate the client *before* you import a module or you'll get a runtime error.**
`index.ts`
```ts
import {Client} from "discord.js";
export const client = new Client();
//...
import "./modules/myModule";
```

View File

@ -1,48 +0,0 @@
# Structure
The top-level directory is reserved for files that have to be there for it to work as well as configuration files.
- `src`: Contains all the code for the bot itself. Code in this directory is for independent tasks keeping the initialization out of the subdirectories.
- `core`: This is where core structures and critical functions for the bot go.
- `modules`: This is where modules go that accomplish one specific purpose but isn't so necessary for the bot to function. The goal is to be able to safely remove these without too much trouble.
- `commands`: Here's the place to store commands. The file name determines the command name.
- `subcommands/`: All commands here are ignored by the category loader. Here is where you can split commands into different files. Also works per directory, for example, `utility/subcommands/` is ignored.
- `<directory>/`: Specify a directory which'll group commands into a category. For example, a `utility` folder would make all commands inside have the `Utility` category.
- `<file>.ts`: All commands at this level will have the `Miscellaneous` category.
- `events`: Here's the place to store events. The file name determines the event type.
- `dist`: This is where the runnable code in `src` compiles to. (The directory structure mirrors `src`.)
- `test`: Used for all the unit tests.
- `data`: Holds all the dynamic data used by the bot. This is what you modify if you want to change stuff for just your instance of the bot.
- `standard`: Contains all the standard data to be used with the project itself. It's part of the code and will not be checked for inaccuracies because it's not meant to be easily modified.
- `docs`: Used for information about the design of the project.
# Specific Files
This list starts from `src`/`dist`.
- `index`: This is the entry point of the bot. Here is where all the initialization is done, because the idea is to keep repeatable code in separate modules while having code that runs only once here designating this is **the** starting point.
- `setup`: Used for the first time the bot is loaded, walking the user through setting up the bot.
- `core/lib`: Exports a function object which lets you wrap values letting you call special functions as well as calling utility functions common to all commands.
- `core/structures`: Contains all the structures that the dynamic data read from JSON files should follow. This exports instances of these classes.
- `core/command`: Contains the class used to instantiate commands.
- `core/event`: Contains the class used to instantiate events.
- `core/storage`: Exports an object which handles everything related to files.
- `core/wrappers`: Contains classes that wrap around values and provide extra functionality.
- `core/permissions`: The file containing everything related to permissions.
# Design Decisions
- All top-level files (relative to `src`/`dist`) should ideally be independent, one-time use scripts. This helps separate code that just initializes once and reusable code that forms the bulk of the main program itself. That's why all the file searching and loading commands/events will be done in `index`.
- Wrapper objects were designed with the idea of letting you assign functions directly to native objects [without the baggage of actually doing so](https://developer.mozilla.org/en-US/docs/Web/JavaScript/The_performance_hazards_of__%5B%5BPrototype%5D%5D_mutation).
- `test` should be a keyword for any file not tracked by git and generally be more flexible to play around with. It should also be automatically generated during initialization in `commands` so you can have templates ready for new commands.
- The storage module should not provide an auto-write feature. This would actually end up overcomplicating things especially when code isn't fully blocking.
- I think it's much easier to make a template system within the code itself. After all, the templates only change when the code changes to use new keys or remove old ones. You'll also be able to dedicate specific classes for the task rather than attaching meta tags to arrays and objects.
- I decided to forget about implementing dynamic events. I don't think it'll work with this setup. After all, there are only so many events you can use, whereas commands can have any number of ones, more suitable for dynamic loading. The main reasons were unsecure types and no easy way to access variables like the config or client.
- I want to make attaching subcommands more flexible, so you can either add subcommands in the constructor or by using a method. However, you have to add all other properties when instantiating a command.
- All commands should have only one parameter. This parameter is meant to be flexible so you can add properties without making a laundry list of parameters. It also has convenience functions too so you don't have to import the library for each command.
- The objects in `core/structures` are initialized into a special object once and then cached into memory automatically due to an import system. This means you don't have to keep loading JSON files over and over again even without the stack storage system. Because a JSON file resolves into an object, any extra keys are removed automatically (as it isn't initialized into the data) and any property that doesn't yet exist on the JSON object will be initialized into something. You can then have specific functions like `addUser` onto objects with a specific structure.
- There were several possible ways to go about implementing aliases and subaliases.
- Two properties on the `Command` class, `aliases: string[]` and `subaliases: {[key: string]: string[]}`.
- Exporting a const named `aliases` which would handle top-level aliases.
- For `subaliases`, either making subaliases work like redirects (Instead of doing `new Command(...)`, you'd do `"nameOfSubcommand"`), or define properties on `Command`.
- What I ended up doing for aliases is making an `aliases` property on `Command` and then converting those string arrays to a more usable structure with strings pointing to the original commands. `aliases` at the very top will determine global aliases and is pretty much the exception in the program's logic. `aliases` elsewhere will provide aliases per subcommand. For anything other than the top-level or `subcommands`, `aliases` does nothing (plus you'll get a warning about it).

10
jest.config.js Normal file
View File

@ -0,0 +1,10 @@
module.exports = {
roots: ["<rootDir>/src"],
testMatch: ["**/*.test.+(ts|tsx)"],
transform: {
"^.+\\.(ts|tsx)$": "ts-jest"
},
// The environment is the DOM by default, so discord.js fails to load because it's calling a Node-specific function.
// https://github.com/discordjs/discord.js/issues/3971#issuecomment-602010271
testEnvironment: "node"
};

10879
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,24 @@
{
"name": "d.js-v12-bot",
"version": "0.0.1",
"description": "A Discord bot built on Discord.JS v12",
"name": "travebot",
"version": "3.2.0",
"description": "TravBot Discord bot.",
"main": "dist/index.js",
"private": true,
"scripts": {
"build": "tsc --project tsconfig.prod.json && npm prune --production",
"start": "node .",
"once": "tsc && npm start",
"dev": "tsc-watch --onSuccess \"node . dev\"",
"test": "jest",
"format": "prettier --write **/*",
"postinstall": "husky install"
},
"dependencies": {
"canvas": "^2.7.0",
"chalk": "^4.1.0",
"discord.js": "^12.5.1",
"discord.js-lavalink-lib": "^0.1.8",
"figlet": "^1.5.0",
"glob": "^7.1.6",
"inquirer": "^7.3.3",
"mathjs": "^9.3.0",
"moment": "^2.29.1",
@ -20,30 +29,24 @@
},
"devDependencies": {
"@types/figlet": "^1.5.0",
"@types/glob": "^7.1.3",
"@types/inquirer": "^6.5.0",
"@types/jest": "^26.0.20",
"@types/mathjs": "^6.0.11",
"@types/mocha": "^8.2.0",
"@types/ms": "^0.7.31",
"@types/node": "^14.14.20",
"@types/ws": "^7.4.0",
"mocha": "^8.2.1",
"husky": "^5.0.6",
"jest": "^26.6.3",
"prettier": "2.1.2",
"ts-node": "^9.1.1",
"ts-jest": "^26.4.4",
"tsc-watch": "^4.2.9",
"typescript": "^3.9.7"
},
"scripts": {
"build": "tsc && npm prune --production",
"start": "node dist/index.js",
"once": "tsc && npm start",
"dev": "tsc-watch --onSuccess \"node dist/index.js dev\"",
"test": "mocha --require ts-node/register --extension ts --recursive",
"format": "prettier --write **/*"
},
"author": "Keanu Timmermans",
"license": "MIT",
"keywords": [
"discord.js",
"bot"
],
"author": "Keanu Timmermans",
"license": "MIT"
]
}

View File

@ -1,370 +0,0 @@
import Command from "../core/command";
import {CommonLibrary, logs, botHasPermission, clean} from "../core/lib";
import {Config, Storage} from "../core/structures";
import {PermissionNames, getPermissionLevel} from "../core/permissions";
import {Permissions} from "discord.js";
import * as discord from "discord.js";
function getLogBuffer(type: string) {
return {
files: [
{
attachment: Buffer.alloc(logs[type].length, logs[type]),
name: `${Date.now()}.${type}.log`
}
]
};
}
const activities = ["playing", "listening", "streaming", "watching"];
const statuses = ["online", "idle", "dnd", "invisible"];
export default new Command({
description:
"An all-in-one command to do admin stuff. You need to be either an admin of the server or one of the bot's mechanics to use this command.",
async run($: CommonLibrary): Promise<any> {
if (!$.member)
return $.channel.send(
"Couldn't find a member object for you! Did you make sure you used this in a server?"
);
const permLevel = getPermissionLevel($.member);
$.channel.send(
`${$.author.toString()}, your permission level is \`${PermissionNames[permLevel]}\` (${permLevel}).`
);
},
subcommands: {
set: new Command({
description: "Set different per-guild settings for the bot.",
run: "You have to specify the option you want to set.",
permission: Command.PERMISSIONS.ADMIN,
subcommands: {
prefix: new Command({
description: "Set a custom prefix for your guild. Removes your custom prefix if none is provided.",
usage: "(<prefix>)",
async run($: CommonLibrary): Promise<any> {
Storage.getGuild($.guild?.id || "N/A").prefix = null;
Storage.save();
$.channel.send(
`The custom prefix for this guild has been removed. My prefix is now back to \`${Config.prefix}\`.`
);
},
any: new Command({
async run($: CommonLibrary): Promise<any> {
Storage.getGuild($.guild?.id || "N/A").prefix = $.args[0];
Storage.save();
$.channel.send(`The custom prefix for this guild is now \`${$.args[0]}\`.`);
}
})
}),
welcome: new Command({
description: "Configure your server's welcome settings for the bot.",
usage: "type/channel <...>",
run: "You need to specify which part to modify, `type`/`channel`.",
subcommands: {
type: new Command({
description:
"Sets how welcome messages are displayed for your server. Removes welcome messages if unspecified.",
usage: "`none`/`text`/`graphical`",
async run($) {
if ($.guild) {
Storage.getGuild($.guild.id).welcomeType = "none";
Storage.save();
$.channel.send("Set this server's welcome type to `none`.");
} else {
$.channel.send("You must use this command in a server.");
}
},
// I should probably make this a bit more dynamic... Oh well.
subcommands: {
text: new Command({
async run($) {
if ($.guild) {
Storage.getGuild($.guild.id).welcomeType = "text";
Storage.save();
$.channel.send("Set this server's welcome type to `text`.");
} else {
$.channel.send("You must use this command in a server.");
}
}
}),
graphical: new Command({
async run($) {
if ($.guild) {
Storage.getGuild($.guild.id).welcomeType = "graphical";
Storage.save();
$.channel.send("Set this server's welcome type to `graphical`.");
} else {
$.channel.send("You must use this command in a server.");
}
}
})
}
}),
channel: new Command({
description: "Sets the welcome channel for your server. Type `#` to reference the channel.",
usage: "(<channel mention>)",
async run($) {
if ($.guild) {
Storage.getGuild($.guild.id).welcomeChannel = $.channel.id;
Storage.save();
$.channel.send(
`Successfully set ${$.channel} as the welcome channel for this server.`
);
} else {
$.channel.send("You must use this command in a server.");
}
},
// If/when channel types come out, this will be the perfect candidate to test it.
any: new Command({
async run($) {
if ($.guild) {
const match = $.args[0].match(/^<#(\d{17,19})>$/);
if (match) {
Storage.getGuild($.guild.id).welcomeChannel = match[1];
Storage.save();
$.channel.send(
`Successfully set this server's welcome channel to ${match[0]}.`
);
} else {
$.channel.send(
"You must provide a reference channel. You can do this by typing `#` then searching for the proper channel."
);
}
} else {
$.channel.send("You must use this command in a server.");
}
}
})
}),
message: new Command({
description:
"Sets a custom welcome message for your server. Use `%user%` as the placeholder for the user.",
usage: "(<message>)",
async run($) {
if ($.guild) {
Storage.getGuild($.guild.id).welcomeMessage = null;
Storage.save();
$.channel.send("Reset your server's welcome message to the default.");
} else {
$.channel.send("You must use this command in a server.");
}
},
any: new Command({
async run($) {
if ($.guild) {
const message = $.args.join(" ");
Storage.getGuild($.guild.id).welcomeMessage = message;
Storage.save();
$.channel.send(`Set your server's welcome message to \`${message}\`.`);
} else {
$.channel.send("You must use this command in a server.");
}
}
})
})
}
}),
stream: new Command({
description: "Set a channel to send stream notifications. Type `#` to reference the channel.",
usage: "(<channel mention>)",
async run($) {
if ($.guild) {
const guild = Storage.getGuild($.guild.id);
if (guild.streamingChannel) {
guild.streamingChannel = null;
$.channel.send("Removed your server's stream notifications channel.");
} else {
guild.streamingChannel = $.channel.id;
$.channel.send(`Set your server's stream notifications channel to ${$.channel}.`);
}
Storage.save();
} else {
$.channel.send("You must use this command in a server.");
}
},
// If/when channel types come out, this will be the perfect candidate to test it.
any: new Command({
async run($) {
if ($.guild) {
const match = $.args[0].match(/^<#(\d{17,19})>$/);
if (match) {
Storage.getGuild($.guild.id).streamingChannel = match[1];
Storage.save();
$.channel.send(`Successfully set this server's welcome channel to ${match[0]}.`);
} else {
$.channel.send(
"You must provide a reference channel. You can do this by typing `#` then searching for the proper channel."
);
}
} else {
$.channel.send("You must use this command in a server.");
}
}
})
})
}
}),
diag: new Command({
description: 'Requests a debug log with the "info" verbosity level.',
permission: Command.PERMISSIONS.BOT_SUPPORT,
async run($: CommonLibrary): Promise<any> {
$.channel.send(getLogBuffer("info"));
},
any: new Command({
description: `Select a verbosity to listen to. Available levels: \`[${Object.keys(logs).join(", ")}]\``,
async run($: CommonLibrary): Promise<any> {
const type = $.args[0];
if (type in logs) $.channel.send(getLogBuffer(type));
else
$.channel.send(
`Couldn't find a verbosity level named \`${type}\`! The available types are \`[${Object.keys(
logs
).join(", ")}]\`.`
);
}
})
}),
status: new Command({
description: "Changes the bot's status.",
permission: Command.PERMISSIONS.BOT_SUPPORT,
async run($: CommonLibrary): Promise<any> {
$.channel.send("Setting status to `online`...");
},
any: new Command({
description: `Select a status to set to. Available statuses: \`[${statuses.join(", ")}]\`.`,
async run($: CommonLibrary): Promise<any> {
if (!statuses.includes($.args[0])) return $.channel.send("That status doesn't exist!");
else {
$.client.user?.setStatus($.args[0]);
$.channel.send(`Setting status to \`${$.args[0]}\`...`);
}
}
})
}),
purge: new Command({
description: "Purges bot messages.",
permission: Command.PERMISSIONS.BOT_SUPPORT,
async run($: CommonLibrary): Promise<any> {
if ($.message.channel instanceof discord.DMChannel) {
return;
}
$.message.delete();
const msgs = await $.channel.messages.fetch({
limit: 100
});
const travMessages = msgs.filter((m) => m.author.id === $.client.user?.id);
await $.message.channel.send(`Found ${travMessages.size} messages to delete.`).then((m) =>
m.delete({
timeout: 5000
})
);
await $.message.channel.bulkDelete(travMessages);
}
}),
clear: new Command({
description: "Clears a given amount of messages.",
usage: "<amount>",
run: "A number was not provided.",
number: new Command({
description: "Amount of messages to delete.",
async run($: CommonLibrary): Promise<any> {
if ($.channel.type === "dm") {
await $.channel.send("Can't clear messages in the DMs!");
return;
}
$.message.delete();
const fetched = await $.channel.messages.fetch({
limit: $.args[0]
});
await $.channel.bulkDelete(fetched);
}
})
}),
eval: new Command({
description: "Evaluate code.",
usage: "<code>",
permission: Command.PERMISSIONS.BOT_OWNER,
// You have to bring everything into scope to use them. AFAIK, there isn't a more maintainable way to do this, but at least TS will let you know if anything gets removed.
async run({args, author, channel, client, guild, member, message}): Promise<any> {
try {
const code = args.join(" ");
let evaled = eval(code);
if (typeof evaled !== "string") evaled = require("util").inspect(evaled);
channel.send(clean(evaled), {code: "js", split: true});
} catch (err) {
channel.send(`\`ERROR\` \`\`\`js\n${clean(err)}\n\`\`\``);
}
}
}),
nick: new Command({
description: "Change the bot's nickname.",
permission: Command.PERMISSIONS.BOT_SUPPORT,
async run($: CommonLibrary): Promise<any> {
const nickName = $.args.join(" ");
await $.guild?.me?.setNickname(nickName);
if (botHasPermission($.guild, Permissions.FLAGS.MANAGE_MESSAGES))
$.message.delete({timeout: 5000}).catch($.handler.bind($));
$.channel.send(`Nickname set to \`${nickName}\``).then((m) => m.delete({timeout: 5000}));
}
}),
guilds: new Command({
description: "Shows a list of all guilds the bot is a member of.",
permission: Command.PERMISSIONS.BOT_SUPPORT,
async run($: CommonLibrary): Promise<any> {
const guildList = $.client.guilds.cache.array().map((e) => e.name);
$.channel.send(guildList);
}
}),
activity: new Command({
description: "Set the activity of the bot.",
permission: Command.PERMISSIONS.BOT_SUPPORT,
usage: "<type> <string>",
async run($: CommonLibrary): Promise<any> {
$.client.user?.setActivity(".help", {
type: "LISTENING"
});
$.channel.send("Activity set to default.");
},
any: new Command({
description: `Select an activity type to set. Available levels: \`[${activities.join(", ")}]\``,
async run($: CommonLibrary): Promise<any> {
const type = $.args[0];
if (activities.includes(type)) {
$.client.user?.setActivity($.args.slice(1).join(" "), {
type: $.args[0].toUpperCase()
});
$.channel.send(
`Set activity to \`${$.args[0].toUpperCase()}\` \`${$.args.slice(1).join(" ")}\`.`
);
} else
$.channel.send(
`Couldn't find an activity type named \`${type}\`! The available types are \`[${activities.join(
", "
)}]\`.`
);
}
})
}),
syslog: new Command({
description: "Sets up the current channel to receive system logs.",
permission: Command.PERMISSIONS.BOT_ADMIN,
async run($) {
if ($.guild) {
Config.systemLogsChannel = $.channel.id;
Config.save();
$.channel.send(`Successfully set ${$.channel} as the system logs channel.`);
} else {
$.channel.send("DM system log channels aren't supported.");
}
}
})
}
});

View File

@ -1,5 +1,5 @@
import Command from "../../core/command";
import {CommonLibrary} from "../../core/lib";
import {Command, NamedCommand} from "../../core";
import {random} from "../../lib";
const responses = [
"Most likely,",
@ -24,16 +24,16 @@ const responses = [
"Very doubtful,"
];
export default new Command({
export default new NamedCommand({
description: "Answers your question in an 8-ball manner.",
endpoint: false,
usage: "<question>",
run: "Please provide a question.",
any: new Command({
description: "Question to ask the 8-ball.",
async run($: CommonLibrary): Promise<any> {
const sender = $.message.author;
$.channel.send($(responses).random() + ` <@${sender.id}>`);
async run({message, channel, guild, author, member, client, args}) {
const sender = message.author;
channel.send(`${random(responses)} <@${sender.id}>`);
}
})
});

View File

@ -1,49 +1,55 @@
import Command from "../../core/command";
import {CommonLibrary} from "../../core/lib";
import {User} from "discord.js";
import {Command, NamedCommand} from "../../core";
import {random, parseVars} from "../../lib";
export default new Command({
const cookies = [
`has given %target% a chocolate chip cookie!`,
`has given %target% a soft homemade oatmeal cookie!`,
`has given %target% a plain, dry, old cookie. It was the last one in the bag. Gross.`,
`gives %target% a sugar cookie. What, no frosting and sprinkles? 0/10 would not touch.`,
`gives %target% a chocolate chip cookie. Oh wait, those are raisins. Bleck!`,
`gives %target% an enormous cookie. Poking it gives you more cookies. Weird.`,
`gives %target% a fortune cookie. It reads "Why aren't you working on any projects?"`,
`gives %target% a fortune cookie. It reads "Give that special someone a compliment"`,
`gives %target% a fortune cookie. It reads "Take a risk!"`,
`gives %target% a fortune cookie. It reads "Go outside."`,
`gives %target% a fortune cookie. It reads "Don't forget to eat your veggies!"`,
`gives %target% a fortune cookie. It reads "Do you even lift?"`,
`gives %target% a fortune cookie. It reads "m808 pls"`,
`gives %target% a fortune cookie. It reads "If you move your hips, you'll get all the ladies."`,
`gives %target% a fortune cookie. It reads "I love you."`,
`gives %target% a Golden Cookie. You can't eat it because it is made of gold. Dammit.`,
`gives %target% an Oreo cookie with a glass of milk!`,
`gives %target% a rainbow cookie made with love :heart:`,
`gives %target% an old cookie that was left out in the rain, it's moldy.`,
`bakes %target% fresh cookies, it smells amazing.`
];
export default new NamedCommand({
description: "Gives specified user a cookie.",
usage: "['all'/@user]",
run: ":cookie: Here's a cookie!",
any: new Command({
async run($: CommonLibrary): Promise<any> {
if ($.args[0] == "all") return $.channel.send(`${$.author} gave everybody a cookie!`);
}
}),
subcommands: {
all: new NamedCommand({
async run({message, channel, guild, author, member, client, args}) {
channel.send(`${author} gave everybody a cookie!`);
}
})
},
id: "user",
user: new Command({
description: "User to give cookie to.",
async run($: CommonLibrary): Promise<any> {
const sender = $.author;
const mention = $.message.mentions.users.first();
async run({message, channel, guild, author, member, client, args}) {
const sender = author;
const mention: User = args[0];
if (!mention) return;
if (mention.id == sender.id) return channel.send("You can't give yourself cookies!");
const cookies = [
`has given <@${mention.id}> a chocolate chip cookie!`,
`has given <@${mention.id}> a soft homemade oatmeal cookie!`,
`has given <@${mention.id}> a plain, dry, old cookie. It was the last one in the bag. Gross.`,
`gives <@${mention.id}> a sugar cookie. What, no frosting and sprinkles? 0/10 would not touch.`,
`gives <@${mention.id}> a chocolate chip cookie. Oh wait, those are raisins. Bleck!`,
`gives <@${mention.id}> an enormous cookie. Poking it gives you more cookies. Weird.`,
`gives <@${mention.id}> a fortune cookie. It reads "Why aren't you working on any projects?"`,
`gives <@${mention.id}> a fortune cookie. It reads "Give that special someone a compliment"`,
`gives <@${mention.id}> a fortune cookie. It reads "Take a risk!"`,
`gives <@${mention.id}> a fortune cookie. It reads "Go outside."`,
`gives <@${mention.id}> a fortune cookie. It reads "Don't forget to eat your veggies!"`,
`gives <@${mention.id}> a fortune cookie. It reads "Do you even lift?"`,
`gives <@${mention.id}> a fortune cookie. It reads "m808 pls"`,
`gives <@${mention.id}> a fortune cookie. It reads "If you move your hips, you'll get all the ladies."`,
`gives <@${mention.id}> a fortune cookie. It reads "I love you."`,
`gives <@${mention.id}> a Golden Cookie. You can't eat it because it is made of gold. Dammit.`,
`gives <@${mention.id}> an Oreo cookie with a glass of milk!`,
`gives <@${mention.id}> a rainbow cookie made with love :heart:`,
`gives <@${mention.id}> an old cookie that was left out in the rain, it's moldy.`,
`bakes <@${mention.id}> fresh cookies, it smells amazing.`
];
if (mention.id == sender.id) return $.channel.send("You can't give yourself cookies!");
$.channel.send(`:cookie: <@${sender.id}> ` + cookies[Math.floor(Math.random() * cookies.length)]);
return channel.send(
`:cookie: <@${sender.id}> ${parseVars(random(cookies), {
target: mention.toString()
})}`
);
}
})
});

View File

@ -1,11 +1,11 @@
import Command from "../../core/command";
import {isAuthorized, getMoneyEmbed} from "./subcommands/eco-utils";
import {DailyCommand, PayCommand, GuildCommand, LeaderboardCommand} from "./subcommands/eco-core";
import {BuyCommand, ShopCommand} from "./subcommands/eco-shop";
import {MondayCommand} from "./subcommands/eco-extras";
import {BetCommand} from "./subcommands/eco-bet";
import {Command, NamedCommand, callMemberByUsername} from "../../core";
import {isAuthorized, getMoneyEmbed} from "./modules/eco-utils";
import {DailyCommand, PayCommand, GuildCommand, LeaderboardCommand} from "./modules/eco-core";
import {BuyCommand, ShopCommand} from "./modules/eco-shop";
import {MondayCommand} from "./modules/eco-extras";
import {BetCommand} from "./modules/eco-bet";
export default new Command({
export default new NamedCommand({
description: "Economy command for Monika.",
async run({guild, channel, author}) {
if (isAuthorized(guild, channel)) channel.send(getMoneyEmbed(author));
@ -20,6 +20,7 @@ export default new Command({
monday: MondayCommand,
bet: BetCommand
},
id: "user",
user: new Command({
description: "See how much money someone else has by using their user ID or pinging them.",
async run({guild, channel, args}) {
@ -28,7 +29,7 @@ export default new Command({
}),
any: new Command({
description: "See how much money someone else has by using their username.",
async run({guild, channel, args, callMemberByUsername, message}) {
async run({guild, channel, args, message}) {
if (isAuthorized(guild, channel))
callMemberByUsername(message, args.join(" "), (member) => {
channel.send(getMoneyEmbed(member.user));

View File

@ -1,15 +1,12 @@
import Command from "../../core/command";
import {Command, NamedCommand} from "../../core";
import figlet from "figlet";
export default new Command({
export default new NamedCommand({
description: "Generates a figlet of your input.",
async run($) {
const input = $.args.join(" ");
if (!$.args[0]) {
$.channel.send("You have to provide input for me to create a figlet!");
return;
}
$.channel.send(
async run({message, channel, guild, author, member, client, args}) {
const input = args.join(" ");
if (!args[0]) return channel.send("You have to provide input for me to create a figlet!");
return channel.send(
"```" +
figlet.textSync(`${input}`, {
horizontalLayout: "full"

View File

@ -1,14 +1,14 @@
import Command from "../../core/command";
import {Command, NamedCommand} from "../../core";
export default new Command({
export default new NamedCommand({
description: "Insult TravBot! >:D",
async run($) {
$.channel.startTyping();
async run({message, channel, guild, author, member, client, args}) {
channel.startTyping();
setTimeout(() => {
$.channel.send(
`${$.author.toString()} What the fuck did you just fucking say about me, you little bitch? I'll have you know I graduated top of my class in the Navy Seals, and I've been involved in numerous secret raids on Al-Quaeda, and I have over 300 confirmed kills. I am trained in gorilla warfare and I'm the top sniper in the entire US armed forces. You are nothing to me but just another target. I will wipe you the fuck out with precision the likes of which has never been seen before on this Earth, mark my fucking words. You think you can get away with saying that shit to me over the Internet? Think again, fucker. As we speak I am contacting my secret network of spies across the USA and your IP is being traced right now so you better prepare for the storm, maggot. The storm that wipes out the pathetic little thing you call your life. You're fucking dead, kid. I can be anywhere, anytime, and I can kill you in over seven hundred ways, and that's just with my bare hands. Not only am I extensively trained in unarmed combat, but I have access to the entire arsenal of the United States Marine Corps and I will use it to its full extent to wipe your miserable ass off the face of the continent, you little shit. If only you could have known what unholy retribution your little "clever" comment was about to bring down upon you, maybe you would have held your fucking tongue. But you couldn't, you didn't, and now you're paying the price, you goddamn idiot. I will shit fury all over you and you will drown in it. You're fucking dead, kiddo.`
channel.send(
`${author} What the fuck did you just fucking say about me, you little bitch? I'll have you know I graduated top of my class in the Navy Seals, and I've been involved in numerous secret raids on Al-Quaeda, and I have over 300 confirmed kills. I am trained in gorilla warfare and I'm the top sniper in the entire US armed forces. You are nothing to me but just another target. I will wipe you the fuck out with precision the likes of which has never been seen before on this Earth, mark my fucking words. You think you can get away with saying that shit to me over the Internet? Think again, fucker. As we speak I am contacting my secret network of spies across the USA and your IP is being traced right now so you better prepare for the storm, maggot. The storm that wipes out the pathetic little thing you call your life. You're fucking dead, kid. I can be anywhere, anytime, and I can kill you in over seven hundred ways, and that's just with my bare hands. Not only am I extensively trained in unarmed combat, but I have access to the entire arsenal of the United States Marine Corps and I will use it to its full extent to wipe your miserable ass off the face of the continent, you little shit. If only you could have known what unholy retribution your little "clever" comment was about to bring down upon you, maybe you would have held your fucking tongue. But you couldn't, you didn't, and now you're paying the price, you goddamn idiot. I will shit fury all over you and you will drown in it. You're fucking dead, kiddo.`
);
$.channel.stopTyping();
channel.stopTyping();
}, 60000);
}
});

View File

@ -1,13 +1,10 @@
import Command from "../../core/command";
import {Command, NamedCommand, CHANNEL_TYPE} from "../../core";
export default new Command({
export default new NamedCommand({
description: "Chooses someone to love.",
async run($) {
if ($.guild) {
const member = $.guild.members.cache.random();
$.channel.send(`I love ${member.user.username}!`);
} else {
$.channel.send("You must use this command in a guild!");
}
channelType: CHANNEL_TYPE.GUILD,
async run({message, channel, guild, author, client, args}) {
const member = guild!.members.cache.random();
channel.send(`I love ${member.nickname ?? member.user.username}!`);
}
});

View File

@ -1,33 +1,31 @@
import Command from "../../../core/command";
import $ from "../../../core/lib";
import {Storage} from "../../../core/structures";
import {Command, NamedCommand, askYesOrNo} from "../../../core";
import {pluralise} from "../../../lib";
import {Storage} from "../../../structures";
import {isAuthorized, getMoneyEmbed, getSendEmbed, ECO_EMBED_COLOR} from "./eco-utils";
import {User} from "discord.js";
export const BetCommand = new Command({
export const BetCommand = new NamedCommand({
description: "Bet your Mons with other people.",
usage: "<user> <amount> <duration>",
run: "Who are you betting with?",
user: new Command({
description: "User to bet with.",
// handles missing amount argument
async run({args, author, channel, guild}): Promise<any> {
async run({args, author, channel, guild}) {
if (isAuthorized(guild, channel)) {
const target = args[0];
// handle invalid target
if (target.id == author.id)
return channel.send("You can't bet Mons with yourself!");
else if (target.bot && process.argv[2] !== "dev")
return channel.send("You can't bet Mons with a bot!");
if (target.id == author.id) return channel.send("You can't bet Mons with yourself!");
else if (target.bot && process.argv[2] !== "dev") return channel.send("You can't bet Mons with a bot!");
return channel.send("How much are you betting?");
}
} else return;
},
number: new Command({
description: "Amount of Mons to bet.",
// handles missing duration argument
async run({args, author, channel, guild}): Promise<any> {
async run({args, author, channel, guild}) {
if (isAuthorized(guild, channel)) {
const sender = Storage.getUser(author.id);
const target = args[0] as User;
@ -35,29 +33,27 @@ export const BetCommand = new Command({
const amount = Math.floor(args[1]);
// handle invalid target
if (target.id == author.id)
return channel.send("You can't bet Mons with yourself!");
if (target.id == author.id) return channel.send("You can't bet Mons with yourself!");
else if (target.bot && process.argv[2] !== "dev")
return channel.send("You can't bet Mons with a bot!");
// handle invalid amount
if (amount <= 0)
return channel.send("You must bet at least one Mon!");
if (amount <= 0) return channel.send("You must bet at least one Mon!");
else if (sender.money < amount)
return channel.send("You don't have enough Mons for that.", getMoneyEmbed(author));
else if (receiver.money < amount)
return channel.send("They don't have enough Mons for that.", getMoneyEmbed(target));
return channel.send("How long until the bet ends?");
}
} else return;
},
any: new Command({
description: "Duration of the bet.",
async run({client, args, author, message, channel, guild, askYesOrNo}): Promise<any> {
async run({client, args, author, message, channel, guild}) {
if (isAuthorized(guild, channel)) {
// [Pertinence to make configurable on the fly.]
// Lower and upper bounds for bet
const durationBounds = { min:"1m", max:"1d" };
const durationBounds = {min: "1m", max: "1d"};
const sender = Storage.getUser(author.id);
const target = args[0] as User;
@ -66,22 +62,19 @@ export const BetCommand = new Command({
const duration = parseDuration(args[2].trim());
// handle invalid target
if (target.id == author.id)
return channel.send("You can't bet Mons with yourself!");
if (target.id == author.id) return channel.send("You can't bet Mons with yourself!");
else if (target.bot && process.argv[2] !== "dev")
return channel.send("You can't bet Mons with a bot!");
// handle invalid amount
if (amount <= 0)
return channel.send("You must bet at least one Mon!");
if (amount <= 0) return channel.send("You must bet at least one Mon!");
else if (sender.money < amount)
return channel.send("You don't have enough Mons for that.", getMoneyEmbed(author));
else if (receiver.money < amount)
return channel.send("They don't have enough Mons for that.", getMoneyEmbed(target));
// handle invalid duration
if (duration <= 0)
return channel.send("Invalid bet duration");
if (duration <= 0) return channel.send("Invalid bet duration");
else if (duration <= parseDuration(durationBounds.min))
return channel.send(`Bet duration is too short, maximum duration is ${durationBounds.min}`);
else if (duration >= parseDuration(durationBounds.max))
@ -89,7 +82,9 @@ export const BetCommand = new Command({
// Ask target whether or not they want to take the bet.
const takeBet = await askYesOrNo(
await channel.send(`<@${target.id}>, do you want to take this bet of ${$(amount).pluralise("Mon", "s")}`),
await channel.send(
`<@${target.id}>, do you want to take this bet of ${pluralise(amount, "Mon", "s")}`
),
target.id
);
@ -104,62 +99,77 @@ export const BetCommand = new Command({
Storage.save();
// Notify both users.
await channel.send(`<@${target.id}> has taken <@${author.id}>'s bet, the bet amount of ${$(amount).pluralise("Mon", "s")} has been deducted from each of them.`);
await channel.send(
`<@${target.id}> has taken <@${author.id}>'s bet, the bet amount of ${pluralise(
amount,
"Mon",
"s"
)} has been deducted from each of them.`
);
// Wait for the duration of the bet.
client.setTimeout(async () => {
return client.setTimeout(async () => {
// In debug mode, saving the storage will break the references, so you have to redeclare sender and receiver for it to actually save.
const sender = Storage.getUser(author.id);
const receiver = Storage.getUser(target.id);
// [TODO: when D.JSv13 comes out, inline reply to clean up.]
// When bet is over, give a vote to ask people their thoughts.
const voteMsg = await channel.send(`VOTE: do you think that <@${target.id}> has won the bet?\nhttps://discord.com/channels/${guild!.id}/${channel.id}/${message.id}`);
const voteMsg = await channel.send(
`VOTE: do you think that <@${
target.id
}> has won the bet?\nhttps://discord.com/channels/${guild!.id}/${channel.id}/${
message.id
}`
);
await voteMsg.react("✅");
await voteMsg.react("❌");
// Filter reactions to only collect the pertinent ones.
voteMsg.awaitReactions(
(reaction, user) => {
return ["✅", "❌"].includes(reaction.emoji.name);
},
// [Pertinence to make configurable on the fly.]
{ time: parseDuration("2m") }
).then(reactions => {
// Count votes
const okReaction = reactions.get("✅");
const noReaction = reactions.get("❌");
const ok = okReaction ? (okReaction.count ?? 1) - 1 : 0;
const no = noReaction ? (noReaction.count ?? 1) - 1 : 0;
voteMsg
.awaitReactions(
(reaction, user) => {
return ["✅", "❌"].includes(reaction.emoji.name);
},
// [Pertinence to make configurable on the fly.]
{time: parseDuration("2m")}
)
.then((reactions) => {
// Count votes
const okReaction = reactions.get("✅");
const noReaction = reactions.get("❌");
const ok = okReaction ? (okReaction.count ?? 1) - 1 : 0;
const no = noReaction ? (noReaction.count ?? 1) - 1 : 0;
if (ok > no) {
receiver.money += amount * 2;
channel.send(`By the people's votes, <@${target.id}> has won the bet that <@${author.id}> had sent them.`);
}
else if (ok < no) {
sender.money += amount * 2;
channel.send(`By the people's votes, <@${target.id}> has lost the bet that <@${author.id}> had sent them.`);
}
else {
sender.money += amount;
receiver.money += amount;
channel.send(`By the people's votes, <@${target.id}> couldn't be determined to have won or lost the bet that <@${author.id}> had sent them.`);
}
sender.ecoBetInsurance -= amount;
receiver.ecoBetInsurance -= amount;
Storage.save();
});
if (ok > no) {
receiver.money += amount * 2;
channel.send(
`By the people's votes, <@${target.id}> has won the bet that <@${author.id}> had sent them.`
);
} else if (ok < no) {
sender.money += amount * 2;
channel.send(
`By the people's votes, <@${target.id}> has lost the bet that <@${author.id}> had sent them.`
);
} else {
sender.money += amount;
receiver.money += amount;
channel.send(
`By the people's votes, <@${target.id}> couldn't be determined to have won or lost the bet that <@${author.id}> had sent them.`
);
}
sender.ecoBetInsurance -= amount;
receiver.ecoBetInsurance -= amount;
Storage.save();
});
}, duration);
}
else
await channel.send(`<@${target.id}> has rejected your bet, <@${author.id}>`);
}
} else return await channel.send(`<@${target.id}> has rejected your bet, <@${author.id}>`);
} else return;
}
})
})
})
});
/**
* Parses a duration string into milliseconds
* Examples:
@ -174,17 +184,15 @@ function parseDuration(duration: string): number {
// get the rest as value
let value: number = +duration.substring(0, duration.length - 1);
if (!["d","h","m","s"].includes(unit) || isNaN(value))
return 0;
if (!["d", "h", "m", "s"].includes(unit) || isNaN(value)) return 0;
if (unit === "d")
value *= 86400000; // 1000ms * 60s * 60m * 24h
else if (unit === "h")
value *= 3600000; // 1000ms * 60s * 60m
else if (unit === "m")
value *= 60000; // 1000ms * 60s
else if (unit === "s")
value *= 1000; // 1000ms
if (unit === "d") value *= 86400000;
// 1000ms * 60s * 60m * 24h
else if (unit === "h") value *= 3600000;
// 1000ms * 60s * 60m
else if (unit === "m") value *= 60000;
// 1000ms * 60s
else if (unit === "s") value *= 1000; // 1000ms
return value;
}

View File

@ -1,9 +1,9 @@
import Command from "../../../core/command";
import $ from "../../../core/lib";
import {Storage} from "../../../core/structures";
import {Command, NamedCommand, prompt} from "../../../core";
import {pluralise} from "../../../lib";
import {Storage} from "../../../structures";
import {isAuthorized, getMoneyEmbed, getSendEmbed, ECO_EMBED_COLOR} from "./eco-utils";
export const DailyCommand = new Command({
export const DailyCommand = new NamedCommand({
description: "Pick up your daily Mons. The cooldown is per user and every 22 hours to allow for some leeway.",
aliases: ["get"],
async run({author, channel, guild}) {
@ -37,7 +37,7 @@ export const DailyCommand = new Command({
}
});
export const GuildCommand = new Command({
export const GuildCommand = new NamedCommand({
description: "Get info on the guild's economy as a whole.",
async run({guild, channel}) {
if (isAuthorized(guild, channel)) {
@ -74,7 +74,7 @@ export const GuildCommand = new Command({
}
});
export const LeaderboardCommand = new Command({
export const LeaderboardCommand = new NamedCommand({
description: "See the richest players.",
aliases: ["top"],
async run({guild, channel, client}) {
@ -90,7 +90,7 @@ export const LeaderboardCommand = new Command({
fields.push({
name: `#${i + 1}. ${user.username}#${user.discriminator}`,
value: $(users[id].money).pluralise("Mon", "s")
value: pluralise(users[id].money, "Mon", "s")
});
}
@ -108,10 +108,11 @@ export const LeaderboardCommand = new Command({
}
});
export const PayCommand = new Command({
export const PayCommand = new NamedCommand({
description: "Send money to someone.",
usage: "<user> <amount>",
run: "Who are you sending this money to?",
id: "user",
user: new Command({
run: "You need to enter an amount you're sending!",
number: new Command({
@ -141,7 +142,7 @@ export const PayCommand = new Command({
run: "You must use the format `eco pay <user> <amount>`!"
}),
any: new Command({
async run({args, author, channel, guild, prompt}) {
async run({args, author, channel, guild}) {
if (isAuthorized(guild, channel)) {
const last = args.pop();
@ -177,7 +178,8 @@ export const PayCommand = new Command({
return prompt(
await channel.send(
`Are you sure you want to send ${$(amount).pluralise(
`Are you sure you want to send ${pluralise(
amount,
"Mon",
"s"
)} to this person?\n*(This message will automatically be deleted after 10 seconds.)*`,

View File

@ -1,10 +1,10 @@
import Command from "../../../core/command";
import {Storage} from "../../../core/structures";
import {Command, NamedCommand} from "../../../core";
import {Storage} from "../../../structures";
import {isAuthorized, getMoneyEmbed} from "./eco-utils";
const WEEKDAY = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
export const MondayCommand = new Command({
export const MondayCommand = new NamedCommand({
description: "Use this on a UTC Monday to get an extra Mon. Does not affect your 22 hour timer for `eco daily`.",
async run({guild, channel, author}) {
if (isAuthorized(guild, channel)) {

View File

@ -1,5 +1,5 @@
import {Message} from "discord.js";
import $ from "../../../core/lib";
import {random} from "../../../lib";
export interface ShopItem {
cost: number;
@ -43,7 +43,7 @@ export const ShopItems: ShopItem[] = [
description: "Buys what is technically a laser bridge.",
usage: "laser bridge",
run(message) {
message.channel.send($(lines).random(), {
message.channel.send(random(lines), {
files: [
{
attachment:

View File

@ -1,21 +1,21 @@
import Command from "../../../core/command";
import $ from "../../../core/lib";
import {Storage, getPrefix} from "../../../core/structures";
import {Command, NamedCommand, paginate} from "../../../core";
import {pluralise, split} from "../../../lib";
import {Storage, getPrefix} from "../../../structures";
import {isAuthorized, ECO_EMBED_COLOR} from "./eco-utils";
import {ShopItems, ShopItem} from "./eco-shop-items";
import {EmbedField} from "discord.js";
export const ShopCommand = new Command({
export const ShopCommand = new NamedCommand({
description: "Displays the list of items you can buy in the shop.",
async run({guild, channel, author}) {
if (isAuthorized(guild, channel)) {
function getShopEmbed(selection: ShopItem[], title = "Shop") {
function getShopEmbed(selection: ShopItem[], title: string) {
const fields: EmbedField[] = [];
for (const item of selection)
fields.push({
name: `**${item.title}** (${getPrefix(guild)}eco buy ${item.usage})`,
value: `${item.description} Costs ${$(item.cost).pluralise("Mon", "s")}.`,
value: `${item.description} Costs ${pluralise(item.cost, "Mon", "s")}.`,
inline: false
});
@ -31,22 +31,20 @@ export const ShopCommand = new Command({
};
}
// In case there's just one page, omit unnecessary details.
if (ShopItems.length <= 5) channel.send(getShopEmbed(ShopItems));
else {
const shopPages = $(ShopItems).split(5);
const pageAmount = shopPages.length;
const msg = await channel.send(getShopEmbed(shopPages[0], `Shop (Page 1 of ${pageAmount})`));
const shopPages = split(ShopItems, 5);
const pageAmount = shopPages.length;
$.paginate(msg, author.id, pageAmount, (page) => {
msg.edit(getShopEmbed(shopPages[page], `Shop (Page ${page + 1} of ${pageAmount})`));
});
}
paginate(channel, author.id, pageAmount, (page, hasMultiplePages) => {
return getShopEmbed(
shopPages[page],
hasMultiplePages ? `Shop (Page ${page + 1} of ${pageAmount})` : "Shop"
);
});
}
}
});
export const BuyCommand = new Command({
export const BuyCommand = new NamedCommand({
description: "Buys an item from the shop.",
usage: "<item>",
async run({guild, channel, args, message, author}) {

View File

@ -1,5 +1,5 @@
import $ from "../../../core/lib";
import {Storage} from "../../../core/structures";
import {pluralise} from "../../../lib";
import {Storage} from "../../../structures";
import {User, Guild, TextChannel, DMChannel, NewsChannel} from "discord.js";
export const ECO_EMBED_COLOR = 0xf1c40f;
@ -20,7 +20,7 @@ export function getMoneyEmbed(user: User): object {
fields: [
{
name: "Balance",
value: $(profile.money).pluralise("Mon", "s")
value: pluralise(profile.money, "Mon", "s")
}
]
}
@ -39,15 +39,15 @@ export function getSendEmbed(sender: User, receiver: User, amount: number): obje
})
},
title: "Transaction",
description: `${sender.toString()} has sent ${$(amount).pluralise("Mon", "s")} to ${receiver.toString()}!`,
description: `${sender.toString()} has sent ${pluralise(amount, "Mon", "s")} to ${receiver.toString()}!`,
fields: [
{
name: `Sender: ${sender.username}#${sender.discriminator}`,
value: $(Storage.getUser(sender.id).money).pluralise("Mon", "s")
value: pluralise(Storage.getUser(sender.id).money, "Mon", "s")
},
{
name: `Receiver: ${receiver.username}#${receiver.discriminator}`,
value: $(Storage.getUser(receiver.id).money).pluralise("Mon", "s")
value: pluralise(Storage.getUser(receiver.id).money, "Mon", "s")
}
],
footer: {
@ -62,7 +62,7 @@ export function getSendEmbed(sender: User, receiver: User, amount: number): obje
}
export function isAuthorized(guild: Guild | null, channel: TextChannel | DMChannel | NewsChannel): boolean {
if (guild?.id === "637512823676600330" && channel?.id === "669464416420364288" || process.argv[2] === "dev") return true;
if ((guild?.id === "637512823676600330" && channel?.id === "669464416420364288") || IS_DEV_MODE) return true;
else {
channel.send("Sorry, this command can only be used in Monika's emote server. (#mon-stocks)");
return false;

View File

@ -1,28 +1,54 @@
/// @ts-nocheck
import {URL} from "url";
import FileManager from "../../core/storage";
import Command from "../../core/command";
import {CommonLibrary, getContent} from "../../core/lib";
import {Command, NamedCommand} from "../../core";
import {getContent} from "../../lib";
const endpoints = FileManager.read("endpoints");
const endpoints: {sfw: {[key: string]: string}} = {
sfw: {
tickle: "/img/tickle",
slap: "/img/slap",
poke: "/img/poke",
pat: "/img/pat",
neko: "/img/neko",
meow: "/img/meow",
lizard: "/img/lizard",
kiss: "/img/kiss",
hug: "/img/hug",
foxGirl: "/img/fox_girl",
feed: "/img/feed",
cuddle: "/img/cuddle",
why: "/why",
catText: "/cat",
fact: "/fact",
nekoGif: "/img/ngif",
kemonomimi: "/img/kemonomimi",
holo: "/img/holo",
smug: "/img/smug",
baka: "/img/baka",
woof: "/img/woof",
spoiler: "/spoiler",
wallpaper: "/img/wallpaper",
goose: "/img/goose",
gecg: "/img/gecg",
avatar: "/img/avatar",
waifu: "/img/waifu"
}
};
export default new Command({
export default new NamedCommand({
description: "Provides you with a random image with the selected argument.",
async run($: CommonLibrary): Promise<any> {
console.log(endpoints.sfw);
$.channel.send(
async run({message, channel, guild, author, member, client, args}) {
channel.send(
`Please provide an image type. Available arguments:\n\`[${Object.keys(endpoints.sfw).join(", ")}]\`.`
);
},
any: new Command({
description: "Image type to send.",
async run($: CommonLibrary): Promise<any> {
if (!($.args[0] in endpoints.sfw)) return $.channel.send("Couldn't find that endpoint!");
let baseURL = "https://nekos.life/api/v2";
let url = new URL(`${baseURL}${endpoints.sfw[$.args[0]]}`);
async run({message, channel, guild, author, member, client, args}) {
const arg = args[0];
if (!(arg in endpoints.sfw)) return channel.send("Couldn't find that endpoint!");
let url = new URL(`https://nekos.life/api/v2${endpoints.sfw[arg]}`);
const content = await getContent(url.toString());
$.channel.send(content.url);
return channel.send(content.url);
}
})
});

View File

@ -1,67 +1,67 @@
import Command from "../../core/command";
import {CommonLibrary} from "../../core/lib";
import {Command, NamedCommand} from "../../core";
import {random} from "../../lib";
export default new Command({
const responses = [
"boomer",
"zoomer",
"the last generationer",
"the last airbender",
"fire nation",
"fire lord",
"guy fieri",
"guy from final fight",
"haggar",
"Max Thunder from Streets of Rage 2",
"police guy who fires bazookas",
"Mr. X",
"Leon Its Wrong If Its Not Ada Wong S. Kennedy.",
"Jill",
"JFK",
"george bush",
"obama",
"the world",
"copy of scott pilgrim vs the world",
"ok",
"ko",
"Hot Daddy Venomous",
"big daddy",
"John Cena",
"BubbleSpurJarJarBinks",
"T-Series",
"pewdiepie",
"markiplier",
"jacksepticeye",
"vanossgaming",
"miniladd",
"Traves",
"Wilbur Soot",
"sootrhianna",
"person with tiny ears",
"anti-rabbit",
"homo sapiens",
"homo",
"cute kitty",
"ugly kitty",
"sadness",
"doomer",
"gloomer",
"bloomer",
"edgelord",
"weeb",
"m'lady",
"Mr. Crabs",
"hand",
"lahoma",
"big man",
"fox",
"pear",
"cat",
"large man"
];
export default new NamedCommand({
description: "Sends random ok message.",
async run($: CommonLibrary): Promise<any> {
const responses = [
"boomer",
"zoomer",
"the last generationer",
"the last airbender",
"fire nation",
"fire lord",
"guy fieri",
"guy from final fight",
"haggar",
"Max Thunder from Streets of Rage 2",
"police guy who fires bazookas",
"Mr. X",
"Leon Its Wrong If Its Not Ada Wong S. Kennedy.",
"Jill",
"JFK",
"george bush",
"obama",
"the world",
"copy of scott pilgrim vs the world",
"ok",
"ko",
"Hot Daddy Venomous",
"big daddy",
"John Cena",
"BubbleSpurJarJarBinks",
"T-Series",
"pewdiepie",
"markiplier",
"jacksepticeye",
"vanossgaming",
"miniladd",
"Traves",
"Wilbur Soot",
"sootrhianna",
"person with tiny ears",
"anti-rabbit",
"homo sapiens",
"homo",
"cute kitty",
"ugly kitty",
"sadness",
"doomer",
"gloomer",
"bloomer",
"edgelord",
"weeb",
"m'lady",
"Mr. Crabs",
"hand",
"lahoma",
"big man",
"fox",
"pear",
"cat",
"large man"
];
$.channel.send("ok " + responses[Math.floor(Math.random() * responses.length)]);
async run({message, channel, guild, author, member, client, args}) {
channel.send(`ok ${random(responses)}`);
}
});

View File

@ -1,12 +1,12 @@
/// @ts-nocheck
import Command from "../../core/command";
import {CommonLibrary, getContent} from "../../core/lib";
import {Command, NamedCommand} from "../../core";
import {getContent} from "../../lib";
import {URL} from "url";
export default new Command({
export default new NamedCommand({
description: "OwO-ifies the input.",
async run($: CommonLibrary): Promise<any> {
let url = new URL(`https://nekos.life/api/v2/owoify?text=${$.args.join(" ")}`);
const content = await getContent(url.toString());
$.channel.send(content.owo);
async run({message, channel, guild, author, member, client, args}) {
let url = new URL(`https://nekos.life/api/v2/owoify?text=${args.join(" ")}`);
const content = (await getContent(url.toString())) as any; // Apparently, the object in question is {owo: string}.
channel.send(content.owo);
}
});

View File

@ -1,26 +1,25 @@
import {MessageEmbed} from "discord.js";
import Command from "../../core/command";
import {CommonLibrary} from "../../core/lib";
import {Command, NamedCommand} from "../../core";
export default new Command({
export default new NamedCommand({
description: "Create a poll.",
usage: "<question>",
run: "Please provide a question.",
any: new Command({
description: "Question for the poll.",
async run($: CommonLibrary): Promise<any> {
async run({message, channel, guild, author, member, client, args}) {
const embed = new MessageEmbed()
.setAuthor(
`Poll created by ${$.message.author.username}`,
$.message.guild?.iconURL({dynamic: true}) ?? undefined
`Poll created by ${message.author.username}`,
message.guild?.iconURL({dynamic: true}) ?? undefined
)
.setColor(0xffffff)
.setFooter("React to vote.")
.setDescription($.args.join(" "));
const msg = await $.channel.send(embed);
.setDescription(args.join(" "));
const msg = await channel.send(embed);
await msg.react("✅");
await msg.react("⛔");
$.message.delete({
message.delete({
timeout: 1000
});
}

View File

@ -1,11 +1,11 @@
import Command from "../../core/command";
import {Random} from "../../core/lib";
import {Command, NamedCommand} from "../../core";
import {Random} from "../../lib";
export default new Command({
export default new NamedCommand({
description: "Ravioli ravioli...",
usage: "[number from 1 to 9]",
async run($) {
$.channel.send({
async run({message, channel, guild, author, member, client, args}) {
channel.send({
embed: {
title: "Ravioli ravioli...",
image: {
@ -18,11 +18,11 @@ export default new Command({
});
},
number: new Command({
async run($) {
const arg: number = $.args[0];
async run({message, channel, guild, author, member, client, args}) {
const arg: number = args[0];
if (arg >= 1 && arg <= 9) {
$.channel.send({
channel.send({
embed: {
title: "Ravioli ravioli...",
image: {
@ -31,7 +31,7 @@ export default new Command({
}
});
} else {
$.channel.send("Please provide a number between 1 and 9.");
channel.send("Please provide a number between 1 and 9.");
}
}
})

View File

@ -1,4 +1,4 @@
import Command from "../../core/command";
import {Command, NamedCommand} from "../../core";
const letters: {[letter: string]: string[]} = {
a: "aáàảãạâấầẩẫậăắằẳẵặ".split(""),
@ -31,11 +31,11 @@ function transform(str: string) {
let phrase = "I have no currently set phrase!";
export default new Command({
export default new NamedCommand({
description: "Transforms your text into .",
usage: "thonk ([text])",
async run($) {
if ($.args.length > 0) phrase = $.args.join(" ");
$.channel.send(transform(phrase));
async run({message, channel, guild, author, member, client, args}) {
if (args.length > 0) phrase = args.join(" ");
channel.send(transform(phrase));
}
});

View File

@ -1,16 +1,16 @@
import Command from "../../core/command";
import {Command, NamedCommand} from "../../core";
import {MessageEmbed} from "discord.js";
// Anycasting Alert
const urban = require("relevant-urban");
export default new Command({
export default new NamedCommand({
description: "Gives you a definition of the inputted word.",
async run($) {
if (!$.args[0]) {
$.channel.send("Please input a word.");
async run({message, channel, guild, author, member, client, args}) {
if (!args[0]) {
channel.send("Please input a word.");
}
const res = await urban($.args.join(" ")).catch((e: Error) => {
return $.channel.send("Sorry, that word was not found.");
const res = await urban(args.join(" ")).catch((e: Error) => {
return channel.send("Sorry, that word was not found.");
});
const embed = new MessageEmbed()
.setColor(0x1d2439)
@ -22,6 +22,6 @@ export default new Command({
if (res.tags.length > 0 && res.tags.join(" ").length < 1024) {
embed.addField("Tags", res.tags.join(", "), true);
}
$.channel.send(embed);
channel.send(embed);
}
});

View File

@ -1,22 +1,19 @@
import Command from "../../core/command";
import {Command, NamedCommand} from "../../core";
import {MessageEmbed} from "discord.js";
// Anycasting Alert
const weather = require("weather-js");
export default new Command({
export default new NamedCommand({
description: "Shows weather info of specified location.",
async run($) {
if ($.args.length == 0) {
$.channel.send("You need to provide a city.");
return;
}
weather.find(
async run({message, channel, guild, author, member, client, args}) {
if (args.length == 0) return channel.send("You need to provide a city.");
return weather.find(
{
search: $.args.join(" "),
search: args.join(" "),
degreeType: "C"
},
function (err: any, result: any) {
if (err) $.channel.send(err);
if (err) channel.send(err);
var current = result[0].current;
var location = result[0].location;
const embed = new MessageEmbed()
@ -30,7 +27,7 @@ export default new Command({
.addField("Feels like", `${current.feelslike} Degrees`, true)
.addField("Winds", current.winddisplay, true)
.addField("Humidity", `${current.humidity}%`, true);
$.channel.send({
channel.send({
embed
});
}

View File

@ -1,5 +1,5 @@
import {User} from "discord.js";
import Command from "../../core/command";
import {Command, NamedCommand, getMemberByUsername, CHANNEL_TYPE} from "../../core";
// Quotes must be used here or the numbers will change
const registry: {[id: string]: string} = {
@ -30,59 +30,59 @@ const registry: {[id: string]: string} = {
"219661798742163467": "An extremely talented artist and modder.",
"440399719076855818":
"You are, uhh, Stay Put, Soft Puppy, Es-Pee, Swift Pacemaker, Smug Poyo, and many more.\n...Seriously, this woman has too many names.",
"243061915281129472": "Some random conlanger, worldbuilder and programmer doofus. ~~May also secretly be a nyan. :3~~",
"792751612904603668": "Some random nyan. :3 ~~May also secretly be a conlanger, worldbuilder and programmer doofus.~~",
"243061915281129472":
"Some random conlanger, worldbuilder and programmer doofus. ~~May also secretly be a nyan. :3~~",
"792751612904603668":
"Some random nyan. :3 ~~May also secretly be a conlanger, worldbuilder and programmer doofus.~~",
"367439475153829892": "A weeb.",
"760375501775700038": "˙qǝǝʍ ∀",
"389178357302034442": "In his dreams, he is the star. its him. <:itsMe:808174425253871657>",
"606395763404046349": "Me."
};
export default new Command({
export default new NamedCommand({
description: "Tells you who you or the specified user is.",
aliases: ["whoami"],
async run($) {
const id = $.author.id;
async run({message, channel, guild, author, member, client, args}) {
const id = author.id;
if (id in registry) {
$.channel.send(registry[id]);
channel.send(registry[id]);
} else {
$.channel.send("You haven't been added to the registry yet!");
channel.send("You haven't been added to the registry yet!");
}
},
id: "user",
user: new Command({
async run($) {
const user: User = $.args[0];
async run({message, channel, guild, author, member, client, args}) {
const user: User = args[0];
const id = user.id;
if (id in registry) {
$.channel.send(`\`${user.username}\` - ${registry[id]}`);
channel.send(`\`${user.username}\` - ${registry[id]}`);
} else {
$.channel.send(`\`${user.username}#${user.discriminator}\` hasn't been added to the registry yet!`);
channel.send(`\`${user.tag}\` hasn't been added to the registry yet!`);
}
}
}),
any: new Command({
async run($) {
if ($.guild) {
const query: string = $.args.join(" ");
const member = await $.getMemberByUsername($.guild, query);
channelType: CHANNEL_TYPE.GUILD,
async run({message, channel, guild, author, client, args}) {
const query = args.join(" ") as string;
const member = await getMemberByUsername(guild!, query);
if (member && member.id in registry) {
const id = member.id;
if (member && member.id in registry) {
const id = member.id;
if (id in registry) {
$.channel.send(`\`${member.user.username}\` - ${registry[member.id]}`);
} else {
$.channel.send(`\`${member.user.username}\` hasn't been added to the registry yet!`);
}
if (id in registry) {
channel.send(`\`${member.nickname ?? member.user.username}\` - ${registry[member.id]}`);
} else {
$.channel.send(`Couldn't find a user by the name of \`${query}\`!`);
channel.send(
`\`${member.nickname ?? member.user.username}\` hasn't been added to the registry yet!`
);
}
} else {
$.channel.send(
"You must run this in a guild! (*If you have the user's ID, you don't have to be in a guild.*)"
);
channel.send(`Couldn't find a user by the name of \`${query}\`!`);
}
}
})

View File

@ -1,135 +0,0 @@
import Command from "../core/command";
import {CommonLibrary} from "../core/lib";
import {loadCommands, categories} from "../core/command";
import {PermissionNames} from "../core/permissions";
export default new Command({
description: "Lists all commands. If a command is specified, their arguments are listed as well.",
usage: "([command, [subcommand/type], ...])",
aliases: ["h"],
async run($: CommonLibrary): Promise<any> {
const commands = await loadCommands();
let output = `Legend: \`<type>\`, \`[list/of/stuff]\`, \`(optional)\`, \`(<optional type>)\`, \`([optional/list/...])\``;
for (const [category, headers] of categories) {
output += `\n\n===[ ${category} ]===`;
for (const header of headers) {
if (header !== "test") {
const command = commands.get(header);
if (!command)
return $.warn(`Command "${header}" of category "${category}" unexpectedly doesn't exist!`);
output += `\n- \`${header}\`: ${command.description}`;
}
}
}
$.channel.send(output, {split: true});
},
any: new Command({
async run($: CommonLibrary): Promise<any> {
const commands = await loadCommands();
let header = $.args.shift() as string;
let command = commands.get(header);
if (!command || header === "test") return $.channel.send(`No command found by the name \`${header}\`!`);
if (command.originalCommandName) header = command.originalCommandName;
else $.warn(`originalCommandName isn't defined for ${header}?!`);
let permLevel = command.permission ?? Command.PERMISSIONS.NONE;
let usage = command.usage;
let invalid = false;
let selectedCategory = "Unknown";
for (const [category, headers] of categories) {
if (headers.includes(header)) {
if (selectedCategory !== "Unknown")
$.warn(
`Command "${header}" is somehow in multiple categories. This means that the command loading stage probably failed in properly adding categories.`
);
else selectedCategory = category;
}
}
for (const param of $.args) {
const type = command.resolve(param);
command = command.get(param);
permLevel = command.permission ?? permLevel;
switch (type) {
case Command.TYPES.SUBCOMMAND:
header += ` ${command.originalCommandName}`;
break;
case Command.TYPES.USER:
header += " <user>";
break;
case Command.TYPES.NUMBER:
header += " <number>";
break;
case Command.TYPES.ANY:
header += " <any>";
break;
default:
header += ` ${param}`;
break;
}
if (type === Command.TYPES.NONE) {
invalid = true;
break;
}
}
if (invalid) return $.channel.send(`No command found by the name \`${header}\`!`);
let append = "";
if (usage === "") {
const list: string[] = [];
command.subcommands.forEach((subcmd, subtag) => {
// Don't capture duplicates generated from aliases.
if (subcmd.originalCommandName === subtag) {
const customUsage = subcmd.usage ? ` ${subcmd.usage}` : "";
list.push(`- \`${header} ${subtag}${customUsage}\` - ${subcmd.description}`);
}
});
const addDynamicType = (cmd: Command | null, type: string) => {
if (cmd) {
const customUsage = cmd.usage ? ` ${cmd.usage}` : "";
list.push(`- \`${header} <${type}>${customUsage}\` - ${cmd.description}`);
}
};
addDynamicType(command.user, "user");
addDynamicType(command.number, "number");
addDynamicType(command.any, "any");
append = "Usages:" + (list.length > 0 ? `\n${list.join("\n")}` : " None.");
} else append = `Usage: \`${header} ${usage}\``;
let aliases = "None";
if (command.aliases.length > 0) {
aliases = "";
for (let i = 0; i < command.aliases.length; i++) {
const alias = command.aliases[i];
aliases += `\`${alias}\``;
if (i !== command.aliases.length - 1) aliases += ", ";
}
}
$.channel.send(
`Command: \`${header}\`\nAliases: ${aliases}\nCategory: \`${selectedCategory}\`\nPermission Required: \`${PermissionNames[permLevel]}\` (${permLevel})\nDescription: ${command.description}\n${append}`,
{split: true}
);
}
})
});

View File

@ -0,0 +1,316 @@
import {Command, NamedCommand, botHasPermission, getPermissionLevel, getPermissionName, CHANNEL_TYPE} from "../../core";
import {clean} from "../../lib";
import {Config, Storage} from "../../structures";
import {Permissions, TextChannel} from "discord.js";
import {logs} from "../../modules/globals";
function getLogBuffer(type: string) {
return {
files: [
{
attachment: Buffer.alloc(logs[type].length, logs[type]),
name: `${Date.now()}.${type}.log`
}
]
};
}
const activities = ["playing", "listening", "streaming", "watching"];
const statuses = ["online", "idle", "dnd", "invisible"];
export default new NamedCommand({
description:
"An all-in-one command to do admin stuff. You need to be either an admin of the server or one of the bot's mechanics to use this command.",
async run({message, channel, guild, author, member, client, args}) {
const permLevel = getPermissionLevel(author, member);
return channel.send(`${author}, your permission level is \`${getPermissionName(permLevel)}\` (${permLevel}).`);
},
subcommands: {
set: new NamedCommand({
description: "Set different per-guild settings for the bot.",
run: "You have to specify the option you want to set.",
permission: PERMISSIONS.ADMIN,
channelType: CHANNEL_TYPE.GUILD,
subcommands: {
prefix: new NamedCommand({
description: "Set a custom prefix for your guild. Removes your custom prefix if none is provided.",
usage: "(<prefix>)",
async run({message, channel, guild, author, member, client, args}) {
Storage.getGuild(guild!.id).prefix = null;
Storage.save();
channel.send(
`The custom prefix for this guild has been removed. My prefix is now back to \`${Config.prefix}\`.`
);
},
any: new Command({
async run({message, channel, guild, author, member, client, args}) {
Storage.getGuild(guild!.id).prefix = args[0];
Storage.save();
channel.send(`The custom prefix for this guild is now \`${args[0]}\`.`);
}
})
}),
welcome: new NamedCommand({
description: "Configure your server's welcome settings for the bot.",
usage: "type/channel <...>",
run: "You need to specify which part to modify, `type`/`channel`.",
subcommands: {
type: new NamedCommand({
description:
"Sets how welcome messages are displayed for your server. Removes welcome messages if unspecified.",
usage: "`none`/`text`/`graphical`",
async run({message, channel, guild, author, member, client, args}) {
Storage.getGuild(guild!.id).welcomeType = "none";
Storage.save();
channel.send("Set this server's welcome type to `none`.");
},
// I should probably make this a bit more dynamic... Oh well.
subcommands: {
text: new NamedCommand({
async run({message, channel, guild, author, member, client, args}) {
Storage.getGuild(guild!.id).welcomeType = "text";
Storage.save();
channel.send("Set this server's welcome type to `text`.");
}
}),
graphical: new NamedCommand({
async run({message, channel, guild, author, member, client, args}) {
Storage.getGuild(guild!.id).welcomeType = "graphical";
Storage.save();
channel.send("Set this server's welcome type to `graphical`.");
}
})
}
}),
channel: new NamedCommand({
description: "Sets the welcome channel for your server. Type `#` to reference the channel.",
usage: "(<channel mention>)",
async run({message, channel, guild, author, member, client, args}) {
Storage.getGuild(guild!.id).welcomeChannel = channel.id;
Storage.save();
channel.send(`Successfully set ${channel} as the welcome channel for this server.`);
},
id: "channel",
channel: new Command({
async run({message, channel, guild, author, member, client, args}) {
const result = args[0] as TextChannel;
Storage.getGuild(guild!.id).welcomeChannel = result.id;
Storage.save();
channel.send(`Successfully set this server's welcome channel to ${result}.`);
}
})
}),
message: new NamedCommand({
description:
"Sets a custom welcome message for your server. Use `%user%` as the placeholder for the user.",
usage: "(<message>)",
async run({message, channel, guild, author, member, client, args}) {
Storage.getGuild(guild!.id).welcomeMessage = null;
Storage.save();
channel.send("Reset your server's welcome message to the default.");
},
any: new Command({
async run({message, channel, guild, author, member, client, args}) {
const newMessage = args.join(" ");
Storage.getGuild(guild!.id).welcomeMessage = newMessage;
Storage.save();
channel.send(`Set your server's welcome message to \`${newMessage}\`.`);
}
})
})
}
}),
stream: new NamedCommand({
description: "Set a channel to send stream notifications. Type `#` to reference the channel.",
usage: "(<channel mention>)",
async run({message, channel, guild, author, member, client, args}) {
const targetGuild = Storage.getGuild(guild!.id);
if (targetGuild.streamingChannel) {
targetGuild.streamingChannel = null;
channel.send("Removed your server's stream notifications channel.");
} else {
targetGuild.streamingChannel = channel.id;
channel.send(`Set your server's stream notifications channel to ${channel}.`);
}
Storage.save();
},
id: "channel",
channel: new Command({
async run({message, channel, guild, author, member, client, args}) {
const result = args[0] as TextChannel;
Storage.getGuild(guild!.id).streamingChannel = result.id;
Storage.save();
channel.send(`Successfully set this server's stream notifications channel to ${result}.`);
}
})
})
}
}),
diag: new NamedCommand({
description: 'Requests a debug log with the "info" verbosity level.',
permission: PERMISSIONS.BOT_SUPPORT,
async run({message, channel, guild, author, member, client, args}) {
channel.send(getLogBuffer("info"));
},
any: new Command({
description: `Select a verbosity to listen to. Available levels: \`[${Object.keys(logs).join(", ")}]\``,
async run({message, channel, guild, author, member, client, args}) {
const type = args[0];
if (type in logs) channel.send(getLogBuffer(type));
else
channel.send(
`Couldn't find a verbosity level named \`${type}\`! The available types are \`[${Object.keys(
logs
).join(", ")}]\`.`
);
}
})
}),
status: new NamedCommand({
description: "Changes the bot's status.",
permission: PERMISSIONS.BOT_SUPPORT,
async run({message, channel, guild, author, member, client, args}) {
channel.send("Setting status to `online`...");
},
any: new Command({
description: `Select a status to set to. Available statuses: \`[${statuses.join(", ")}]\`.`,
async run({message, channel, guild, author, member, client, args}) {
if (!statuses.includes(args[0])) {
return channel.send("That status doesn't exist!");
} else {
client.user?.setStatus(args[0]);
return channel.send(`Setting status to \`${args[0]}\`...`);
}
}
})
}),
purge: new NamedCommand({
description: "Purges the bot's own messages.",
permission: PERMISSIONS.BOT_SUPPORT,
channelType: CHANNEL_TYPE.GUILD,
async run({message, channel, guild, author, member, client, args}) {
// 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)) {
message.delete();
const msgs = await channel.messages.fetch({
limit: 100
});
const travMessages = msgs.filter((m) => m.author.id === client.user?.id);
await channel.send(`Found ${travMessages.size} messages to delete.`).then((m) =>
m.delete({
timeout: 5000
})
);
await (channel as TextChannel).bulkDelete(travMessages);
} else {
channel.send(
"This command must be executed in a guild where I have the `MANAGE_MESSAGES` permission."
);
}
}
}),
clear: new NamedCommand({
description: "Clears a given amount of messages.",
usage: "<amount>",
channelType: CHANNEL_TYPE.GUILD,
run: "A number was not provided.",
number: new Command({
description: "Amount of messages to delete.",
async run({message, channel, guild, author, member, client, args}) {
message.delete();
const fetched = await channel.messages.fetch({
limit: args[0]
});
return await (channel as TextChannel).bulkDelete(fetched);
}
})
}),
eval: new NamedCommand({
description: "Evaluate code.",
usage: "<code>",
permission: PERMISSIONS.BOT_OWNER,
// You have to bring everything into scope to use them. AFAIK, there isn't a more maintainable way to do this, but at least TS will let you know if anything gets removed.
async run({message, channel, guild, author, member, client, args}) {
try {
const code = args.join(" ");
let evaled = eval(code);
if (typeof evaled !== "string") evaled = require("util").inspect(evaled);
channel.send(clean(evaled), {code: "js", split: true});
} catch (err) {
channel.send(clean(err), {code: "js", split: true});
}
}
}),
nick: new NamedCommand({
description: "Change the bot's nickname.",
permission: PERMISSIONS.BOT_SUPPORT,
channelType: CHANNEL_TYPE.GUILD,
async run({message, channel, guild, author, member, client, args}) {
const nickName = args.join(" ");
await guild!.me?.setNickname(nickName);
if (botHasPermission(guild, Permissions.FLAGS.MANAGE_MESSAGES)) message.delete({timeout: 5000});
channel.send(`Nickname set to \`${nickName}\``).then((m) => m.delete({timeout: 5000}));
}
}),
guilds: new NamedCommand({
description: "Shows a list of all guilds the bot is a member of.",
permission: PERMISSIONS.BOT_SUPPORT,
async run({message, channel, guild, author, member, client, args}) {
const guildList = client.guilds.cache.array().map((e) => e.name);
channel.send(guildList, {split: true});
}
}),
activity: new NamedCommand({
description: "Set the activity of the bot.",
permission: PERMISSIONS.BOT_SUPPORT,
usage: "<type> <string>",
async run({message, channel, guild, author, member, client, args}) {
client.user?.setActivity(".help", {
type: "LISTENING"
});
channel.send("Activity set to default.");
},
any: new Command({
description: `Select an activity type to set. Available levels: \`[${activities.join(", ")}]\``,
async run({message, channel, guild, author, member, client, args}) {
const type = args[0];
if (activities.includes(type)) {
client.user?.setActivity(args.slice(1).join(" "), {
type: args[0].toUpperCase()
});
channel.send(`Set activity to \`${args[0].toUpperCase()}\` \`${args.slice(1).join(" ")}\`.`);
} else
channel.send(
`Couldn't find an activity type named \`${type}\`! The available types are \`[${activities.join(
", "
)}]\`.`
);
}
})
}),
syslog: new NamedCommand({
description: "Sets up the current channel to receive system logs.",
permission: PERMISSIONS.BOT_ADMIN,
channelType: CHANNEL_TYPE.GUILD,
async run({message, channel, guild, author, member, client, args}) {
Config.systemLogsChannel = channel.id;
Config.save();
channel.send(`Successfully set ${channel} as the system logs channel.`);
},
channel: new Command({
async run({message, channel, guild, author, member, client, args}) {
const targetChannel = args[0] as TextChannel;
Config.systemLogsChannel = targetChannel.id;
Config.save();
channel.send(`Successfully set ${targetChannel} as the system logs channel.`);
}
})
})
}
});

110
src/commands/system/help.ts Normal file
View File

@ -0,0 +1,110 @@
import {Command, NamedCommand, loadableCommands, categories, getPermissionName, CHANNEL_TYPE} from "../../core";
import {toTitleCase, requireAllCasesHandledFor} from "../../lib";
export default new NamedCommand({
description: "Lists all commands. If a command is specified, their arguments are listed as well.",
usage: "([command, [subcommand/type], ...])",
aliases: ["h"],
async run({message, channel, guild, author, member, client, args}) {
const commands = await loadableCommands;
let output = `Legend: \`<type>\`, \`[list/of/stuff]\`, \`(optional)\`, \`(<optional type>)\`, \`([optional/list/...])\``;
for (const [category, headers] of categories) {
let tmp = `\n\n===[ ${toTitleCase(category)} ]===`;
// Ignore empty categories, including ["test"].
let hasActualCommands = false;
for (const header of headers) {
if (header !== "test") {
const command = commands.get(header)!;
tmp += `\n- \`${header}\`: ${command.description}`;
hasActualCommands = true;
}
}
if (hasActualCommands) output += tmp;
}
channel.send(output, {split: true});
},
any: new Command({
async run({message, channel, guild, author, member, client, args}) {
// Setup the root command
const commands = await loadableCommands;
let header = args.shift() as string;
let command = commands.get(header);
if (!command || header === "test") return channel.send(`No command found by the name \`${header}\`.`);
if (!(command instanceof NamedCommand))
return channel.send(`Command is not a proper instance of NamedCommand.`);
if (command.name) header = command.name;
// Search categories
let category = "Unknown";
for (const [referenceCategory, headers] of categories) {
if (headers.includes(header)) {
category = toTitleCase(referenceCategory);
break;
}
}
// Gather info
const result = await command.resolveInfo(args);
if (result.type === "error") return channel.send(result.message);
let append = "";
command = result.command;
if (result.args.length > 0) header += " " + result.args.join(" ");
if (command.usage === "") {
const list: string[] = [];
for (const [tag, subcommand] of result.keyedSubcommandInfo) {
const customUsage = subcommand.usage ? ` ${subcommand.usage}` : "";
list.push(`- \`${header} ${tag}${customUsage}\` - ${subcommand.description}`);
}
for (const [type, subcommand] of result.subcommandInfo) {
const customUsage = subcommand.usage ? ` ${subcommand.usage}` : "";
list.push(`- \`${header} ${type}${customUsage}\` - ${subcommand.description}`);
}
append = "Usages:" + (list.length > 0 ? `\n${list.join("\n")}` : " None.");
} else {
append = `Usage: \`${header} ${command.usage}\``;
}
let aliases = "N/A";
if (command instanceof NamedCommand) {
const formattedAliases: string[] = [];
for (const alias of command.aliases) formattedAliases.push(`\`${alias}\``);
// Short circuit an empty string, in this case, if there are no aliases.
aliases = formattedAliases.join(", ") || "None";
}
return channel.send(
`Command: \`${header}\`\nAliases: ${aliases}\nCategory: \`${category}\`\nPermission Required: \`${getPermissionName(
result.permission
)}\` (${result.permission})\nChannel Type: ${getChannelTypeName(result.channelType)}\nNSFW Only: ${
result.nsfw ? "Yes" : "No"
}\nDescription: ${command.description}\n${append}`,
{split: true}
);
}
})
});
function getChannelTypeName(type: CHANNEL_TYPE): string {
switch (type) {
case CHANNEL_TYPE.ANY:
return "Any";
case CHANNEL_TYPE.GUILD:
return "Guild Only";
case CHANNEL_TYPE.DM:
return "DM Only";
default:
requireAllCasesHandledFor(type);
}
}

7
src/commands/template.ts Normal file
View File

@ -0,0 +1,7 @@
import {Command, NamedCommand} from "../core";
export default new NamedCommand({
async run({message, channel, guild, author, member, client, args}) {
// code
}
});

View File

@ -1,26 +0,0 @@
import Command from "../../core/command";
import * as math from "mathjs";
import {MessageEmbed} from "discord.js";
export default new Command({
description: "Calculates a specified math expression.",
async run($) {
if (!$.args[0]) {
$.channel.send("Please provide a calculation.");
return;
}
let resp;
try {
resp = math.evaluate($.args.join(" "));
} catch (e) {
$.channel.send("Please provide a *valid* calculation.");
return;
}
const embed = new MessageEmbed()
.setColor(0xffffff)
.setTitle("Math Calculation")
.addField("Input", `\`\`\`js\n${$.args.join("")}\`\`\``)
.addField("Output", `\`\`\`js\n${resp}\`\`\``);
$.channel.send(embed);
}
});

View File

@ -1,22 +0,0 @@
import Command from "../../core/command";
import {CommonLibrary} from "../../core/lib";
export default new Command({
description: "Renames current voice channel.",
usage: "<name>",
async run($: CommonLibrary): Promise<any> {
const voiceChannel = $.message.member?.voice.channel;
if (!voiceChannel) return $.channel.send("You are not in a voice channel.");
if (!voiceChannel.guild.me?.hasPermission("MANAGE_CHANNELS"))
return $.channel.send("I am lacking the required permissions to perform this action.");
if ($.args.length === 0) return $.channel.send("Please provide a new voice channel name.");
const prevName = voiceChannel.name;
const newName = $.args.join(" ");
await voiceChannel.setName(newName);
await $.channel.send(`Changed channel name from "${prevName}" to "${newName}".`);
}
});

View File

@ -1,12 +0,0 @@
import Command from "../../core/command";
export default new Command({
description: "Gives you the invite link.",
async run($) {
$.channel.send(
`https://discordapp.com/api/oauth2/authorize?client_id=${$.client.user!.id}&permissions=${
$.args[0] || 8
}&scope=bot`
);
}
});

View File

@ -1,14 +0,0 @@
import Command from "../../core/command";
import {CommonLibrary} from "../../core/lib";
export default new Command({
description: "Repeats your message.",
usage: "<message>",
run: "Please provide a message for me to say!",
any: new Command({
description: "Message to repeat.",
async run($: CommonLibrary): Promise<any> {
$.channel.send(`*${$.author} says:*\n${$.args.join(" ")}`);
}
})
});

View File

@ -1,18 +0,0 @@
import Command from "../../core/command";
import {streamList} from "../../events/voiceStateUpdate";
export default new Command({
description: "Sets the description of your stream. You can embed links by writing `[some name](some link)`",
async run($) {
const userID = $.author.id;
if (streamList.has(userID)) {
const stream = streamList.get(userID)!;
stream.description = $.args.join(" ") || "No description set.";
stream.update();
} else {
// Alternatively, I could make descriptions last outside of just one stream.
$.channel.send("You can only use this command when streaming.");
}
}
});

View File

@ -1,62 +0,0 @@
import Command from "../../core/command";
import moment from "moment";
import {Storage} from "../../core/structures";
import {MessageEmbed} from "discord.js";
export default new Command({
description: "Keep and edit your personal todo list.",
async run($) {
const user = Storage.getUser($.author.id);
const embed = new MessageEmbed().setTitle(`Todo list for ${$.author.tag}`).setColor("BLUE");
for (const timestamp in user.todoList) {
const date = new Date(Number(timestamp));
embed.addField(
`${moment(date).format("LT")} ${moment(date).format("LL")} (${moment(date).fromNow()})`,
user.todoList[timestamp]
);
}
$.channel.send(embed);
},
subcommands: {
add: new Command({
async run($) {
const user = Storage.getUser($.author.id);
const note = $.args.join(" ");
user.todoList[Date.now().toString()] = note;
console.debug(user.todoList);
Storage.save();
$.channel.send(`Successfully added \`${note}\` to your todo list.`);
}
}),
remove: new Command({
async run($) {
const user = Storage.getUser($.author.id);
const note = $.args.join(" ");
let isFound = false;
for (const timestamp in user.todoList) {
const selectedNote = user.todoList[timestamp];
if (selectedNote === note) {
delete user.todoList[timestamp];
Storage.save();
isFound = true;
$.channel.send(`Removed \`${note}\` from your todo list.`);
}
}
if (!isFound) $.channel.send("That item couldn't be found.");
}
}),
clear: new Command({
async run($) {
const user = Storage.getUser($.author.id);
user.todoList = {};
Storage.save();
$.channel.send("Cleared todo list.");
}
})
}
});

View File

@ -0,0 +1,22 @@
import {Command, NamedCommand} from "../../core";
import * as math from "mathjs";
import {MessageEmbed} from "discord.js";
export default new NamedCommand({
description: "Calculates a specified math expression.",
async run({message, channel, guild, author, member, client, args}) {
if (!args[0]) return channel.send("Please provide a calculation.");
let resp;
try {
resp = math.evaluate(args.join(" "));
} catch (e) {
return channel.send("Please provide a *valid* calculation.");
}
const embed = new MessageEmbed()
.setColor(0xffffff)
.setTitle("Math Calculation")
.addField("Input", `\`\`\`js\n${args.join("")}\`\`\``)
.addField("Output", `\`\`\`js\n${resp}\`\`\``);
return channel.send(embed);
}
});

View File

@ -1,6 +1,6 @@
import Command from "../../core/command";
import {Command, NamedCommand} from "../../core";
export default new Command({
export default new NamedCommand({
description: "Gives you the Github link.",
run: "https://github.com/keanuplayz/TravBot-v3"
});

View File

@ -0,0 +1,19 @@
import {Command, NamedCommand} from "../../core";
export default new NamedCommand({
description: "Renames current voice channel.",
usage: "<name>",
async run({message, channel, guild, author, member, client, args}) {
const voiceChannel = message.member?.voice.channel;
if (!voiceChannel) return channel.send("You are not in a voice channel.");
if (!voiceChannel.guild.me?.hasPermission("MANAGE_CHANNELS"))
return channel.send("I am lacking the required permissions to perform this action.");
if (args.length === 0) return channel.send("Please provide a new voice channel name.");
const prevName = voiceChannel.name;
const newName = args.join(" ");
await voiceChannel.setName(newName);
return await channel.send(`Changed channel name from "${prevName}" to "${newName}".`);
}
});

View File

@ -1,9 +1,7 @@
import Command from "../../core/command";
import {processEmoteQueryFormatted} from "./subcommands/emote-utils";
import {botHasPermission} from "../../core/lib";
import {Permissions} from "discord.js";
import {Command, NamedCommand} from "../../core";
import {processEmoteQueryFormatted} from "./modules/emote-utils";
export default new Command({
export default new NamedCommand({
description: "Send the specified emote.",
run: "Please provide a command name.",
any: new Command({

View File

@ -1,53 +1,71 @@
import {MessageEmbed, version as djsversion} from "discord.js";
import {MessageEmbed, version as djsversion, Guild, User, GuildMember} from "discord.js";
import ms from "ms";
import os from "os";
import Command from "../core/command";
import {CommonLibrary, formatBytes, trimArray} from "../core/lib";
import {verificationLevels, filterLevels, regions, flags} from "../defs/info";
import moment from "moment";
import utc from "moment";
import {Guild} from "discord.js";
import {Command, NamedCommand, getMemberByUsername, CHANNEL_TYPE} from "../../core";
import {formatBytes, trimArray} from "../../lib";
import {verificationLevels, filterLevels, regions} from "../../defs/info";
import moment, {utc} from "moment";
const {version} = require("../../package.json");
export default new Command({
export default new NamedCommand({
description: "Command to provide all sorts of info about the current server, a user, etc.",
run: "Please provide an argument.\nFor help, run `%prefix%help info`.",
async run({message, channel, guild, author, member, client, args}) {
channel.send(await getUserInfo(author, member));
},
subcommands: {
avatar: new Command({
avatar: new NamedCommand({
description: "Shows your own, or another user's avatar.",
usage: "(<user>)",
async run($: CommonLibrary): Promise<any> {
$.channel.send($.author.displayAvatarURL({dynamic: true, size: 2048}));
async run({message, channel, guild, author, member, client, args}) {
channel.send(author.displayAvatarURL({dynamic: true, size: 2048}));
},
id: "user",
user: new Command({
description: "Shows your own, or another user's avatar.",
async run($: CommonLibrary): Promise<any> {
$.channel.send(
$.args[0].displayAvatarURL({
async run({message, channel, guild, author, member, client, args}) {
channel.send(
args[0].displayAvatarURL({
dynamic: true,
size: 2048
})
);
}
}),
any: new Command({
description: "Shows another user's avatar by searching their name",
channelType: CHANNEL_TYPE.GUILD,
async run({message, channel, guild, author, client, args}) {
const name = args.join(" ");
const member = await getMemberByUsername(guild!, name);
if (member) {
channel.send(
member.user.displayAvatarURL({
dynamic: true,
size: 2048
})
);
} else {
channel.send(`No user found by the name \`${name}\`!`);
}
}
})
}),
bot: new Command({
bot: new NamedCommand({
description: "Displays info about the bot.",
async run($: CommonLibrary): Promise<any> {
async run({message, channel, guild, author, member, client, args}) {
const core = os.cpus()[0];
const embed = new MessageEmbed()
.setColor($.guild?.me?.displayHexColor || "BLUE")
.setColor(guild?.me?.displayHexColor || "BLUE")
.addField("General", [
`** Client:** ${$.client.user?.tag} (${$.client.user?.id})`,
`** Servers:** ${$.client.guilds.cache.size.toLocaleString()}`,
`** Users:** ${$.client.guilds.cache
`** Client:** ${client.user?.tag} (${client.user?.id})`,
`** Servers:** ${client.guilds.cache.size.toLocaleString()}`,
`** Users:** ${client.guilds.cache
.reduce((a: any, b: {memberCount: any}) => a + b.memberCount, 0)
.toLocaleString()}`,
`** Channels:** ${$.client.channels.cache.size.toLocaleString()}`,
`** Creation Date:** ${utc($.client.user?.createdTimestamp).format("Do MMMM YYYY HH:mm:ss")}`,
`** Channels:** ${client.channels.cache.size.toLocaleString()}`,
`** Creation Date:** ${utc(client.user?.createdTimestamp).format("Do MMMM YYYY HH:mm:ss")}`,
`** Node.JS:** ${process.version}`,
`** Version:** v${version}`,
`** Version:** v${process.env.npm_package_version}`,
`** Discord.JS:** ${djsversion}`,
"\u200b"
])
@ -65,100 +83,106 @@ export default new Command({
`\u3000 • Used: ${formatBytes(process.memoryUsage().heapUsed)}`
])
.setTimestamp();
const avatarURL = $.client.user?.displayAvatarURL({
const avatarURL = client.user?.displayAvatarURL({
dynamic: true,
size: 2048
});
if (avatarURL) embed.setThumbnail(avatarURL);
$.channel.send(embed);
channel.send(embed);
}
}),
guild: new Command({
guild: new NamedCommand({
description: "Displays info about the current guild or another guild.",
usage: "(<guild name>/<guild ID>)",
async run($: CommonLibrary): Promise<any> {
if ($.guild) {
$.channel.send(await getGuildInfo($.guild, $.guild));
} else {
$.channel.send("Please execute this command in a guild.");
}
channelType: CHANNEL_TYPE.GUILD,
async run({message, channel, guild, author, member, client, args}) {
channel.send(await getGuildInfo(guild!, guild));
},
any: new Command({
description: "Display info about a guild by finding its name or ID.",
async run($: CommonLibrary): Promise<any> {
async run({message, channel, guild, author, member, client, args}) {
// If a guild ID is provided (avoid the "number" subcommand because of inaccuracies), search for that guild
if ($.args.length === 1 && /^\d{17,19}$/.test($.args[0])) {
const id = $.args[0];
const guild = $.client.guilds.cache.get(id);
if (args.length === 1 && /^\d{17,19}$/.test(args[0])) {
const id = args[0];
const targetGuild = client.guilds.cache.get(id);
if (guild) {
$.channel.send(await getGuildInfo(guild, $.guild));
if (targetGuild) {
channel.send(await getGuildInfo(targetGuild, guild));
} else {
$.channel.send(`None of the servers I'm in matches the guild ID \`${id}\`!`);
channel.send(`None of the servers I'm in matches the guild ID \`${id}\`!`);
}
} else {
const query: string = $.args.join(" ").toLowerCase();
const guild = $.client.guilds.cache.find((guild) => guild.name.toLowerCase().includes(query));
const query: string = args.join(" ").toLowerCase();
const targetGuild = client.guilds.cache.find((guild) =>
guild.name.toLowerCase().includes(query)
);
if (guild) {
$.channel.send(await getGuildInfo(guild, $.guild));
if (targetGuild) {
channel.send(await getGuildInfo(targetGuild, guild));
} else {
$.channel.send(`None of the servers I'm in matches the query \`${query}\`!`);
channel.send(`None of the servers I'm in matches the query \`${query}\`!`);
}
}
}
})
})
},
id: "user",
user: new Command({
description: "Displays info about mentioned user.",
async run($: CommonLibrary): Promise<any> {
async run({message, channel, guild, author, client, args}) {
const user = args[0] as User;
// Transforms the User object into a GuildMember object of the current guild.
const member = await $.guild?.members.fetch($.args[0]);
if (!member)
return $.channel.send(
"No member object was found by that user! Are you sure you used this command in a server?"
);
const roles = member.roles.cache
.sort((a: {position: number}, b: {position: number}) => b.position - a.position)
.map((role: {toString: () => any}) => role.toString())
.slice(0, -1);
const userFlags = (await member.user.fetchFlags()).toArray();
const embed = new MessageEmbed()
.setThumbnail(member.user.displayAvatarURL({dynamic: true, size: 512}))
.setColor(member.displayHexColor || "BLUE")
.addField("User", [
`** Username:** ${member.user.username}`,
`** Discriminator:** ${member.user.discriminator}`,
`** ID:** ${member.id}`,
`** Flags:** ${userFlags.length ? userFlags.join(", ") : "None"}`,
`** Avatar:** [Link to avatar](${member.user.displayAvatarURL({
dynamic: true
})})`,
`** Time Created:** ${moment(member.user.createdTimestamp).format("LT")} ${moment(
member.user.createdTimestamp
).format("LL")} ${moment(member.user.createdTimestamp).fromNow()}`,
`** Status:** ${member.user.presence.status}`,
`** Game:** ${member.user.presence.activities || "Not playing a game."}`
])
.addField("Member", [
`** Highest Role:** ${
member.roles.highest.id === $.guild?.id ? "None" : member.roles.highest.name
}`,
`** Server Join Date:** ${moment(member.joinedAt).format("LL LTS")}`,
`** Hoist Role:** ${member.roles.hoist ? member.roles.hoist.name : "None"}`,
`** Roles:** [${roles.length}]: ${
roles.length == 0 ? "None" : roles.length <= 10 ? roles.join(", ") : trimArray(roles).join(", ")
}`
]);
$.channel.send(embed);
const member = guild?.members.resolve(args[0]);
channel.send(await getUserInfo(user, member));
}
})
});
async function getUserInfo(user: User, member: GuildMember | null | undefined): Promise<MessageEmbed> {
const userFlags = (await user.fetchFlags()).toArray();
const embed = new MessageEmbed()
.setThumbnail(user.displayAvatarURL({dynamic: true, size: 512}))
.setColor("BLUE")
.addField("User", [
`** Username:** ${user.username}`,
`** Discriminator:** ${user.discriminator}`,
`** ID:** ${user.id}`,
`** Flags:** ${userFlags.length ? userFlags.join(", ") : "None"}`,
`** Avatar:** [Link to avatar](${user.displayAvatarURL({
dynamic: true
})})`,
`** Time Created:** ${moment(user.createdTimestamp).format("LT")} ${moment(user.createdTimestamp).format(
"LL"
)} ${moment(user.createdTimestamp).fromNow()}`,
`** Status:** ${user.presence.status}`,
`** Game:** ${user.presence.activities || "Not playing a game."}`
]);
if (member) {
const roles = member.roles.cache
.sort((a: {position: number}, b: {position: number}) => b.position - a.position)
.map((role: {toString: () => any}) => role.toString())
.slice(0, -1);
embed
.setColor(member.displayHexColor)
.addField("Member", [
`** Highest Role:** ${
member.roles.highest.id === member.guild.id ? "None" : member.roles.highest.name
}`,
`** Server Join Date:** ${moment(member.joinedAt).format("LL LTS")}`,
`** Hoist Role:** ${member.roles.hoist ? member.roles.hoist.name : "None"}`,
`** Roles:** [${roles.length}]: ${
roles.length == 0 ? "None" : roles.length <= 10 ? roles.join(", ") : trimArray(roles).join(", ")
}`
]);
}
return embed;
}
async function getGuildInfo(guild: Guild, currentGuild: Guild | null) {
const members = await guild.members.fetch({
withPresences: true,

View File

@ -0,0 +1,12 @@
import {Command, NamedCommand} from "../../core";
export default new NamedCommand({
description: "Gives you the invite link.",
async run({message, channel, guild, author, member, client, args}) {
channel.send(
`https://discordapp.com/api/oauth2/authorize?client_id=${client.user!.id}&permissions=${
args[0] || 8
}&scope=bot`
);
}
});

View File

@ -1,35 +1,38 @@
import {GuildEmoji} from "discord.js";
import {MessageEmbed} from "discord.js";
import Command from "../../core/command";
import {CommonLibrary} from "../../core/lib";
import {GuildEmoji, MessageEmbed, TextChannel, DMChannel, NewsChannel, User} from "discord.js";
import {Command, NamedCommand, paginate} from "../../core";
import {split} from "../../lib";
import vm from "vm";
const REGEX_TIMEOUT_MS = 1000;
export default new Command({
export default new NamedCommand({
description: "Lists all emotes the bot has in it's registry,",
usage: "<regex pattern> (-flags)",
async run($: CommonLibrary): Promise<any> {
displayEmoteList($, $.client.emojis.cache.array());
async run({message, channel, guild, author, member, client, args}) {
displayEmoteList(client.emojis.cache.array(), channel, author);
},
any: new Command({
description:
"Filters emotes by via a regular expression. Flags can be added by adding a dash at the end. For example, to do a case-insensitive search, do %prefix%lsemotes somepattern -i",
async run($: CommonLibrary): Promise<any> {
async run({message, channel, guild, author, member, client, args}) {
// If a guild ID is provided, filter all emotes by that guild (but only if there aren't any arguments afterward)
if ($.args.length === 1 && /^\d{17,19}$/.test($.args[0])) {
const guildID: string = $.args[0];
if (args.length === 1 && /^\d{17,19}$/.test(args[0])) {
const guildID: string = args[0];
displayEmoteList($, $.client.emojis.cache.filter((emote) => emote.guild.id === guildID).array());
displayEmoteList(
client.emojis.cache.filter((emote) => emote.guild.id === guildID).array(),
channel,
author
);
} else {
// Otherwise, search via a regex pattern
let flags: string | undefined = undefined;
if (/^-[dgimsuy]{1,7}$/.test($.args[$.args.length - 1])) {
flags = $.args.pop().substring(1);
if (/^-[dgimsuy]{1,7}$/.test(args[args.length - 1])) {
flags = args.pop().substring(1);
}
let emoteCollection = $.client.emojis.cache.array();
let emoteCollection = client.emojis.cache.array();
// Creates a sandbox to stop a regular expression if it takes too much time to search.
// To avoid passing in a giant data structure, I'll just pass in the structure {[id: string]: [name: string]}.
//let emotes: {[id: string]: string} = {};
@ -41,7 +44,7 @@ export default new Command({
// The result will be sandbox.emotes because it'll be modified in-place.
const sandbox = {
regex: new RegExp($.args.join(" "), flags),
regex: new RegExp(args.join(" "), flags),
emotes
};
const context = vm.createContext(sandbox);
@ -55,10 +58,10 @@ export default new Command({
script.runInContext(context, {timeout: REGEX_TIMEOUT_MS});
emotes = sandbox.emotes;
emoteCollection = emoteCollection.filter((emote) => emotes.has(emote.id)); // Only allow emotes that haven't been deleted.
displayEmoteList($, emoteCollection);
displayEmoteList(emoteCollection, channel, author);
} catch (error) {
if (error.code === "ERR_SCRIPT_EXECUTION_TIMEOUT") {
$.channel.send(
channel.send(
`The regular expression you entered exceeded the time limit of ${REGEX_TIMEOUT_MS} milliseconds.`
);
} else {
@ -66,14 +69,14 @@ export default new Command({
}
}
} else {
$.channel.send("Failed to initialize sandbox.");
channel.send("Failed to initialize sandbox.");
}
}
}
})
});
async function displayEmoteList($: CommonLibrary, emotes: GuildEmoji[]) {
async function displayEmoteList(emotes: GuildEmoji[], channel: TextChannel | DMChannel | NewsChannel, author: User) {
emotes.sort((a, b) => {
const first = a.name.toLowerCase();
const second = b.name.toLowerCase();
@ -82,36 +85,24 @@ async function displayEmoteList($: CommonLibrary, emotes: GuildEmoji[]) {
else if (first < second) return -1;
else return 0;
});
const sections = $(emotes).split(20);
const sections = split(emotes, 20);
const pages = sections.length;
const embed = new MessageEmbed().setTitle("**Emotes**").setColor("AQUA");
let desc = "";
const embed = new MessageEmbed().setColor("AQUA");
// Gather the first page (if it even exists, which it might not if there no valid emotes appear)
if (pages > 0) {
for (const emote of sections[0]) {
desc += `${emote} ${emote.name} (**${emote.guild.name}**)\n`;
}
paginate(channel, author.id, pages, (page, hasMultiplePages) => {
embed.setTitle(hasMultiplePages ? `**Emotes** (Page ${page + 1} of ${pages})` : "**Emotes**");
embed.setDescription(desc);
let desc = "";
for (const emote of sections[page]) {
desc += `${emote} ${emote.name} (**${emote.guild.name}**)\n`;
}
embed.setDescription(desc);
if (pages > 1) {
embed.setTitle(`**Emotes** (Page 1 of ${pages})`);
const msg = await $.channel.send({embed});
$.paginate(msg, $.author.id, pages, (page) => {
let desc = "";
for (const emote of sections[page]) {
desc += `${emote} ${emote.name} (**${emote.guild.name}**)\n`;
}
embed.setTitle(`**Emotes** (Page ${page + 1} of ${pages})`);
embed.setDescription(desc);
msg.edit(embed);
});
} else {
await $.channel.send({embed});
}
return embed;
});
} else {
$.channel.send("No valid emotes found by that query.");
channel.send("No valid emotes found by that query.");
}
}

View File

@ -37,31 +37,28 @@ function levenshtein(s: string, t: string): number {
else if (s[i] === t[j].toLowerCase()) r = lowercaseWeight;
else r = substitutionWeight;
v1[j + 1] = Math.min(
v0[j + 1] + deletionWeight,
v1[j] + insertionWeight,
v0[j] + r);
v1[j + 1] = Math.min(v0[j + 1] + deletionWeight, v1[j] + insertionWeight, v0[j] + r);
}
const tmp = v1;
v1 = v0, v0 = tmp;
(v1 = v0), (v0 = tmp);
}
return v0[n];
}
function searchSimilarEmotes(query: string): GuildEmoji[] {
const emoteCandidates: {emote: GuildEmoji, dist: number}[] = [];
const emoteCandidates: {emote: GuildEmoji; dist: number}[] = [];
for (const emote of client.emojis.cache.values()) {
const dist = levenshtein(emote.name, query);
if (dist <= maxAcceptedDistance) {
emoteCandidates.push({ emote, dist });
emoteCandidates.push({emote, dist});
}
}
emoteCandidates.sort((b, a) => b.dist - a.dist);
return emoteCandidates.map(em => em.emote);
return emoteCandidates.map((em) => em.emote);
}
const unicodeEmojiRegex = /^(?:[\u2700-\u27bf]|(?:\ud83c[\udde6-\uddff]){2}|[\ud800-\udbff][\udc00-\udfff]|[\u0023-\u0039]\ufe0f?\u20e3|\u3299|\u3297|\u303d|\u3030|\u24c2|\ud83c[\udd70-\udd71]|\ud83c[\udd7e-\udd7f]|\ud83c\udd8e|\ud83c[\udd91-\udd9a]|\ud83c[\udde6-\uddff]|\ud83c[\ude01-\ude02]|\ud83c\ude1a|\ud83c\ude2f|\ud83c[\ude32-\ude3a]|\ud83c[\ude50-\ude51]|\u203c|\u2049|[\u25aa-\u25ab]|\u25b6|\u25c0|[\u25fb-\u25fe]|\u00a9|\u00ae|\u2122|\u2139|\ud83c\udc04|[\u2600-\u26FF]|\u2b05|\u2b06|\u2b07|\u2b1b|\u2b1c|\u2b50|\u2b55|\u231a|\u231b|\u2328|\u23cf|[\u23e9-\u23f3]|[\u23f8-\u23fa]|\ud83c\udccf|\u2934|\u2935|[\u2190-\u21ff])[\ufe00-\ufe0f]?$/;
@ -69,12 +66,11 @@ const discordEmoteMentionRegex = /^<a?:\w+:\d+>$/;
const emoteNameWithSelectorRegex = /^(.+)~(\d+)$/;
function processEmoteQuery(query: string[], isFormatted: boolean): string[] {
return query.map(emote => {
return query.map((emote) => {
emote = emote.trim();
// If the query directly matches a Unicode emoji or a Discord custom emote mention, pass it as-is.
if (discordEmoteMentionRegex.test(emote)
|| unicodeEmojiRegex.test(emote)) return emote;
if (discordEmoteMentionRegex.test(emote) || unicodeEmojiRegex.test(emote)) return emote;
// If formatted mode is enabled, parse whitespace and newline elements.
if (isFormatted) {
@ -94,7 +90,7 @@ function processEmoteQuery(query: string[], isFormatted: boolean): string[] {
// Try to match an emote name directly if the selector is for the closest match.
if (selector == 0) {
const directMatchEmote = client.emojis.cache.find(em => em.name === emote);
const directMatchEmote = client.emojis.cache.find((em) => em.name === emote);
if (directMatchEmote) return directMatchEmote.toString();
}

View File

@ -1,23 +1,22 @@
import Command from "../../core/command";
import {CommonLibrary} from "../../core/lib";
import {Command, NamedCommand} from "../../core";
import {Message, Channel, TextChannel} from "discord.js";
import {processEmoteQueryArray} from "./subcommands/emote-utils";
import {processEmoteQueryArray} from "./modules/emote-utils";
export default new Command({
export default new NamedCommand({
description:
"Reacts to the a previous message in your place. You have to react with the same emote before the bot removes that reaction.",
usage: 'react <emotes...> (<distance / message ID / "Copy ID" / "Copy Message Link">)',
async run($: CommonLibrary): Promise<any> {
async run({message, channel, guild, author, member, client, args}) {
let target: Message | undefined;
let distance = 1;
if ($.message.reference) {
if (message.reference) {
// If the command message is a reply to another message, use that as the react target.
target = await $.channel.messages.fetch($.message.reference.messageID!);
target = await channel.messages.fetch(message.reference.messageID!);
}
// handles reacts by message id/distance
else if ($.args.length >= 2) {
const last = $.args[$.args.length - 1]; // Because this is optional, do not .pop() unless you're sure it's a message link indicator.
else if (args.length >= 2) {
const last = args[args.length - 1]; // Because this is optional, do not .pop() unless you're sure it's a message link indicator.
const URLPattern = /^(?:https:\/\/discord.com\/channels\/(\d{17,19})\/(\d{17,19})\/(\d{17,19}))$/;
const copyIDPattern = /^(?:(\d{17,19})-(\d{17,19}))$/;
@ -27,66 +26,65 @@ export default new Command({
const guildID = match[1];
const channelID = match[2];
const messageID = match[3];
let guild = $.guild;
let channel: Channel | undefined = $.channel;
let tmpChannel: Channel | undefined = channel;
if (guild?.id !== guildID) {
try {
guild = await $.client.guilds.fetch(guildID);
guild = await client.guilds.fetch(guildID);
} catch {
return $.channel.send(`\`${guildID}\` is an invalid guild ID!`);
return channel.send(`\`${guildID}\` is an invalid guild ID!`);
}
}
if (channel.id !== channelID) channel = guild.channels.cache.get(channelID);
if (!channel) return $.channel.send(`\`${channelID}\` is an invalid channel ID!`);
if (tmpChannel.id !== channelID) tmpChannel = guild.channels.cache.get(channelID);
if (!tmpChannel) return channel.send(`\`${channelID}\` is an invalid channel ID!`);
if ($.message.id !== messageID) {
if (message.id !== messageID) {
try {
target = await (channel as TextChannel).messages.fetch(messageID);
target = await (tmpChannel as TextChannel).messages.fetch(messageID);
} catch {
return $.channel.send(`\`${messageID}\` is an invalid message ID!`);
return channel.send(`\`${messageID}\` is an invalid message ID!`);
}
}
$.args.pop();
args.pop();
}
// <Channel ID>-<Message ID> ("Copy ID" Button)
else if (copyIDPattern.test(last)) {
const match = copyIDPattern.exec(last)!;
const channelID = match[1];
const messageID = match[2];
let channel: Channel | undefined = $.channel;
let tmpChannel: Channel | undefined = channel;
if (channel.id !== channelID) channel = $.guild?.channels.cache.get(channelID);
if (!channel) return $.channel.send(`\`${channelID}\` is an invalid channel ID!`);
if (tmpChannel.id !== channelID) tmpChannel = guild?.channels.cache.get(channelID);
if (!tmpChannel) return channel.send(`\`${channelID}\` is an invalid channel ID!`);
if ($.message.id !== messageID) {
if (message.id !== messageID) {
try {
target = await (channel as TextChannel).messages.fetch(messageID);
target = await (tmpChannel as TextChannel).messages.fetch(messageID);
} catch {
return $.channel.send(`\`${messageID}\` is an invalid message ID!`);
return channel.send(`\`${messageID}\` is an invalid message ID!`);
}
}
$.args.pop();
args.pop();
}
// <Message ID>
else if (/^\d{17,19}$/.test(last)) {
try {
target = await $.channel.messages.fetch(last);
target = await channel.messages.fetch(last);
} catch {
return $.channel.send(`No valid message found by the ID \`${last}\`!`);
return channel.send(`No valid message found by the ID \`${last}\`!`);
}
$.args.pop();
args.pop();
}
// The entire string has to be a number for this to match. Prevents leaCheeseAmerican1 from triggering this.
else if (/^\d+$/.test(last)) {
distance = parseInt(last);
if (distance >= 0 && distance <= 99) $.args.pop();
else return $.channel.send("Your distance must be between 0 and 99!");
if (distance >= 0 && distance <= 99) args.pop();
else return channel.send("Your distance must be between 0 and 99!");
}
}
@ -94,21 +92,23 @@ export default new Command({
// Messages are ordered from latest to earliest.
// You also have to add 1 as well because fetchMessages includes your own message.
target = (
await $.message.channel.messages.fetch({
await message.channel.messages.fetch({
limit: distance + 1
})
).last();
}
for (const emote of processEmoteQueryArray($.args)) {
for (const emote of processEmoteQueryArray(args)) {
// Even though the bot will always grab *some* emote, the user can choose not to keep that emote there if it isn't what they want
const reaction = await target!.react(emote);
// This part is called with a promise because you don't want to wait 5 seconds between each reaction.
setTimeout(() => {
// This reason for this null assertion is that by the time you use this command, the client is going to be loaded.
reaction.users.remove($.client.user!);
reaction.users.remove(client.user!);
}, 5000);
}
return;
}
});

View File

@ -0,0 +1,13 @@
import {Command, NamedCommand} from "../../core";
export default new NamedCommand({
description: "Repeats your message.",
usage: "<message>",
run: "Please provide a message for me to say!",
any: new Command({
description: "Message to repeat.",
async run({message, channel, guild, author, member, client, args}) {
channel.send(`*${author} says:*\n${args.join(" ")}`);
}
})
});

View File

@ -1,29 +1,28 @@
import Command from "../core/command";
import {CommonLibrary} from "../core/lib";
import {Command, NamedCommand, CHANNEL_TYPE} from "../../core";
import {pluralise} from "../../lib";
import moment from "moment";
import {Collection, TextChannel} from "discord.js";
const lastUsedTimestamps: {[id: string]: number} = {};
export default new Command({
export default new NamedCommand({
description:
"Scans all text channels in the current guild and returns the number of times each emoji specific to the guild has been used. Has a cooldown of 24 hours per guild.",
async run($: CommonLibrary): Promise<any> {
if (!$.guild) return $.channel.send(`You must use this command on a server!`);
channelType: CHANNEL_TYPE.GUILD,
async run({message, channel, guild, author, member, client, args}) {
// Test if the command is on cooldown. This isn't the strictest cooldown possible, because in the event that the bot crashes, the cooldown will be reset. But for all intends and purposes, it's a good enough cooldown. It's a per-server cooldown.
const startTime = Date.now();
const cooldown = 86400000; // 24 hours
const lastUsedTimestamp = lastUsedTimestamps[$.guild.id] ?? 0;
const lastUsedTimestamp = lastUsedTimestamps[guild!.id] ?? 0;
const difference = startTime - lastUsedTimestamp;
const howLong = moment(startTime).to(lastUsedTimestamp + cooldown);
// If it's been less than an hour since the command was last used, prevent it from executing.
if (difference < cooldown)
return $.channel.send(
return channel.send(
`This command requires a day to cooldown. You'll be able to activate this command ${howLong}.`
);
else lastUsedTimestamps[$.guild.id] = startTime;
else lastUsedTimestamps[guild!.id] = startTime;
const stats: {
[id: string]: {
@ -35,20 +34,20 @@ export default new Command({
} = {};
let totalUserEmoteUsage = 0;
// IMPORTANT: You MUST check if the bot actually has access to the channel in the first place. It will get the list of all channels, but that doesn't mean it has access to every channel. Without this, it'll require admin access and throw an annoying unhelpful DiscordAPIError: Missing Access otherwise.
const allTextChannelsInCurrentGuild = $.guild.channels.cache.filter(
const allTextChannelsInCurrentGuild = guild!.channels.cache.filter(
(channel) => channel.type === "text" && channel.viewable
) as Collection<string, TextChannel>;
let messagesSearched = 0;
let channelsSearched = 0;
let currentChannelName = "";
const totalChannels = allTextChannelsInCurrentGuild.size;
const statusMessage = await $.channel.send("Gathering emotes...");
const statusMessage = await channel.send("Gathering emotes...");
let warnings = 0;
$.channel.startTyping();
channel.startTyping();
// Initialize the emote stats object with every emote in the current guild.
// The goal here is to cut the need to access guild.emojis.get() which'll make it faster and easier to work with.
for (let emote of $.guild.emojis.cache.values()) {
for (let emote of guild!.emojis.cache.values()) {
// If you don't include the "a" for animated emotes, it'll not only not show up, but also cause all other emotes in the same message to not show up. The emote name is self-correcting but it's better to keep the right value since it'll be used to calculate message lengths that fit.
stats[emote.id] = {
name: emote.name,
@ -66,7 +65,7 @@ export default new Command({
for (const channel of allTextChannelsInCurrentGuild.values()) {
currentChannelName = channel.name;
let selected = channel.lastMessageID ?? $.message.id;
let selected = channel.lastMessageID ?? message.id;
let continueLoop = true;
while (continueLoop) {
@ -131,7 +130,7 @@ export default new Command({
continueReactionLoop = false;
if (reaction.count !== userReactions + botReactions) {
$.warn(
console.warn(
`[Channel: ${channel.id}, Message: ${msg.id}] A reaction count of ${reaction.count} was expected but was given ${userReactions} user reactions and ${botReactions} bot reactions.`
);
warnings++;
@ -155,14 +154,15 @@ export default new Command({
const finishTime = Date.now();
clearInterval(interval);
statusMessage.edit(
`Finished operation in ${moment.duration(finishTime - startTime).humanize()} with ${$(warnings).pluralise(
`Finished operation in ${moment.duration(finishTime - startTime).humanize()} with ${pluralise(
warnings,
"inconsistenc",
"ies",
"y"
)}.`
);
$.log(`Finished operation in ${finishTime - startTime} ms.`);
$.channel.stopTyping();
console.log(`Finished operation in ${finishTime - startTime} ms.`);
channel.stopTyping();
// Display stats on emote usage.
// This can work outside the loop now that it's synchronous, and now it's clearer what code is meant to execute at the end.
@ -181,6 +181,6 @@ export default new Command({
);
}
$.channel.send(lines, {split: true}).catch($.handler.bind($));
return await channel.send(lines, {split: true});
}
});

View File

@ -1,19 +1,18 @@
import Command from "../../core/command";
import {CommonLibrary} from "../../core/lib";
import {Command, NamedCommand} from "../../core";
import * as https from "https";
export default new Command({
export default new NamedCommand({
description: "Shortens a given URL.",
run: "Please provide a URL.",
any: new Command({
async run($: CommonLibrary): Promise<any> {
https.get("https://is.gd/create.php?format=simple&url=" + encodeURIComponent($.args[0]), function (res) {
async run({message, channel, guild, author, member, client, args}) {
https.get("https://is.gd/create.php?format=simple&url=" + encodeURIComponent(args[0]), function (res) {
var body = "";
res.on("data", function (chunk) {
body += chunk;
});
res.on("end", function () {
$.channel.send(`<${body}>`);
channel.send(`<${body}>`);
});
});
}

View File

@ -0,0 +1,25 @@
import {Command, NamedCommand} from "../../core";
import {streamList} from "../../modules/streamNotifications";
export default new NamedCommand({
description: "Sets the description of your stream. You can embed links by writing `[some name](some link)`",
async run({message, channel, guild, author, member, client, args}) {
const userID = author.id;
if (streamList.has(userID)) {
const stream = streamList.get(userID)!;
const description = args.join(" ") || "No description set.";
stream.description = description;
stream.update();
channel.send(`Successfully set the stream description to:`, {
embed: {
description,
color: member!.displayColor
}
});
} else {
// Alternatively, I could make descriptions last outside of just one stream.
channel.send("You can only use this command when streaming.");
}
}
});

View File

@ -1,5 +1,5 @@
import Command from "../../core/command";
import {Storage} from "../../core/structures";
import {Command, NamedCommand, ask, askYesOrNo, askMultipleChoice, prompt, callMemberByUsername} from "../../core";
import {Storage} from "../../structures";
import {User} from "discord.js";
import moment from "moment";
@ -166,7 +166,7 @@ function getTimeEmbed(user: User) {
return embed;
}
export default new Command({
export default new NamedCommand({
description: "Show others what time it is for you.",
aliases: ["tz"],
async run({channel, author}) {
@ -174,9 +174,9 @@ export default new Command({
},
subcommands: {
// Welcome to callback hell. We hope you enjoy your stay here!
setup: new Command({
setup: new NamedCommand({
description: "Registers your timezone information for the bot.",
async run({author, channel, ask, askYesOrNo, askMultipleChoice}) {
async run({author, channel}) {
const profile = Storage.getUser(author.id);
profile.timezone = null;
profile.daylightSavingsRegion = null;
@ -326,9 +326,9 @@ export default new Command({
);
}
}),
delete: new Command({
delete: new NamedCommand({
description: "Delete your timezone information.",
async run({channel, author, prompt}) {
async run({channel, author}) {
prompt(
await channel.send(
"Are you sure you want to delete your timezone information?\n*(This message will automatically be deleted after 10 seconds.)*"
@ -343,7 +343,7 @@ export default new Command({
);
}
}),
utc: new Command({
utc: new NamedCommand({
description: "Displays UTC time.",
async run({channel}) {
const time = moment().utc();
@ -369,11 +369,12 @@ export default new Command({
});
}
}),
daylight: new Command({
daylight: new NamedCommand({
description: "Provides information on the daylight savings region",
run: DST_NOTE_INFO
})
},
id: "user",
user: new Command({
description: "See what time it is for someone else.",
async run({channel, args}) {
@ -382,7 +383,7 @@ export default new Command({
}),
any: new Command({
description: "See what time it is for someone else (by their username).",
async run({channel, args, message, callMemberByUsername}) {
async run({channel, args, message}) {
callMemberByUsername(message, args.join(" "), (member) => {
channel.send(getTimeEmbed(member.user));
});

View File

@ -0,0 +1,62 @@
import {Command, NamedCommand} from "../../core";
import moment from "moment";
import {Storage} from "../../structures";
import {MessageEmbed} from "discord.js";
export default new NamedCommand({
description: "Keep and edit your personal todo list.",
async run({message, channel, guild, author, member, client, args}) {
const user = Storage.getUser(author.id);
const embed = new MessageEmbed().setTitle(`Todo list for ${author.tag}`).setColor("BLUE");
for (const timestamp in user.todoList) {
const date = new Date(Number(timestamp));
embed.addField(
`${moment(date).format("LT")} ${moment(date).format("LL")} (${moment(date).fromNow()})`,
user.todoList[timestamp]
);
}
channel.send(embed);
},
subcommands: {
add: new NamedCommand({
async run({message, channel, guild, author, member, client, args}) {
const user = Storage.getUser(author.id);
const note = args.join(" ");
user.todoList[Date.now().toString()] = note;
console.debug(user.todoList);
Storage.save();
channel.send(`Successfully added \`${note}\` to your todo list.`);
}
}),
remove: new NamedCommand({
async run({message, channel, guild, author, member, client, args}) {
const user = Storage.getUser(author.id);
const note = args.join(" ");
let isFound = false;
for (const timestamp in user.todoList) {
const selectedNote = user.todoList[timestamp];
if (selectedNote === note) {
delete user.todoList[timestamp];
Storage.save();
isFound = true;
channel.send(`Removed \`${note}\` from your todo list.`);
}
}
if (!isFound) channel.send("That item couldn't be found.");
}
}),
clear: new NamedCommand({
async run({message, channel, guild, author, member, client, args}) {
const user = Storage.getUser(author.id);
user.todoList = {};
Storage.save();
channel.send("Cleared todo list.");
}
})
}
});

View File

@ -1,18 +1,18 @@
import Command from "../../core/command";
import {Command, NamedCommand} from "../../core";
// Anycasting Alert
const translate = require("translate-google");
export default new Command({
export default new NamedCommand({
description: "Translates your input.",
usage: "<lang ID> <input>",
async run($) {
const lang = $.args[0];
const input = $.args.slice(1).join(" ");
async run({message, channel, guild, author, member, client, args}) {
const lang = args[0];
const input = args.slice(1).join(" ");
translate(input, {
to: lang
})
.then((res: any) => {
$.channel.send({
channel.send({
embed: {
title: "Translation",
fields: [
@ -30,7 +30,7 @@ export default new Command({
})
.catch((err: any) => {
console.error(err);
$.channel.send(
channel.send(
`${err}\nPlease use the following list: https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes`
);
});

View File

@ -1,284 +1,646 @@
import $, {isType, parseVars, CommonLibrary} from "./lib";
import {Collection} from "discord.js";
import {generateHandler} from "./storage";
import {promises as ffs, existsSync, writeFile} from "fs";
import {PERMISSIONS} from "./permissions";
import {getPrefix} from "../core/structures";
import {
Collection,
Client,
Message,
TextChannel,
DMChannel,
NewsChannel,
Guild,
User,
GuildMember,
GuildChannel
} from "discord.js";
import {SingleMessageOptions} from "./libd";
import {hasPermission, getPermissionLevel, getPermissionName} from "./permissions";
import {getPrefix} from "./interface";
import {parseVars, requireAllCasesHandledFor} from "../lib";
interface CommandOptions {
description?: string;
endpoint?: boolean;
usage?: string;
permission?: PERMISSIONS | null;
aliases?: string[];
run?: (($: CommonLibrary) => Promise<any>) | string;
subcommands?: {[key: string]: Command};
user?: Command;
number?: Command;
any?: Command;
}
/**
* ===[ 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.
*/
export enum TYPES {
SUBCOMMAND,
USER,
NUMBER,
// RegEx patterns used for identifying/extracting each type from a string argument.
const patterns = {
channel: /^<#(\d{17,19})>$/,
role: /^<@&(\d{17,19})>$/,
emote: /^<a?:.*?:(\d{17,19})>$/,
messageLink: /^https?:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/channels\/(?:\d{17,19}|@me)\/(\d{17,19})\/(\d{17,19})$/,
messagePair: /^(\d{17,19})-(\d{17,19})$/,
user: /^<@!?(\d{17,19})>$/,
id: /^(\d{17,19})$/
};
// Maybe add a guild redirect... somehow?
type ID = "channel" | "role" | "emote" | "message" | "user";
// 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,
NONE
GUILD,
DM
}
export default class Command {
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;
}
interface CommandOptionsBase {
readonly description?: string;
readonly endpoint?: boolean;
readonly usage?: string;
readonly permission?: number;
readonly nsfw?: boolean;
readonly channelType?: CHANNEL_TYPE;
readonly run?: (($: CommandMenu) => Promise<any>) | string;
}
interface CommandOptionsEndpoint {
readonly endpoint: true;
}
// Prevents subcommands from being added by compile-time.
// 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 CommandOptionsNonEndpoint {
readonly endpoint?: false;
readonly subcommands?: {[key: string]: NamedCommand};
readonly channel?: Command;
readonly role?: Command;
readonly emote?: Command;
readonly message?: Command;
readonly user?: Command;
readonly id?: ID;
readonly number?: Command;
readonly any?: Command;
}
type CommandOptions = CommandOptionsBase & (CommandOptionsEndpoint | CommandOptionsNonEndpoint);
type NamedCommandOptions = CommandOptions & {aliases?: string[]};
interface ExecuteCommandMetadata {
readonly header: string;
readonly args: string[];
permission: number;
nsfw: boolean;
channelType: CHANNEL_TYPE;
}
interface CommandInfo {
readonly type: "info";
readonly command: Command;
readonly subcommandInfo: Collection<string, Command>;
readonly keyedSubcommandInfo: Collection<string, NamedCommand>;
readonly permission: number;
readonly nsfw: boolean;
readonly channelType: CHANNEL_TYPE;
readonly args: 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[];
}
export const defaultMetadata = {
permission: 0,
nsfw: false,
channelType: CHANNEL_TYPE.ANY
};
// Each Command instance represents a block that links other Command instances under it.
export class Command {
public readonly description: string;
public readonly endpoint: boolean;
public readonly usage: string;
public readonly permission: PERMISSIONS | null;
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.
public originalCommandName: string | null; // If the command is an alias, what's the original name?
public run: (($: CommonLibrary) => Promise<any>) | string;
public readonly subcommands: Collection<string, Command>; // This is the final data structure you'll actually use to work with the commands the aliases point to.
public user: Command | null;
public number: Command | null;
public any: Command | null;
public static readonly TYPES = TYPES;
public static readonly PERMISSIONS = PERMISSIONS;
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
// 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 id: Command | null;
private idType: ID | null;
private number: Command | null;
private any: Command | null;
constructor(options?: CommandOptions) {
this.description = options?.description || "No description.";
this.endpoint = options?.endpoint || false;
this.usage = options?.usage || "";
this.permission = options?.permission ?? null;
this.aliases = options?.aliases ?? [];
this.originalCommandName = null;
this.endpoint = !!options?.endpoint;
this.usage = options?.usage ?? "";
this.permission = options?.permission ?? -1;
this.nsfw = options?.nsfw ?? null;
this.channelType = options?.channelType ?? null;
this.run = options?.run || "No action was set on this command!";
this.subcommands = new Collection(); // Populate this collection after setting subcommands.
this.user = options?.user || null;
this.number = options?.number || null;
this.any = options?.any || null;
this.channel = null;
this.role = null;
this.emote = null;
this.message = null;
this.user = null;
this.id = null;
this.idType = null;
this.number = null;
this.any = null;
if (options?.subcommands) {
const baseSubcommands = Object.keys(options.subcommands);
if (options && !options.endpoint) {
if (options?.channel) this.channel = options.channel;
if (options?.role) this.role = options.role;
if (options?.emote) this.emote = options.emote;
if (options?.message) this.message = options.message;
if (options?.user) this.user = options.user;
if (options?.number) this.number = options.number;
if (options?.any) this.any = options.any;
if (options?.id) this.idType = options.id;
// 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.originalCommandName = name;
const aliases = subcmd.aliases;
for (const alias of aliases) {
if (baseSubcommands.includes(alias))
$.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))
$.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);
}
}
}
if (this.user && this.user.aliases.length > 0)
$.warn(
`There are aliases defined for a "user"-type subcommand, but those aliases won't be used. (Look at the next "Loading Command" line to see which command is affected.)`
);
if (this.number && this.number.aliases.length > 0)
$.warn(
`There are aliases defined for a "number"-type subcommand, but those aliases won't be used. (Look at the next "Loading Command" line to see which command is affected.)`
);
if (this.any && this.any.aliases.length > 0)
$.warn(
`There are aliases defined for an "any"-type subcommand, but those aliases won't be used. (Look at the next "Loading Command" line to see which command is affected.)`
);
}
public execute($: CommonLibrary) {
if (isType(this.run, String)) {
$.channel.send(
parseVars(
this.run as string,
{
author: $.author.toString(),
prefix: getPrefix($.guild)
},
"???"
)
);
} else (this.run as Function)($).catch($.handler.bind($));
}
public resolve(param: string): TYPES {
if (this.subcommands.has(param)) return TYPES.SUBCOMMAND;
// Any Discord ID format will automatically format to a user ID.
else if (this.user && /\d{17,19}/.test(param)) return TYPES.USER;
// Disallow infinity and allow for 0.
else if (this.number && (Number(param) || param === "0") && !param.includes("Infinity")) return TYPES.NUMBER;
else if (this.any) return TYPES.ANY;
else return TYPES.NONE;
}
public get(param: string): Command {
const type = this.resolve(param);
let command: Command;
switch (type) {
case TYPES.SUBCOMMAND:
command = this.subcommands.get(param) as Command;
break;
case TYPES.USER:
command = this.user as Command;
break;
case TYPES.NUMBER:
command = this.number as Command;
break;
case TYPES.ANY:
command = this.any as Command;
break;
default:
command = this;
break;
}
return command;
}
}
let commands: Collection<string, Command> | null = null;
export const categories: Collection<string, string[]> = new Collection();
export const aliases: Collection<string, string> = new Collection(); // Top-level aliases only.
/** Returns the cache of the commands if it exists and searches the directory if not. */
export async function loadCommands(): Promise<Collection<string, Command>> {
if (commands) return commands;
if (process.argv[2] === "dev" && !existsSync("src/commands/test.ts"))
writeFile(
"src/commands/test.ts",
template,
generateHandler('"test.ts" (testing/template command) successfully generated.')
);
commands = new Collection();
const dir = await ffs.opendir("dist/commands");
const listMisc: string[] = [];
let selected;
// There will only be one level of directory searching (per category).
while ((selected = await dir.read())) {
if (selected.isDirectory()) {
if (selected.name === "subcommands") continue;
const subdir = await ffs.opendir(`dist/commands/${selected.name}`);
const category = $(selected.name).toTitleCase();
const list: string[] = [];
let cmd;
while ((cmd = await subdir.read())) {
if (cmd.isDirectory()) {
if (cmd.name === "subcommands") continue;
else $.warn(`You can't have multiple levels of directories! From: "dist/commands/${cmd.name}"`);
} else if (cmd.name.endsWith(".js")) {
loadCommand(cmd.name, list, selected.name);
if (options?.id) {
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;
default:
requireAllCasesHandledFor(options.id);
}
}
subdir.close();
categories.set(category, list);
} else if (selected.name.endsWith(".js")) {
loadCommand(selected.name, listMisc);
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);
}
}
}
}
}
dir.close();
categories.set("Miscellaneous", listMisc);
// Go through the arguments provided and find the right subcommand, then execute with the given arguments.
// Will return null if it successfully executes, SingleMessageOptions 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.
public async execute(
args: string[],
menu: CommandMenu,
metadata: ExecuteCommandMetadata
): Promise<SingleMessageOptions | 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;
return commands;
}
// Take off the leftmost argument from the list.
const param = args.shift();
async function loadCommand(filename: string, list: string[], category?: string) {
if (!commands) return $.error(`Function "loadCommand" was called without first initializing commands!`);
// If there are no arguments left, execute the current command. Otherwise, continue on.
if (param === undefined) {
// See if there is anything that'll prevent the user from executing the command.
const prefix = category ?? "";
const header = filename.substring(0, filename.indexOf(".js"));
const command = (await import(`../commands/${prefix}/${header}`)).default as Command | undefined;
// 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 {content: "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 {content: "This command must be executed as a direct message."};
}
if (!command) return $.warn(`Command "${header}" has no default export which is a Command instance!`);
// 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 {content: "This command must be executed in either an NSFW channel or as a direct message."};
}
command.originalCommandName = header;
list.push(header);
// 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);
if (commands.has(header))
$.warn(
`Command "${header}" already exists! Make sure to make each command uniquely identifiable across categories!`
);
else commands.set(header, command);
return {
content: `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}).`
};
}
for (const alias of command.aliases) {
if (commands.has(alias))
$.warn(`Top-level alias "${alias}" from command "${header}" already exists either as a command or alias!`);
else commands.set(alias, command);
// Then capture any potential errors.
try {
if (typeof this.run === "string") {
await menu.channel.send(
parseVars(
this.run,
{
author: menu.author.toString(),
prefix: getPrefix(menu.guild)
},
"???"
)
);
} else {
await this.run(menu);
}
return null;
} catch (error) {
const errorMessage = error.stack ?? error;
console.error(`Command Error: ${metadata.header} (${metadata.args.join(", ")})\n${errorMessage}`);
return {
content: `There was an error while trying to execute that command!\`\`\`${errorMessage}\`\`\``
};
}
}
// If the current command is an endpoint but there are still some arguments left, don't continue.
if (this.endpoint) return {content: "Too many arguments!"};
// 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)) {
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 = menu.client.channels.cache.get(id);
// Users can only enter in this format for text channels, so this restricts it to that.
if (channel instanceof TextChannel) {
menu.args.push(channel);
return this.channel.execute(args, menu, metadata);
} else {
return {
content: `\`${id}\` is not a valid text channel!`
};
}
} else if (this.role && patterns.role.test(param)) {
const id = patterns.role.exec(param)![1];
if (!menu.guild) {
return {
content: "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.role.execute(args, menu, metadata);
} else {
return {
content: `\`${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) {
menu.args.push(emote);
return this.emote.execute(args, menu, metadata);
} else {
return {
content: `\`${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 channel = menu.client.channels.cache.get(channelID);
if (channel instanceof TextChannel || channel instanceof DMChannel) {
try {
menu.args.push(await channel.messages.fetch(messageID));
return this.message.execute(args, menu, metadata);
} catch {
return {
content: `\`${messageID}\` isn't a valid message of channel ${channel}!`
};
}
} else {
return {
content: `\`${channelID}\` is not a valid text channel!`
};
}
} else if (this.user && patterns.user.test(param)) {
const id = patterns.user.exec(param)![1];
try {
menu.args.push(await menu.client.users.fetch(id));
return this.user.execute(args, menu, metadata);
} catch {
return {
content: `No user found by the ID \`${id}\`!`
};
}
} else if (this.id && this.idType && patterns.id.test(param)) {
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 = menu.client.channels.cache.get(id);
// Users can only enter in this format for text channels, so this restricts it to that.
if (channel instanceof TextChannel) {
menu.args.push(channel);
return this.id.execute(args, menu, metadata);
} else {
return {
content: `\`${id}\` isn't a valid text channel!`
};
}
case "role":
if (!menu.guild) {
return {
content: "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 {
content: `\`${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 {
content: `\`${id}\` isn't a valid emote!`
};
}
case "message":
try {
menu.args.push(await menu.channel.messages.fetch(id));
return this.id.execute(args, menu, metadata);
} catch {
return {
content: `\`${id}\` isn't a valid message of channel ${menu.channel}!`
};
}
case "user":
try {
menu.args.push(await menu.client.users.fetch(id));
return this.id.execute(args, menu, metadata);
} catch {
return {
content: `No user found by the ID \`${id}\`!`
};
}
default:
requireAllCasesHandledFor(this.idType);
}
} else if (this.number && !Number.isNaN(Number(param)) && param !== "Infinity" && param !== "-Infinity") {
menu.args.push(Number(param));
return this.number.execute(args, menu, metadata);
} else if (this.any) {
menu.args.push(param);
return this.any.execute(args, menu, metadata);
} else {
// Continue adding on the rest of the arguments if there's no valid subcommand.
menu.args.push(param);
return this.execute(args, menu, metadata);
}
// 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.
}
$.log(`Loading Command: ${header} (${category ? $(category).toTitleCase() : "Miscellaneous"})`);
// What this does is resolve the resulting subcommand as well as the inherited properties and the available subcommands.
public async resolveInfo(args: string[]): Promise<CommandInfo | CommandInfoError> {
return this.resolveInfoInternal(args, {...defaultMetadata, args: [], usage: "", originalArgs: [...args]});
}
private async resolveInfoInternal(
args: string[],
metadata: CommandInfoMetadata
): Promise<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, NamedCommand>();
const subcommandInfo = new Collection<string, Command>();
// 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);
if (this.any) subcommandInfo.set("<any>", 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) {
metadata.args.push("<any>");
return this.any.resolveInfoInternal(args, 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();
}
}
}
// 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.
const template = `import Command from '../core/command';
import {CommonLibrary} from '../core/lib';
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?
export default new Command({
description: "This is a template/testing command providing common functionality. Remove what you don't need, and rename/delete this file to generate a fresh command file here. This command should be automatically excluded from the help command. The \\"usage\\" parameter (string) overrides the default usage for the help command. The \\"endpoint\\" parameter (boolean) prevents further arguments from being passed. Also, as long as you keep the run function async, it'll return a promise allowing the program to automatically catch any synchronous errors. However, you'll have to do manual error handling if you go the then and catch route.",
endpoint: false,
usage: '',
permission: null,
aliases: [],
async run($: CommonLibrary): Promise<any> {
constructor(options?: NamedCommandOptions) {
super(options);
this.aliases = options?.aliases || [];
this.originalCommandName = null;
}
},
subcommands: {
layer: new Command({
description: "This is a named subcommand, meaning that the key name is what determines the keyword to use. With default settings for example, \\"$test layer\\".",
endpoint: false,
usage: '',
permission: null,
aliases: [],
async run($: CommonLibrary): Promise<any> {
public get name(): string {
if (this.originalCommandName === null) throw new Error("originalCommandName must be set before accessing it!");
else return this.originalCommandName;
}
}
})
},
user: new Command({
description: "This is the subcommand for getting users by pinging them or copying their ID. With default settings for example, \\"$test 237359961842253835\\". The argument will be a user object and won't run if no user is found by that ID.",
endpoint: false,
usage: '',
permission: null,
async run($: CommonLibrary): Promise<any> {
}
}),
number: new Command({
description: "This is a numeric subcommand, meaning that any type of number (excluding Infinity/NaN) will route to this command if present. With default settings for example, \\"$test -5.2\\". The argument with the number is already parsed so you can just use it without converting it.",
endpoint: false,
usage: '',
permission: null,
async run($: CommonLibrary): Promise<any> {
}
}),
any: new Command({
description: "This is a generic subcommand, meaning that if there isn't a more specific subcommand that's called, it falls to this. With default settings for example, \\"$test reeee\\".",
endpoint: false,
usage: '',
permission: null,
async run($: CommonLibrary): Promise<any> {
}
})
});`;
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;
}
}

View File

@ -1,39 +0,0 @@
import {Client, ClientEvents, Constants} from "discord.js";
import Storage from "./storage";
import $ from "./lib";
interface EventOptions<K extends keyof ClientEvents> {
readonly on?: (...args: ClientEvents[K]) => void;
readonly once?: (...args: ClientEvents[K]) => void;
}
export default class Event<K extends keyof ClientEvents> {
private readonly on?: (...args: ClientEvents[K]) => void;
private readonly once?: (...args: ClientEvents[K]) => void;
constructor(options: EventOptions<K>) {
this.on = options.on;
this.once = options.once;
}
// For this function, I'm going to assume that the event is used with the correct arguments and that the event tag is checked in "storage.ts".
public attach(client: Client, event: K) {
if (this.on) client.on(event, this.on);
if (this.once) client.once(event, this.once);
}
}
export async function loadEvents(client: Client) {
for (const file of Storage.open("dist/events", (filename: string) => filename.endsWith(".js"))) {
const header = file.substring(0, file.indexOf(".js"));
const event = (await import(`../events/${header}`)).default;
if ((Object.values(Constants.Events) as string[]).includes(header)) {
event.attach(client, header);
$.log(`Loading Event: ${header}`);
} else
$.warn(
`"${header}" is not a valid event type! Did you misspell it? (Note: If you fixed the issue, delete "dist" because the compiler won't automatically delete any extra files.)`
);
}
}

View File

@ -0,0 +1,29 @@
import {Client, Permissions, Message} 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.
export const unreactEventListeners: Map<string, (emote: string, id: string) => void> = new Map();
// 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.
// Also, I'm defining it here instead of the message event because the load order screws up if you export it from there. Yeah... I'm starting to notice just how much technical debt has been built up. The command handler needs to be modularized and refactored sooner rather than later. Define all constants in one area then grab from there.
export const replyEventListeners = new Map<string, (message: Message) => void>();
export function attachEventListenersToClient(client: Client) {
// Attached to the client, there can be one event listener attached to a message ID which is executed if present.
client.on("messageReactionRemove", (reaction, user) => {
const canDeleteEmotes = botHasPermission(reaction.message.guild, Permissions.FLAGS.MANAGE_MESSAGES);
if (!canDeleteEmotes) {
const callback = unreactEventListeners.get(reaction.message.id);
callback && callback(reaction.emoji.name, user.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);
}
});
}

130
src/core/handler.ts Normal file
View File

@ -0,0 +1,130 @@
import {Client, Permissions, Message, TextChannel, DMChannel, NewsChannel} from "discord.js";
import {loadableCommands} from "./loader";
import {defaultMetadata} from "./command";
import {getPrefix} 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
};
// 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 text = content;
const menu = {
author,
channel,
client,
guild,
member,
message,
args: []
};
// 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
});
// If something went wrong, let the user know (like if they don't have permission to use a command).
if (result) {
channel.send(result);
}
} else {
channel.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)) {
channel.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
});
// If something went wrong, let the user know (like if they don't have permission to use a command).
if (result) {
channel.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}\`\`\``
);
}
});

16
src/core/index.ts Normal file
View File

@ -0,0 +1,16 @@
export {Command, NamedCommand, CHANNEL_TYPE} from "./command";
export {addInterceptRule} from "./handler";
export {launch} from "./interface";
export {
SingleMessageOptions,
botHasPermission,
paginate,
prompt,
ask,
askYesOrNo,
askMultipleChoice,
getMemberByUsername,
callMemberByUsername
} from "./libd";
export {loadableCommands, categories} from "./loader";
export {hasPermission, getPermissionLevel, getPermissionName} from "./permissions";

23
src/core/interface.ts Normal file
View File

@ -0,0 +1,23 @@
import {Client, User, GuildMember, Guild} from "discord.js";
import {attachMessageHandlerToClient} from "./handler";
import {attachEventListenersToClient} from "./eventListeners";
interface LaunchSettings {
permissionLevels: PermissionLevel[];
getPrefix: (guild: Guild | null) => string;
}
export async function launch(client: Client, settings: LaunchSettings) {
attachMessageHandlerToClient(client);
attachEventListenersToClient(client);
permissionLevels = settings.permissionLevels;
getPrefix = settings.getPrefix;
}
interface PermissionLevel {
name: string;
check: (user: User, member: GuildMember | null) => boolean;
}
export let permissionLevels: PermissionLevel[] = [];
export let getPrefix: (guild: Guild | null) => string = () => ".";

View File

@ -1,574 +0,0 @@
import {GenericWrapper, NumberWrapper, StringWrapper, ArrayWrapper} from "./wrappers";
import {Client, Message, TextChannel, DMChannel, NewsChannel, Guild, User, GuildMember, Permissions} from "discord.js";
import chalk from "chalk";
import {get} from "https";
import FileManager from "./storage";
import {eventListeners} from "../events/messageReactionRemove";
import {client} from "../index";
import {EmoteRegistryDump, EmoteRegistryDumpEntry} from "./structures";
/** A type that describes what the library module does. */
export interface CommonLibrary {
// Wrapper Object //
/** Wraps the value you enter with an object that provides extra functionality and provides common utility functions. */
(value: number): NumberWrapper;
(value: string): StringWrapper;
<T>(value: T[]): ArrayWrapper<T>;
<T>(value: T): GenericWrapper<T>;
// Common Library Functions //
/** <Promise>.catch($.handler.bind($)) or <Promise>.catch(error => $.handler(error)) */
handler: (error: Error) => void;
log: (...args: any[]) => void;
warn: (...args: any[]) => void;
error: (...args: any[]) => void;
debug: (...args: any[]) => void;
ready: (...args: any[]) => void;
paginate: (
message: Message,
senderID: string,
total: number,
callback: (page: number) => void,
duration?: number
) => void;
prompt: (message: Message, senderID: string, onConfirm: () => void, duration?: number) => void;
getMemberByUsername: (guild: Guild, username: string) => Promise<GuildMember | undefined>;
callMemberByUsername: (
message: Message,
username: string,
onSuccess: (member: GuildMember) => void
) => Promise<void>;
ask: (
message: Message,
senderID: string,
condition: (reply: string) => boolean,
onSuccess: () => void,
onReject: () => string,
timeout?: number
) => void;
askYesOrNo: (message: Message, senderID: string, timeout?: number) => Promise<boolean>;
askMultipleChoice: (
message: Message,
senderID: string,
callbackStack: (() => void)[] | ((choice: number) => void)
) => void;
// Dynamic Properties //
args: any[];
client: Client;
message: Message;
channel: TextChannel | DMChannel | NewsChannel;
guild: Guild | null;
author: User;
member: GuildMember | null;
}
export default function $(value: number): NumberWrapper;
export default function $(value: string): StringWrapper;
export default function $<T>(value: T[]): ArrayWrapper<T>;
export default function $<T>(value: T): GenericWrapper<T>;
export default function $(value: any) {
if (isType(value, Number)) return new NumberWrapper(value);
else if (isType(value, String)) return new StringWrapper(value);
else if (isType(value, Array)) return new ArrayWrapper(value);
else return new GenericWrapper(value);
}
// If you use promises, use this function to display the error in chat.
// Case #1: await $.channel.send(""); --> Automatically caught by Command.execute().
// Case #2: $.channel.send("").catch($.handler.bind($)); --> Manually caught by the user.
$.handler = function (this: CommonLibrary, error: Error) {
if (this)
this.channel.send(
`There was an error while trying to execute that command!\`\`\`${error.stack ?? error}\`\`\``
);
else
$.warn(
"No context was attached to $.handler! Make sure to use .catch($.handler.bind($)) or .catch(error => $.handler(error)) instead!"
);
$.error(error);
};
// Logs with different levels of verbosity.
export const logs: {[type: string]: string} = {
error: "",
warn: "",
info: "",
verbose: ""
};
let enabled = true;
export function setConsoleActivated(activated: boolean) {
enabled = activated;
}
// The custom console. In order of verbosity, error, warn, log, and debug. Ready is a variation of log.
// General Purpose Logger
$.log = (...args: any[]) => {
if (enabled) console.log(chalk.white.bgGray(formatTimestamp()), chalk.black.bgWhite("INFO"), ...args);
const text = `[${formatUTCTimestamp()}] [INFO] ${args.join(" ")}\n`;
logs.info += text;
logs.verbose += text;
};
// "It'll still work, but you should really check up on this."
$.warn = (...args: any[]) => {
if (enabled) console.warn(chalk.white.bgGray(formatTimestamp()), chalk.black.bgYellow("WARN"), ...args);
const text = `[${formatUTCTimestamp()}] [WARN] ${args.join(" ")}\n`;
logs.warn += text;
logs.info += text;
logs.verbose += text;
};
// Used for anything which prevents the program from actually running.
$.error = (...args: any[]) => {
if (enabled) console.error(chalk.white.bgGray(formatTimestamp()), chalk.white.bgRed("ERROR"), ...args);
const text = `[${formatUTCTimestamp()}] [ERROR] ${args.join(" ")}\n`;
logs.error += text;
logs.warn += text;
logs.info += text;
logs.verbose += text;
};
// Be as verbose as possible. If anything might help when debugging an error, then include it. This only shows in your console if you run this with "dev", but you can still get it from "logs.verbose".
// $.debug(`core/lib::parseArgs("testing \"in progress\"") = ["testing", "in progress"]`) --> <path>/::(<object>.)<function>(<args>) = <value>
// Would probably be more suited for debugging program logic rather than function logic, which can be checked using unit tests.
$.debug = (...args: any[]) => {
if (process.argv[2] === "dev" && enabled)
console.debug(chalk.white.bgGray(formatTimestamp()), chalk.white.bgBlue("DEBUG"), ...args);
const text = `[${formatUTCTimestamp()}] [DEBUG] ${args.join(" ")}\n`;
logs.verbose += text;
};
// Used once at the start of the program when the bot loads.
$.ready = (...args: any[]) => {
if (enabled) console.log(chalk.white.bgGray(formatTimestamp()), chalk.black.bgGreen("READY"), ...args);
const text = `[${formatUTCTimestamp()}] [READY] ${args.join(" ")}\n`;
logs.info += text;
logs.verbose += text;
};
export function formatTimestamp(now = new Date()) {
const year = now.getFullYear();
const month = (now.getMonth() + 1).toString().padStart(2, "0");
const day = now.getDate().toString().padStart(2, "0");
const hour = now.getHours().toString().padStart(2, "0");
const minute = now.getMinutes().toString().padStart(2, "0");
const second = now.getSeconds().toString().padStart(2, "0");
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
}
export function formatUTCTimestamp(now = new Date()) {
const year = now.getUTCFullYear();
const month = (now.getUTCMonth() + 1).toString().padStart(2, "0");
const day = now.getUTCDate().toString().padStart(2, "0");
const hour = now.getUTCHours().toString().padStart(2, "0");
const minute = now.getUTCMinutes().toString().padStart(2, "0");
const second = now.getUTCSeconds().toString().padStart(2, "0");
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
}
export function botHasPermission(guild: Guild | null, permission: number): boolean {
return !!guild?.me?.hasPermission(permission);
}
export function updateGlobalEmoteRegistry(): void {
const data: EmoteRegistryDump = {version: 1, list: []};
for (const guild of client.guilds.cache.values()) {
for (const emote of guild.emojis.cache.values()) {
data.list.push({
ref: emote.name,
id: emote.id,
name: emote.name,
requires_colons: emote.requiresColons || false,
animated: emote.animated,
url: emote.url,
guild_id: emote.guild.name,
guild_name: emote.guild.name
});
}
}
FileManager.write("emote-registry", data, true);
}
// Maybe promisify this section to reduce the potential for creating callback hell? Especially if multiple questions in a row are being asked.
// 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.
$.paginate = async (
message: Message,
senderID: string,
total: number,
callback: (page: number) => void,
duration = 60000
) => {
let page = 0;
const turn = (amount: number) => {
page += amount;
if (page < 0) page += total;
else if (page >= total) page -= total;
callback(page);
};
const BACKWARDS_EMOJI = "⬅️";
const FORWARDS_EMOJI = "➡️";
const handle = (emote: string, reacterID: string) => {
switch (emote) {
case BACKWARDS_EMOJI:
turn(-1);
break;
case FORWARDS_EMOJI:
turn(1);
break;
}
};
// Listen for reactions and call the handler.
let backwardsReaction = await message.react(BACKWARDS_EMOJI);
let forwardsReaction = await message.react(FORWARDS_EMOJI);
eventListeners.set(message.id, handle);
await message.awaitReactions(
(reaction, user) => {
if (user.id === senderID) {
// 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(message.guild, Permissions.FLAGS.MANAGE_MESSAGES);
handle(reaction.emoji.name, user.id);
if (canDeleteEmotes) reaction.users.remove(user);
}
return false;
},
{time: duration}
);
// When time's up, remove the bot's own reactions.
eventListeners.delete(message.id);
backwardsReaction.users.remove(message.author);
forwardsReaction.users.remove(message.author);
};
// Waits for the sender to either confirm an action or let it pass (and delete the message).
// This should probably be renamed to "confirm" now that I think of it, "prompt" is better used elsewhere.
// Append "\n*(This message will automatically be deleted after 10 seconds.)*" in the future?
$.prompt = async (message: Message, senderID: string, onConfirm: () => void, duration = 10000) => {
let isDeleted = false;
message.react("✅");
await message.awaitReactions(
(reaction, user) => {
if (user.id === senderID) {
if (reaction.emoji.name === "✅") {
onConfirm();
isDeleted = true;
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 (!isDeleted) message.delete();
};
// 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.
// Also, I'm defining it here instead of the message event because the load order screws up if you export it from there. Yeah... I'm starting to notice just how much technical debt has been built up. The command handler needs to be modularized and refactored sooner rather than later. Define all constants in one area then grab from there.
export const replyEventListeners = new Map<string, (message: Message) => void>();
// 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).
// Append "\n*(Note: Make sure to use Discord's inline reply feature or this won't work!)*" in the future? And also the "you can now reply to this message" edit.
$.ask = async (
message: Message,
senderID: string,
condition: (reply: string) => boolean,
onSuccess: () => void,
onReject: () => string,
timeout = 60000
) => {
const referenceID = `${message.channel.id}-${message.id}`;
replyEventListeners.set(referenceID, (reply) => {
if (reply.author.id === senderID) {
if (condition(reply.content)) {
onSuccess();
replyEventListeners.delete(referenceID);
} else {
reply.reply(onReject());
}
}
});
setTimeout(() => {
replyEventListeners.set(referenceID, (reply) => {
reply.reply("that action timed out, try using the command again");
replyEventListeners.delete(referenceID);
});
}, timeout);
};
$.askYesOrNo = (message: Message, senderID: string, timeout = 30000): Promise<boolean> => {
return new Promise(async (resolve, reject) => {
let isDeleted = false;
await message.react("✅");
message.react("❌");
await message.awaitReactions(
(reaction, user) => {
if (user.id === senderID) {
const isCheckReacted = reaction.emoji.name === "✅";
if (isCheckReacted || reaction.emoji.name === "❌") {
resolve(isCheckReacted);
isDeleted = true;
message.delete();
}
}
return false;
},
{time: timeout}
);
if (!isDeleted) {
message.delete();
reject("Prompt timed out.");
}
});
};
// 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.
$.askMultipleChoice = async (message: Message, senderID: string, callbackStack: (() => void)[], timeout = 90000) => {
if (callbackStack.length > multiNumbers.length) {
message.channel.send(
`\`ERROR: The amount of callbacks in "askMultipleChoice" must not exceed the total amount of allowed options (${multiNumbers.length})!\``
);
return;
}
let isDeleted = false;
for (let i = 0; i < callbackStack.length; i++) {
await message.react(multiNumbers[i]);
}
await message.awaitReactions(
(reaction, user) => {
if (user.id === senderID) {
const index = multiNumbers.indexOf(reaction.emoji.name);
if (index !== -1) {
callbackStack[index]();
isDeleted = true;
message.delete();
}
}
return false;
},
{time: timeout}
);
if (!isDeleted) message.delete();
};
$.getMemberByUsername = async (guild: Guild, username: string) => {
return (
await guild.members.fetch({
query: username,
limit: 1
})
).first();
};
/** Convenience function to handle false cases automatically. */
$.callMemberByUsername = async (message: Message, username: string, onSuccess: (member: GuildMember) => void) => {
const guild = message.guild;
const send = message.channel.send;
if (guild) {
const member = await $.getMemberByUsername(guild, username);
if (member) onSuccess(member);
else send(`Couldn't find a user by the name of \`${username}\`!`);
} else send("You must execute this command in a server!");
};
/**
* Splits a command by spaces while accounting for quotes which capture string arguments.
* - `\"` = `"`
* - `\\` = `\`
*/
export function parseArgs(line: string): string[] {
let result = [];
let selection = "";
let inString = false;
let isEscaped = false;
for (let c of line) {
if (isEscaped) {
if (['"', "\\"].includes(c)) selection += c;
else selection += "\\" + c;
isEscaped = false;
} else if (c === "\\") isEscaped = true;
else if (c === '"') inString = !inString;
else if (c === " " && !inString) {
result.push(selection);
selection = "";
} else selection += c;
}
if (selection.length > 0) result.push(selection);
return result;
}
/**
* Allows you to store a template string with variable markers and parse it later.
* - Use `%name%` for variables
* - `%%` = `%`
* - If the invalid token is null/undefined, nothing is changed.
*/
export function parseVars(line: string, definitions: {[key: string]: string}, invalid: string | null = ""): string {
let result = "";
let inVariable = false;
let token = "";
for (const c of line) {
if (c === "%") {
if (inVariable) {
if (token === "") result += "%";
else {
if (token in definitions) result += definitions[token];
else if (invalid === null) result += `%${token}%`;
else result += invalid;
token = "";
}
}
inVariable = !inVariable;
} else if (inVariable) token += c;
else result += c;
}
return result;
}
export function isType(value: any, type: any): boolean {
if (value === undefined && type === undefined) return true;
else if (value === null && type === null) return true;
else return value !== undefined && value !== null && value.constructor === type;
}
/**
* Checks a value to see if it matches the fallback's type, otherwise returns the fallback.
* For the purposes of the templates system, this function will only check array types, objects should be checked under their own type (as you'd do anyway with something like a User object).
* If at any point the value doesn't match the data structure provided, the fallback is returned.
* Warning: Type checking is based on the fallback's type. Be sure that the "type" parameter is accurate to this!
*/
export function select<T>(value: any, fallback: T, type: Function, isArray = false): T {
if (isArray && isType(value, Array)) {
for (let item of value) if (!isType(item, type)) return fallback;
return value;
} else {
if (isType(value, type)) return value;
else return fallback;
}
}
export function clean(text: any) {
if (typeof text === "string")
return text.replace(/`/g, "`" + String.fromCharCode(8203)).replace(/@/g, "@" + String.fromCharCode(8203));
else return text;
}
export function trimArray(arr: any, maxLen = 10) {
if (arr.length > maxLen) {
const len = arr.length - maxLen;
arr = arr.slice(0, maxLen);
arr.push(`${len} more...`);
}
return arr;
}
export function formatBytes(bytes: any) {
if (bytes === 0) return "0 Bytes";
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${parseFloat((bytes / Math.pow(1024, i)).toFixed(2))} ${sizes[i]}`;
}
export function getContent(url: any) {
return new Promise((resolve, reject) => {
get(url, (res: {resume?: any; setEncoding?: any; on?: any; statusCode?: any}) => {
const {statusCode} = res;
if (statusCode !== 200) {
res.resume();
reject(`Request failed. Status code: ${statusCode}`);
}
res.setEncoding("utf8");
let rawData = "";
res.on("data", (chunk: string) => {
rawData += chunk;
});
res.on("end", () => {
try {
const parsedData = JSON.parse(rawData);
resolve(parsedData);
} catch (e) {
reject(`Error: ${e.message}`);
}
});
}).on("error", (err: {message: any}) => {
reject(`Error: ${err.message}`);
});
});
}
export interface GenericJSON {
[key: string]: any;
}
export abstract class GenericStructure {
private __meta__ = "generic";
constructor(tag?: string) {
this.__meta__ = tag || this.__meta__;
}
public save(asynchronous = true) {
const tag = this.__meta__;
/// @ts-ignore
delete this.__meta__;
FileManager.write(tag, this, asynchronous);
this.__meta__ = tag;
}
}
// A 50% chance would be "Math.random() < 0.5" because Math.random() can be [0, 1), so to make two equal ranges, you'd need [0, 0.5)U[0.5, 1).
// Similar logic would follow for any other percentage. Math.random() < 1 is always true (100% chance) and Math.random() < 0 is always false (0% chance).
export const Random = {
num: (min: number, max: number) => Math.random() * (max - min) + min,
int: (min: number, max: number) => Math.floor(Random.num(min, max)),
chance: (decimal: number) => Math.random() < decimal,
sign: (number = 1) => number * (Random.chance(0.5) ? -1 : 1),
deviation: (base: number, deviation: number) => Random.num(base - deviation, base + deviation)
};

275
src/core/libd.ts Normal file
View File

@ -0,0 +1,275 @@
// Library for Discord-specific functions
import {
Message,
Guild,
GuildMember,
Permissions,
TextChannel,
DMChannel,
NewsChannel,
MessageOptions
} from "discord.js";
import {unreactEventListeners, replyEventListeners} from "./eventListeners";
export type SingleMessageOptions = MessageOptions & {split?: false};
/**
* 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);
}
// Maybe promisify this section to reduce the potential for creating callback hell? Especially if multiple questions in a row are being asked.
// 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.
*/
export async function paginate(
channel: TextChannel | DMChannel | NewsChannel,
senderID: string,
total: number,
callback: (page: number, hasMultiplePages: boolean) => SingleMessageOptions,
duration = 60000
) {
const hasMultiplePages = total > 1;
const message = await channel.send(callback(0, hasMultiplePages));
if (hasMultiplePages) {
let page = 0;
const turn = (amount: number) => {
page += amount;
if (page < 0) page += total;
else if (page >= total) page -= total;
message.edit(callback(page, true));
};
const BACKWARDS_EMOJI = "⬅️";
const FORWARDS_EMOJI = "➡️";
const handle = (emote: string, reacterID: string) => {
if (senderID === reacterID) {
switch (emote) {
case BACKWARDS_EMOJI:
turn(-1);
break;
case FORWARDS_EMOJI:
turn(1);
break;
}
}
};
// Listen for reactions and call the handler.
let backwardsReaction = await message.react(BACKWARDS_EMOJI);
let forwardsReaction = await message.react(FORWARDS_EMOJI);
unreactEventListeners.set(message.id, handle);
const collector = message.createReactionCollector(
(reaction, user) => {
if (user.id === senderID) {
// 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(message.guild, Permissions.FLAGS.MANAGE_MESSAGES);
handle(reaction.emoji.name, user.id);
if (canDeleteEmotes) reaction.users.remove(user);
collector.resetTimer();
}
return false;
},
// Apparently, regardless of whether you put "time" or "idle", it won't matter to the collector.
// In order to actually reset the timer, you have to do it manually via collector.resetTimer().
{time: duration}
);
// When time's up, remove the bot's own reactions.
collector.on("end", () => {
unreactEventListeners.delete(message.id);
backwardsReaction.users.remove(message.author);
forwardsReaction.users.remove(message.author);
});
}
}
// Waits for the sender to either confirm an action or let it pass (and delete the message).
// This should probably be renamed to "confirm" now that I think of it, "prompt" is better used elsewhere.
// Append "\n*(This message will automatically be deleted after 10 seconds.)*" in the future?
/**
* Prompts the user about a decision before following through.
*/
export async function prompt(message: Message, senderID: string, onConfirm: () => void, duration = 10000) {
let isDeleted = false;
message.react("✅");
await message.awaitReactions(
(reaction, user) => {
if (user.id === senderID) {
if (reaction.emoji.name === "✅") {
onConfirm();
isDeleted = true;
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 (!isDeleted) message.delete();
}
// 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).
// Append "\n*(Note: Make sure to use Discord's inline reply feature or this won't work!)*" in the future? And also the "you can now reply to this message" edit.
export function ask(
message: Message,
senderID: string,
condition: (reply: string) => boolean,
onSuccess: () => void,
onReject: () => string,
timeout = 60000
) {
const referenceID = `${message.channel.id}-${message.id}`;
replyEventListeners.set(referenceID, (reply) => {
if (reply.author.id === senderID) {
if (condition(reply.content)) {
onSuccess();
replyEventListeners.delete(referenceID);
} else {
reply.reply(onReject());
}
}
});
setTimeout(() => {
replyEventListeners.set(referenceID, (reply) => {
reply.reply("that action timed out, try using the command again");
replyEventListeners.delete(referenceID);
});
}, timeout);
}
export function askYesOrNo(message: Message, senderID: string, timeout = 30000): Promise<boolean> {
return new Promise(async (resolve, reject) => {
let isDeleted = false;
await message.react("✅");
message.react("❌");
await message.awaitReactions(
(reaction, user) => {
if (user.id === senderID) {
const isCheckReacted = reaction.emoji.name === "✅";
if (isCheckReacted || reaction.emoji.name === "❌") {
resolve(isCheckReacted);
isDeleted = true;
message.delete();
}
}
return false;
},
{time: timeout}
);
if (!isDeleted) {
message.delete();
reject("Prompt timed out.");
}
});
}
// 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,
callbackStack: (() => void)[],
timeout = 90000
) {
if (callbackStack.length > multiNumbers.length) {
message.channel.send(
`\`ERROR: The amount of callbacks in "askMultipleChoice" must not exceed the total amount of allowed options (${multiNumbers.length})!\``
);
return;
}
let isDeleted = false;
for (let i = 0; i < callbackStack.length; i++) {
await message.react(multiNumbers[i]);
}
await message.awaitReactions(
(reaction, user) => {
if (user.id === senderID) {
const index = multiNumbers.indexOf(reaction.emoji.name);
if (index !== -1) {
callbackStack[index]();
isDeleted = true;
message.delete();
}
}
return false;
},
{time: timeout}
);
if (!isDeleted) message.delete();
}
/**
* Gets a user by their username. Gets the first one then rolls with it.
*/
export async function getMemberByUsername(guild: Guild, username: string) {
return (
await guild.members.fetch({
query: username,
limit: 1
})
).first();
}
/**
* Convenience function to handle cases where someone isn't found by a username automatically.
*/
export async function callMemberByUsername(
message: Message,
username: string,
onSuccess: (member: GuildMember) => void
) {
const guild = message.guild;
const send = message.channel.send;
if (guild) {
const member = await getMemberByUsername(guild, username);
if (member) onSuccess(member);
else send(`Couldn't find a user by the name of \`${username}\`!`);
} else send("You must execute this command in a server!");
}
// TO DO Section //
// getGuildByID() - checks for guild.available (boolean)
// getGuildByName()
// findMemberByNickname() - gets a member by their nickname or their username
// findUserByUsername()
// 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.

81
src/core/loader.ts Normal file
View File

@ -0,0 +1,81 @@
import {Collection} from "discord.js";
import glob from "glob";
import {Command, NamedCommand} from "./command";
// Internally, it'll keep its original capitalization. It's up to you to convert it to title case when you make a help command.
export const categories = new Collection<string, string[]>();
/** Returns the cache of the commands if it exists and searches the directory if not. */
export const loadableCommands = (async () => {
const commands = new Collection<string, Command>();
// Include all .ts files recursively in "src/commands/".
const files = await globP("src/commands/**/*.ts");
// Extract the usable parts from "src/commands/" 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.ts (for jest testing)
// - The filename cannot be the hardcoded top-level "template.ts", reserved for generating templates
const pattern = /src\/commands\/(?!template\.ts)(?!modules\/)(\w+(?:\/\w+)?)(?:test\.)?\.ts/;
const lists: {[category: string]: string[]} = {};
for (const path of files) {
const match = pattern.exec(path);
if (match) {
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"
// 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(`../commands/${commandID}`)).default as unknown;
if (command instanceof NamedCommand) {
command.name = commandName;
if (commands.has(commandName)) {
console.warn(
`Command "${commandName}" already exists! Make sure to make each command uniquely identifiable across categories!`
);
} else {
commands.set(commandName, 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(commandName);
console.log(`Loading Command: ${commandID}`);
} else {
console.warn(`Command "${commandID}" has no default export which is a NamedCommand instance!`);
}
}
}
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);
}
});
});
}

View File

@ -1,76 +1,25 @@
import {GuildMember, Permissions} from "discord.js";
import {Config} from "./structures";
import $ from "./lib";
// Contains all the permission roles and checking functions.
import {User, GuildMember} from "discord.js";
import {permissionLevels} from "./interface";
export enum PERMISSIONS {
NONE,
MOD,
ADMIN,
OWNER,
BOT_SUPPORT,
BOT_ADMIN,
BOT_OWNER
}
export const PermissionNames = [
"User",
"Moderator",
"Administrator",
"Server Owner",
"Bot Support",
"Bot Admin",
"Bot Owner"
];
// Here is where you enter in the functions that check for permissions.
const PermissionChecker: ((member: GuildMember) => boolean)[] = [
// NONE //
() => true,
// MOD //
(member) =>
member.hasPermission(Permissions.FLAGS.MANAGE_ROLES) ||
member.hasPermission(Permissions.FLAGS.MANAGE_MESSAGES) ||
member.hasPermission(Permissions.FLAGS.KICK_MEMBERS) ||
member.hasPermission(Permissions.FLAGS.BAN_MEMBERS),
// ADMIN //
(member) => member.hasPermission(Permissions.FLAGS.ADMINISTRATOR),
// OWNER //
(member) => member.guild.ownerID === member.id,
// BOT_SUPPORT //
(member) => Config.support.includes(member.id),
// BOT_ADMIN //
(member) => Config.admins.includes(member.id),
// BOT_OWNER //
(member) => Config.owner === member.id
];
// After checking the lengths of these three objects, use this as the length for consistency.
const length = Object.keys(PERMISSIONS).length / 2;
export function hasPermission(member: GuildMember, permission: PERMISSIONS): boolean {
for (let i = length - 1; i >= permission; i--) if (PermissionChecker[i](member)) return true;
/**
* Checks if a `Member` has a certain permission.
*/
export function hasPermission(user: User, member: GuildMember | null, permission: number): boolean {
for (let i = permissionLevels.length - 1; i >= permission; i--)
if (permissionLevels[i].check(user, member)) return true;
return false;
}
export function getPermissionLevel(member: GuildMember): number {
for (let i = length - 1; i >= 0; i--) if (PermissionChecker[i](member)) return i;
/**
* 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;
}
// Length Checking
(() => {
const lenNames = PermissionNames.length;
const lenChecker = PermissionChecker.length;
// By transitive property, lenNames and lenChecker have to be equal to each other as well.
if (length !== lenNames || length !== lenChecker)
$.error(
`Permission object lengths aren't equal! Enum Length (${length}), Names Length (${lenNames}), and Functions Length (${lenChecker}). This WILL cause problems!`
);
})();
export function getPermissionName(level: number) {
if (level > permissionLevels.length || level < 0) return "N/A";
else return permissionLevels[level].name;
}

View File

@ -1,73 +0,0 @@
export class GenericWrapper<T> {
protected readonly value: T;
public constructor(value: T) {
this.value = value;
}
}
export class NumberWrapper extends GenericWrapper<number> {
/**
* Pluralises a word and chooses a suffix attached to the root provided.
* - pluralise("credit", "s") = credit/credits
* - pluralise("part", "ies", "y") = party/parties
* - pluralise("sheep") = sheep
*/
public pluralise(word: string, plural = "", singular = "", excludeNumber = false): string {
let result = excludeNumber ? "" : `${this.value} `;
if (this.value === 1) result += word + singular;
else result += word + plural;
return result;
}
/**
* Pluralises a word for changes.
* - (-1).pluraliseSigned() = '-1 credits'
* - (0).pluraliseSigned() = '+0 credits'
* - (1).pluraliseSigned() = '+1 credit'
*/
public pluraliseSigned(word: string, plural = "", singular = "", excludeNumber = false): string {
const sign = this.value >= 0 ? "+" : "";
return `${sign}${this.pluralise(word, plural, singular, excludeNumber)}`;
}
}
export class StringWrapper extends GenericWrapper<string> {
public replaceAll(before: string, after: string): string {
let result = this.value;
while (result.indexOf(before) !== -1) result = result.replace(before, after);
return result;
}
public toTitleCase(): string {
return this.value.replace(
/([^\W_]+[^\s-]*) */g,
(txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase()
);
}
}
export class ArrayWrapper<T> extends GenericWrapper<T[]> {
/** Returns a random element from this array. */
public random(): T {
return this.value[Math.floor(Math.random() * this.value.length)];
}
/**
* Splits up this array into a specified length.
* `$([1,2,3,4,5,6,7,8,9,10]).split(3)` = `[[1,2,3],[4,5,6],[7,8,9],[10]]`
*/
public split(lengthOfEachSection: number): T[][] {
const amountOfSections = Math.ceil(this.value.length / lengthOfEachSection);
const sections: T[][] = new Array(amountOfSections);
for (let index = 0; index < amountOfSections; index++)
sections[index] = this.value.slice(index * lengthOfEachSection, (index + 1) * lengthOfEachSection);
return sections;
}
}

View File

@ -1,14 +0,0 @@
import Event from "../core/event";
import {client} from "../index";
import $ from "../core/lib";
import * as discord from "discord.js";
export default new Event<"channelCreate">({
async on(channel) {
const botGuilds = client.guilds;
if (channel instanceof discord.GuildChannel) {
const createdGuild = await botGuilds.fetch(channel.guild.id);
$.log(`Channel created in '${createdGuild.name}' called '#${channel.name}'`);
}
}
});

View File

@ -1,14 +0,0 @@
import Event from "../core/event";
import {client} from "../index";
import $ from "../core/lib";
import * as discord from "discord.js";
export default new Event<"channelDelete">({
async on(channel) {
const botGuilds = client.guilds;
if (channel instanceof discord.GuildChannel) {
const createdGuild = await botGuilds.fetch(channel.guild.id);
$.log(`Channel deleted in '${createdGuild.name}' called '#${channel.name}'`);
}
}
});

View File

@ -1,14 +0,0 @@
import Event from "../core/event";
import {streamList} from "./voiceStateUpdate";
export default new Event<"channelUpdate">({
async on(before, after) {
if (before.type === "voice" && after.type === "voice") {
for (const stream of streamList.values()) {
if (after.id === stream.channel.id) {
stream.update();
}
}
}
}
});

View File

@ -1,10 +0,0 @@
import Event from "../core/event";
import $ from "../core/lib";
import {updateGlobalEmoteRegistry} from "../core/lib";
export default new Event<"emojiCreate">({
on(emote) {
$.log(`Updated emote registry. ${emote.name}`);
updateGlobalEmoteRegistry();
}
});

View File

@ -1,10 +0,0 @@
import Event from "../core/event";
import $ from "../core/lib";
import {updateGlobalEmoteRegistry} from "../core/lib";
export default new Event<"emojiDelete">({
on() {
$.log("Updated emote registry.");
updateGlobalEmoteRegistry();
}
});

View File

@ -1,10 +0,0 @@
import Event from "../core/event";
import $ from "../core/lib";
import {updateGlobalEmoteRegistry} from "../core/lib";
export default new Event<"emojiUpdate">({
on() {
$.log("Updated emote registry.");
updateGlobalEmoteRegistry();
}
});

View File

@ -1,32 +0,0 @@
import Event from "../core/event";
import $ from "../core/lib";
import {updateGlobalEmoteRegistry} from "../core/lib";
import {client} from "../index";
import {Config} from "../core/structures";
import {TextChannel} from "discord.js";
export default new Event<"guildCreate">({
on(guild) {
$.log(
`[GUILD JOIN] ${guild.name} (${guild.id}) added the bot. Owner: ${guild.owner!.user.tag} (${
guild.owner!.user.id
}). Updated emote registry.`
);
if (Config.systemLogsChannel) {
const channel = client.channels.cache.get(Config.systemLogsChannel);
if (channel && channel.type === "text") {
(channel as TextChannel).send(
`TravBot joined: \`${guild.name}\`. The owner of this guild is: \`${guild.owner!.user.tag}\` (\`${
guild.owner!.user.id
}\`)`
);
} else {
console.warn(`${Config.systemLogsChannel} is not a valid text channel for system logs!`);
}
}
updateGlobalEmoteRegistry();
}
});

View File

@ -1,24 +0,0 @@
import Event from "../core/event";
import $ from "../core/lib";
import {updateGlobalEmoteRegistry} from "../core/lib";
import {client} from "../index";
import {Config} from "../core/structures";
import {TextChannel} from "discord.js";
export default new Event<"guildDelete">({
on(guild) {
$.log(`[GUILD LEAVE] ${guild.name} (${guild.id}) removed the bot. Updated emote registry.`);
if (Config.systemLogsChannel) {
const channel = client.channels.cache.get(Config.systemLogsChannel);
if (channel && channel.type === "text") {
(channel as TextChannel).send(`\`${guild.name}\` (\`${guild.id}\`) removed the bot.`);
} else {
console.warn(`${Config.systemLogsChannel} is not a valid text channel for system logs!`);
}
}
updateGlobalEmoteRegistry();
}
});

View File

@ -1,77 +0,0 @@
import Event from "../core/event";
import $, {parseVars} from "../core/lib";
import {createCanvas, loadImage, Canvas} from "canvas";
import {Storage} from "../core/structures";
import {TextChannel, MessageAttachment} from "discord.js";
function applyText(canvas: Canvas, text: string) {
const ctx = canvas.getContext("2d");
let fontSize = 70;
do {
ctx.font = `${(fontSize -= 10)}px sans-serif`;
} while (ctx.measureText(text).width > canvas.width - 300);
return ctx.font;
}
export default new Event<"guildMemberAdd">({
async on(member) {
const {welcomeType, welcomeChannel, welcomeMessage} = Storage.getGuild(member.guild.id);
if (welcomeChannel) {
const channel = member.guild.channels.cache.get(welcomeChannel);
if (channel && channel.type === "text") {
if (welcomeType === "graphical") {
const canvas = createCanvas(700, 250);
const ctx = canvas.getContext("2d");
const background = await loadImage(
"https://raw.githubusercontent.com/keanuplayz/TravBot/dev/assets/wallpaper.png"
);
ctx.drawImage(background, 0, 0, canvas.width, canvas.height);
ctx.strokeStyle = "#74037b";
ctx.strokeRect(0, 0, canvas.width, canvas.height);
ctx.font = "28px sans-serif";
ctx.fillStyle = "#ffffff";
ctx.fillText("Welcome to the server,", canvas.width / 2.5, canvas.height / 3.5);
ctx.font = applyText(canvas, member.displayName);
ctx.fillStyle = "#ffffff";
ctx.fillText(`${member.displayName}!`, canvas.width / 2.5, canvas.height / 1.5);
ctx.beginPath();
ctx.arc(125, 125, 100, 0, Math.PI * 2, true);
ctx.closePath();
ctx.clip();
const avatarURL =
member.user.avatarURL({
dynamic: true,
size: 2048,
format: "png"
}) ?? member.user.defaultAvatarURL;
const avatar = await loadImage(avatarURL);
ctx.drawImage(avatar, 25, 25, 200, 200);
const attachment = new MessageAttachment(canvas.toBuffer("image/png"), "welcome-image.png");
(channel as TextChannel).send(`Welcome \`${member.user.tag}\`!`, attachment);
} else if (welcomeType === "text") {
(channel as TextChannel).send(
parseVars(
welcomeMessage ||
"Say hello to `%user%`, everyone! We all need a warm welcome sometimes :D",
{
user: member.user.tag
}
)
);
}
} else {
$.error(`"${welcomeChannel}" is not a valid text channel ID!`);
}
}
}
});

View File

@ -1,154 +0,0 @@
import Event from "../core/event";
import Command, {loadCommands} from "../core/command";
import {hasPermission, getPermissionLevel, PermissionNames} from "../core/permissions";
import {Permissions, Collection} from "discord.js";
import {getPrefix} from "../core/structures";
import $, {replyEventListeners} from "../core/lib";
import quote from "../modules/message_embed";
// It's a rather hacky solution, but since there's no top-level await, I just have to make the loading conditional.
let commands: Collection<string, Command> | null = null;
export default new Event<"message">({
async on(message) {
if (message.content.toLowerCase().includes("remember to drink water")) {
message.react("🚱");
}
// Load commands if it hasn't already done so. Luckily, it's called once at most.
if (!commands) commands = await loadCommands();
// Message Setup //
if (message.author.bot) return;
// 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);
}
let prefix = getPrefix(message.guild);
const originalPrefix = prefix;
let exitEarly = !message.content.startsWith(prefix);
const clientUser = message.client.user;
let usesBotSpecificPrefix = false;
if (!message.content.startsWith(prefix)) {
return quote(message);
}
// If the client user exists, check if it starts with the bot-specific prefix.
if (clientUser) {
// If the prefix starts with the bot-specific prefix, go off that instead (these two options must mutually exclude each other).
// The pattern here has an optional space at the end to capture that and make it not mess with the header and args.
const matches = message.content.match(new RegExp(`^<@!?${clientUser.id}> ?`));
if (matches) {
prefix = matches[0];
exitEarly = false;
usesBotSpecificPrefix = true;
}
}
// If it doesn't start with the current normal prefix or the bot-specific unique prefix, exit the thread of execution early.
// Inline replies should still be captured here because if it doesn't exit early, two characters for a two-length prefix would still trigger commands.
if (exitEarly) return;
const [header, ...args] = message.content.substring(prefix.length).split(/ +/);
// If the message is just the prefix itself, move onto this block.
if (header === "" && args.length === 0) {
// I moved the bot-specific prefix to a separate conditional block to separate the logic.
// And because it listens for the mention as a prefix instead of a free-form mention, inline replies (probably) shouldn't ever trigger this unintentionally.
if (usesBotSpecificPrefix) {
message.channel.send(`${message.author.toString()}, my prefix on this guild is \`${originalPrefix}\`.`);
return;
}
}
if (!commands.has(header)) return;
if (
message.channel.type === "text" &&
!message.channel.permissionsFor(message.client.user || "")?.has(Permissions.FLAGS.SEND_MESSAGES)
) {
let status;
if (message.member?.hasPermission(Permissions.FLAGS.ADMINISTRATOR))
status =
"Because you're a server admin, you have the ability to change that channel's permissions to match if that's what you intended.";
else
status =
"Try using a different channel or contacting a server admin to change permissions of that channel if you think something's wrong.";
return message.author.send(
`I don't have permission to send messages in ${message.channel.toString()}. ${status}`
);
}
$.log(
`${message.author.username}#${message.author.discriminator} executed the command "${header}" with arguments "${args}".`
);
// Subcommand Recursion //
let command = commands.get(header);
if (!command) return $.warn(`Command "${header}" was called but for some reason it's still undefined!`);
const params: any[] = [];
let isEndpoint = false;
let permLevel = command.permission ?? Command.PERMISSIONS.NONE;
for (let param of args) {
if (command.endpoint) {
if (command.subcommands.size > 0 || command.user || command.number || command.any)
$.warn(`An endpoint cannot have subcommands! Check ${originalPrefix}${header} again.`);
isEndpoint = true;
break;
}
const type = command.resolve(param);
command = command.get(param);
permLevel = command.permission ?? permLevel;
if (type === Command.TYPES.USER) {
const id = param.match(/\d+/g)![0];
try {
params.push(await message.client.users.fetch(id));
} catch (error) {
return message.channel.send(`No user found by the ID \`${id}\`!`);
}
} else if (type === Command.TYPES.NUMBER) params.push(Number(param));
else if (type !== Command.TYPES.SUBCOMMAND) params.push(param);
}
if (!message.member)
return $.warn("This command was likely called from a DM channel meaning the member object is null.");
if (!hasPermission(message.member, permLevel)) {
const userPermLevel = getPermissionLevel(message.member);
return message.channel.send(
`You don't have access to this command! Your permission level is \`${PermissionNames[userPermLevel]}\` (${userPermLevel}), but this command requires a permission level of \`${PermissionNames[permLevel]}\` (${permLevel}).`
);
}
if (isEndpoint) return message.channel.send("Too many arguments!");
// Execute with dynamic library attached. //
// The purpose of using $.bind($) is to clone the function so as to not modify the original $.
// The cloned function doesn't copy the properties, so Object.assign() is used.
// Object.assign() modifies the first element and returns that, the second element applies its properties and the third element applies its own overriding the second one.
command.execute(
Object.assign(
$.bind($),
{
args: params,
author: message.author,
channel: message.channel,
client: message.client,
guild: message.guild,
member: message.member,
message: message
},
$
)
);
}
});

View File

@ -1,18 +0,0 @@
import Event from "../core/event";
import {Permissions} from "discord.js";
import {botHasPermission} from "../core/lib";
// A list of message ID and callback pairs. You get the emote name and ID of the user reacting.
export const eventListeners: Map<string, (emote: string, id: string) => void> = new Map();
// Attached to the client, there can be one event listener attached to a message ID which is executed if present.
export default new Event<"messageReactionRemove">({
on(reaction, user) {
const canDeleteEmotes = botHasPermission(reaction.message.guild, Permissions.FLAGS.MANAGE_MESSAGES);
if (!canDeleteEmotes) {
const callback = eventListeners.get(reaction.message.id);
callback && callback(reaction.emoji.name, user.id);
}
}
});

View File

@ -1,32 +0,0 @@
import Event from "../core/event";
import {client} from "../index";
import $ from "../core/lib";
import {Config, Storage} from "../core/structures";
import {updateGlobalEmoteRegistry} from "../core/lib";
export default new Event<"ready">({
once() {
if (client.user) {
$.ready(
`Logged in as ${client.user.tag}, ready to serve ${client.users.cache.size} users in ${client.guilds.cache.size} servers..`
);
client.user.setActivity({
type: "LISTENING",
name: `${Config.prefix}help`
});
}
updateGlobalEmoteRegistry();
// Run this setup block once to restore eco bet money in case the bot went down. (And I guess search the client for those users to let them know too.)
for (const id in Storage.users) {
const user = Storage.users[id];
if(user.ecoBetInsurance > 0) {
client.users.cache.get(id)?.send(`Because my system either crashed or restarted while you had a pending bet, the total amount of money that you bet, which was \`${user.ecoBetInsurance}\`, has been restored.`);
user.money += user.ecoBetInsurance;
user.ecoBetInsurance = 0;
}
}
Storage.save();
}
});

View File

@ -1,78 +0,0 @@
import {GuildMember, VoiceChannel, MessageEmbed, TextChannel, Permissions, Message, Collection} from "discord.js";
import Event from "../core/event";
import $ from "../core/lib";
import {Storage} from "../core/structures";
import {client} from "../index";
type Stream = {
streamer: GuildMember;
channel: VoiceChannel;
description?: string;
message: Message;
update: () => void;
};
// A list of user IDs and message embeds.
export const streamList = new Collection<string, Stream>();
// Probably find a better, DRY way of doing this.
function getStreamEmbed(streamer: GuildMember, channel: VoiceChannel, description?: string): MessageEmbed {
const user = streamer.user;
const embed = new MessageEmbed()
.setTitle(`Stream: \`#${channel.name}\``)
.setAuthor(
streamer.nickname ?? user.username,
user.avatarURL({
dynamic: true,
format: "png"
}) ?? user.defaultAvatarURL
)
.setColor(streamer.displayColor);
if (description) {
embed.setDescription(description);
}
return embed;
}
export default new Event<"voiceStateUpdate">({
async on(before, after) {
const isStartStreamEvent = !before.streaming && after.streaming;
const isStopStreamEvent = before.streaming && (!after.streaming || !after.channel); // If you were streaming before but now are either not streaming or have left the channel.
// Note: isStopStreamEvent can be called twice in a row - If Discord crashes/quits while you're streaming, it'll call once with a null channel and a second time with a channel.
if (isStartStreamEvent || isStopStreamEvent) {
const {streamingChannel} = Storage.getGuild(after.guild.id);
if (streamingChannel) {
const member = after.member!;
const voiceChannel = after.channel!;
const textChannel = client.channels.cache.get(streamingChannel);
if (textChannel instanceof TextChannel) {
if (isStartStreamEvent) {
streamList.set(member.id, {
streamer: member,
channel: voiceChannel,
message: await textChannel.send(getStreamEmbed(member, voiceChannel)),
update(this: Stream) {
this.message.edit(getStreamEmbed(this.streamer, this.channel, this.description));
}
});
} else if (isStopStreamEvent) {
if (streamList.has(member.id)) {
const {message} = streamList.get(member.id)!;
message.delete();
streamList.delete(member.id);
}
}
} else {
$.error(
`The streaming notifications channel ${streamingChannel} for guild ${after.guild.id} either doesn't exist or isn't a text channel.`
);
}
}
}
}
});

View File

@ -1,66 +1,73 @@
import * as discord from "discord.js";
import setup from "./setup";
import {Config} from "./core/structures";
import {loadCommands} from "./core/command";
import {loadEvents} from "./core/event";
import "discord.js-lavalink-lib";
import LavalinkMusic from "discord.js-lavalink-lib";
declare module "discord.js" {
interface Presence {
patch(data: any): void;
}
}
// The terrible hacks were written by none other than The Noble Programmer On The White PC.
// NOTE: Terrible hack ahead!!! In order to reduce the memory usage of the bot
// we only store the information from presences that we actually end up using,
// which currently is only the (online/idle/dnd/offline/...) status (see
// `src/commands/info.ts`). What data is retrieved from the `data` object
// (which contains the data received from the Gateway) and how can be seen
// here:
// <https://github.com/discordjs/discord.js/blob/cee6cf70ce76e9b06dc7f25bfd77498e18d7c8d4/src/structures/Presence.js#L81-L110>.
const oldPresencePatch = discord.Presence.prototype.patch;
discord.Presence.prototype.patch = function patch(data: any) {
oldPresencePatch.call(this, {status: data.status});
};
// Bootstrapping Section //
import "./modules/globals";
import {Client, Permissions} from "discord.js";
import {launch} from "./core";
import setup from "./modules/setup";
import {Config, getPrefix} from "./structures";
// This is here in order to make it much less of a headache to access the client from other files.
// This of course won't actually do anything until the setup process is complete and it logs in.
export const client = new discord.Client();
export const client = new Client();
// NOTE: Terrible hack continued!!! Unfortunately we can't receive the presence
// data at all when the GUILD_PRESENCES intent is disabled, so while we do
// waste network bandwidth and the CPU time for decoding the incoming packets,
// the function which handles those packets is NOP-ed out, which, among other
// things, skips the code which caches the referenced users in the packet. See
// <https://github.com/discordjs/discord.js/blob/cee6cf70ce76e9b06dc7f25bfd77498e18d7c8d4/src/client/actions/PresenceUpdate.js#L7-L41>.
(client["actions"] as any)["PresenceUpdate"].handle = () => {};
(client as any).music = LavalinkMusic(client, {
lavalink: {
restnode: {
host: "localhost",
port: 2333,
password: "youshallnotpass"
},
nodes: [
{
host: "localhost",
port: 2333,
password: "youshallnotpass"
}
]
},
prefix: "!!",
helpCmd: "mhelp",
admins: ["717352467280691331"]
});
// Begin the command loading here rather than when it's needed like in the message event.
// Send the login request to Discord's API and then load modules while waiting for it.
setup.init().then(() => {
loadCommands();
loadEvents(client);
client.login(Config.token).catch(setup.again);
});
// Setup the command handler.
launch(client, {
permissionLevels: [
{
// NONE //
name: "User",
check: () => true
},
{
// MOD //
name: "Moderator",
check: (_user, member) =>
!!member &&
(member.hasPermission(Permissions.FLAGS.MANAGE_ROLES) ||
member.hasPermission(Permissions.FLAGS.MANAGE_MESSAGES) ||
member.hasPermission(Permissions.FLAGS.KICK_MEMBERS) ||
member.hasPermission(Permissions.FLAGS.BAN_MEMBERS))
},
{
// ADMIN //
name: "Administrator",
check: (_user, member) => !!member && member.hasPermission(Permissions.FLAGS.ADMINISTRATOR)
},
{
// OWNER //
name: "Server Owner",
check: (_user, member) => !!member && member.guild.ownerID === member.id
},
{
// BOT_SUPPORT //
name: "Bot Support",
check: (user) => Config.support.includes(user.id)
},
{
// BOT_ADMIN //
name: "Bot Admin",
check: (user) => Config.admins.includes(user.id)
},
{
// BOT_OWNER //
name: "Bot Owner",
check: (user) => Config.owner === user.id
}
],
getPrefix: getPrefix
});
// Initialize Modules //
import "./modules/ready";
import "./modules/presence";
import "./modules/lavalink";
import "./modules/emoteRegistry";
import "./modules/systemInfo";
import "./modules/intercept";
import "./modules/messageEmbed";
import "./modules/guildMemberAdd";
import "./modules/streamNotifications";

View File

@ -1,46 +1,46 @@
import {strict as assert} from "assert";
import {NumberWrapper, StringWrapper, ArrayWrapper} from "../src/core/wrappers";
import {pluralise, pluraliseSigned, replaceAll, toTitleCase, split} from "./lib";
// I can't figure out a way to run the test suite while running the bot.
describe("Wrappers", () => {
describe("NumberWrapper", () => {
describe("#pluralise()", () => {
it('should return "5 credits"', () => {
assert.strictEqual(new NumberWrapper(5).pluralise("credit", "s"), "5 credits");
assert.strictEqual(pluralise(5, "credit", "s"), "5 credits");
});
it('should return "1 credit"', () => {
assert.strictEqual(new NumberWrapper(1).pluralise("credit", "s"), "1 credit");
assert.strictEqual(pluralise(1, "credit", "s"), "1 credit");
});
it('should return "-1 credits"', () => {
assert.strictEqual(new NumberWrapper(-1).pluralise("credit", "s"), "-1 credits");
assert.strictEqual(pluralise(-1, "credit", "s"), "-1 credits");
});
it("should be able to work with a plural suffix", () => {
assert.strictEqual(new NumberWrapper(2).pluralise("part", "ies", "y"), "2 parties");
assert.strictEqual(pluralise(2, "part", "ies", "y"), "2 parties");
});
it("should be able to work with a singular suffix", () => {
assert.strictEqual(new NumberWrapper(1).pluralise("part", "ies", "y"), "1 party");
assert.strictEqual(pluralise(1, "part", "ies", "y"), "1 party");
});
it("should be able to exclude the number", () => {
assert.strictEqual(new NumberWrapper(1).pluralise("credit", "s", "", true), "credit");
assert.strictEqual(pluralise(1, "credit", "s", "", true), "credit");
});
});
describe("#pluraliseSigned()", () => {
it('should return "-1 credits"', () => {
assert.strictEqual(new NumberWrapper(-1).pluraliseSigned("credit", "s"), "-1 credits");
assert.strictEqual(pluraliseSigned(-1, "credit", "s"), "-1 credits");
});
it('should return "+0 credits"', () => {
assert.strictEqual(new NumberWrapper(0).pluraliseSigned("credit", "s"), "+0 credits");
assert.strictEqual(pluraliseSigned(0, "credit", "s"), "+0 credits");
});
it('should return "+1 credit"', () => {
assert.strictEqual(new NumberWrapper(1).pluraliseSigned("credit", "s"), "+1 credit");
assert.strictEqual(pluraliseSigned(1, "credit", "s"), "+1 credit");
});
});
});
@ -48,14 +48,14 @@ describe("Wrappers", () => {
describe("StringWrapper", () => {
describe("#replaceAll()", () => {
it('should convert "test" to "zesz"', () => {
assert.strictEqual(new StringWrapper("test").replaceAll("t", "z"), "zesz");
assert.strictEqual(replaceAll("test", "t", "z"), "zesz");
});
});
describe("#toTitleCase()", () => {
it("should capitalize the first letter of each word", () => {
assert.strictEqual(
new StringWrapper("yeetus deletus find salvation from jesus").toTitleCase(),
toTitleCase("yeetus deletus find salvation from jesus"),
"Yeetus Deletus Find Salvation From Jesus"
);
});
@ -65,7 +65,7 @@ describe("Wrappers", () => {
describe("ArrayWrapper", () => {
describe("#split()", () => {
it("should split [1,2,3,4,5,6,7,8,9,10] into [[1,2,3],[4,5,6],[7,8,9],[10]]", () => {
assert.deepStrictEqual(new ArrayWrapper([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]).split(3), [
assert.deepStrictEqual(split([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 3), [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],

236
src/lib.ts Normal file
View File

@ -0,0 +1,236 @@
// Library for pure functions
import {get} from "https";
import FileManager from "./modules/storage";
/**
* Splits a command by spaces while accounting for quotes which capture string arguments.
* - `\"` = `"`
* - `\\` = `\`
*/
export function parseArgs(line: string): string[] {
let result = [];
let selection = "";
let inString = false;
let isEscaped = false;
for (let c of line) {
if (isEscaped) {
if (['"', "\\"].includes(c)) selection += c;
else selection += "\\" + c;
isEscaped = false;
} else if (c === "\\") isEscaped = true;
else if (c === '"') inString = !inString;
else if (c === " " && !inString) {
result.push(selection);
selection = "";
} else selection += c;
}
if (selection.length > 0) result.push(selection);
return result;
}
/**
* Allows you to store a template string with variable markers and parse it later.
* - Use `%name%` for variables
* - `%%` = `%`
* - If the invalid token is null/undefined, nothing is changed.
*/
export function parseVars(line: string, definitions: {[key: string]: string}, invalid: string | null = ""): string {
let result = "";
let inVariable = false;
let token = "";
for (const c of line) {
if (c === "%") {
if (inVariable) {
if (token === "") result += "%";
else {
if (token in definitions) result += definitions[token];
else if (invalid === null) result += `%${token}%`;
else result += invalid;
token = "";
}
}
inVariable = !inVariable;
} else if (inVariable) token += c;
else result += c;
}
return result;
}
export function isType(value: any, type: any): boolean {
if (value === undefined && type === undefined) return true;
else if (value === null && type === null) return true;
else return value !== undefined && value !== null && value.constructor === type;
}
/**
* Checks a value to see if it matches the fallback's type, otherwise returns the fallback.
* For the purposes of the templates system, this function will only check array types, objects should be checked under their own type (as you'd do anyway with something like a User object).
* If at any point the value doesn't match the data structure provided, the fallback is returned.
* Warning: Type checking is based on the fallback's type. Be sure that the "type" parameter is accurate to this!
*/
export function select<T>(value: any, fallback: T, type: Function, isArray = false): T {
if (isArray && isType(value, Array)) {
for (let item of value) if (!isType(item, type)) return fallback;
return value;
} else {
if (isType(value, type)) return value;
else return fallback;
}
}
export function clean(text: any) {
if (typeof text === "string")
return text.replace(/`/g, "`" + String.fromCharCode(8203)).replace(/@/g, "@" + String.fromCharCode(8203));
else return text;
}
export function trimArray(arr: any, maxLen = 10) {
if (arr.length > maxLen) {
const len = arr.length - maxLen;
arr = arr.slice(0, maxLen);
arr.push(`${len} more...`);
}
return arr;
}
export function formatBytes(bytes: any) {
if (bytes === 0) return "0 Bytes";
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${parseFloat((bytes / Math.pow(1024, i)).toFixed(2))} ${sizes[i]}`;
}
export function getContent(url: string): Promise<{url: string}> {
return new Promise((resolve, reject) => {
get(url, (res) => {
const {statusCode} = res;
if (statusCode !== 200) {
res.resume();
reject(`Request failed. Status code: ${statusCode}`);
}
res.setEncoding("utf8");
let rawData = "";
res.on("data", (chunk: string) => {
rawData += chunk;
});
res.on("end", () => {
try {
const parsedData = JSON.parse(rawData);
resolve(parsedData);
} catch (e) {
reject(`Error: ${e.message}`);
}
});
}).on("error", (err: {message: any}) => {
reject(`Error: ${err.message}`);
});
});
}
export interface GenericJSON {
[key: string]: any;
}
export abstract class GenericStructure {
private __meta__ = "generic";
constructor(tag?: string) {
this.__meta__ = tag || this.__meta__;
}
public save(asynchronous = true) {
const tag = this.__meta__;
/// @ts-ignore
delete this.__meta__;
FileManager.write(tag, this, asynchronous);
this.__meta__ = tag;
}
}
// A 50% chance would be "Math.random() < 0.5" because Math.random() can be [0, 1), so to make two equal ranges, you'd need [0, 0.5)U[0.5, 1).
// Similar logic would follow for any other percentage. Math.random() < 1 is always true (100% chance) and Math.random() < 0 is always false (0% chance).
export const Random = {
num: (min: number, max: number) => Math.random() * (max - min) + min,
int: (min: number, max: number) => Math.floor(Random.num(min, max)),
chance: (decimal: number) => Math.random() < decimal,
sign: (number = 1) => number * (Random.chance(0.5) ? -1 : 1),
deviation: (base: number, deviation: number) => Random.num(base - deviation, base + deviation)
};
/**
* Pluralises a word and chooses a suffix attached to the root provided.
* - pluralise("credit", "s") = credit/credits
* - pluralise("part", "ies", "y") = party/parties
* - pluralise("sheep") = sheep
*/
export function pluralise(value: number, word: string, plural = "", singular = "", excludeNumber = false): string {
let result = excludeNumber ? "" : `${value} `;
if (value === 1) result += word + singular;
else result += word + plural;
return result;
}
/**
* Pluralises a word for changes.
* - (-1).pluraliseSigned() = '-1 credits'
* - (0).pluraliseSigned() = '+0 credits'
* - (1).pluraliseSigned() = '+1 credit'
*/
export function pluraliseSigned(
value: number,
word: string,
plural = "",
singular = "",
excludeNumber = false
): string {
const sign = value >= 0 ? "+" : "";
return `${sign}${pluralise(value, word, plural, singular, excludeNumber)}`;
}
export function replaceAll(text: string, before: string, after: string): string {
while (text.indexOf(before) !== -1) text = text.replace(before, after);
return text;
}
export function toTitleCase(text: string): string {
return text.replace(/([^\W_]+[^\s-]*) */g, (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase());
}
/** Returns a random element from this array. */
export function random<T>(array: T[]): T {
return array[Math.floor(Math.random() * array.length)];
}
/**
* Splits up this array into a specified length.
* `$([1,2,3,4,5,6,7,8,9,10]).split(3)` = `[[1,2,3],[4,5,6],[7,8,9],[10]]`
*/
export function split<T>(array: T[], lengthOfEachSection: number): T[][] {
const amountOfSections = Math.ceil(array.length / lengthOfEachSection);
const sections = new Array<T[]>(amountOfSections);
for (let index = 0; index < amountOfSections; index++)
sections[index] = array.slice(index * lengthOfEachSection, (index + 1) * lengthOfEachSection);
return sections;
}
/**
* Utility function to require all possible cases to be handled at compile time.
*
* To use this function, place it in the "default" case of a switch statement or the "else" statement of an if-else branch.
* If all cases are handled, the variable being tested for should be of type "never", and if it isn't, that means not all cases are handled yet.
*/
export function requireAllCasesHandledFor(variable: never): never {
throw new Error(`This function should never be called but got the value: ${variable}`);
}

View File

@ -0,0 +1,53 @@
import {client} from "../index";
import FileManager from "./storage";
import {EmoteRegistryDump} from "../structures";
function updateGlobalEmoteRegistry(): void {
const data: EmoteRegistryDump = {version: 1, list: []};
for (const guild of client.guilds.cache.values()) {
for (const emote of guild.emojis.cache.values()) {
data.list.push({
ref: emote.name,
id: emote.id,
name: emote.name,
requires_colons: emote.requiresColons || false,
animated: emote.animated,
url: emote.url,
guild_id: emote.guild.name,
guild_name: emote.guild.name
});
}
}
FileManager.write("emote-registry", data, true);
}
client.on("emojiCreate", (emote) => {
console.log(`Updated emote registry. ${emote.name}`);
updateGlobalEmoteRegistry();
});
client.on("emojiDelete", () => {
console.log("Updated emote registry.");
updateGlobalEmoteRegistry();
});
client.on("emojiUpdate", () => {
console.log("Updated emote registry.");
updateGlobalEmoteRegistry();
});
client.on("guildCreate", () => {
console.log("Updated emote registry.");
updateGlobalEmoteRegistry();
});
client.on("guildDelete", () => {
console.log("Updated emote registry.");
updateGlobalEmoteRegistry();
});
client.on("ready", () => {
updateGlobalEmoteRegistry();
});

98
src/modules/globals.ts Normal file
View File

@ -0,0 +1,98 @@
import chalk from "chalk";
declare global {
var IS_DEV_MODE: boolean;
var PERMISSIONS: typeof PermissionsEnum;
interface Console {
ready: (...data: any[]) => void;
}
}
enum PermissionsEnum {
NONE,
MOD,
ADMIN,
OWNER,
BOT_SUPPORT,
BOT_ADMIN,
BOT_OWNER
}
global.IS_DEV_MODE = process.argv[2] === "dev";
global.PERMISSIONS = PermissionsEnum;
const oldConsole = console;
export const logs: {[type: string]: string} = {
error: "",
warn: "",
info: "",
verbose: ""
};
function formatTimestamp(now = new Date()) {
const year = now.getFullYear();
const month = (now.getMonth() + 1).toString().padStart(2, "0");
const day = now.getDate().toString().padStart(2, "0");
const hour = now.getHours().toString().padStart(2, "0");
const minute = now.getMinutes().toString().padStart(2, "0");
const second = now.getSeconds().toString().padStart(2, "0");
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
}
function formatUTCTimestamp(now = new Date()) {
const year = now.getUTCFullYear();
const month = (now.getUTCMonth() + 1).toString().padStart(2, "0");
const day = now.getUTCDate().toString().padStart(2, "0");
const hour = now.getUTCHours().toString().padStart(2, "0");
const minute = now.getUTCMinutes().toString().padStart(2, "0");
const second = now.getUTCSeconds().toString().padStart(2, "0");
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
}
// The custom console. In order of verbosity, error, warn, log, and debug. Ready is a variation of log.
console = {
...oldConsole,
// General Purpose Logger
log(...args: any[]) {
oldConsole.log(chalk.white.bgGray(formatTimestamp()), chalk.black.bgWhite("INFO"), ...args);
const text = `[${formatUTCTimestamp()}] [INFO] ${args.join(" ")}\n`;
logs.info += text;
logs.verbose += text;
},
// "It'll still work, but you should really check up on this."
warn(...args: any[]) {
oldConsole.warn(chalk.white.bgGray(formatTimestamp()), chalk.black.bgYellow("WARN"), ...args);
const text = `[${formatUTCTimestamp()}] [WARN] ${args.join(" ")}\n`;
logs.warn += text;
logs.info += text;
logs.verbose += text;
},
// Used for anything which prevents the program from actually running.
error(...args: any[]) {
oldConsole.error(chalk.white.bgGray(formatTimestamp()), chalk.white.bgRed("ERROR"), ...args);
const text = `[${formatUTCTimestamp()}] [ERROR] ${args.join(" ")}\n`;
logs.error += text;
logs.warn += text;
logs.info += text;
logs.verbose += text;
},
// Be as verbose as possible. If anything might help when debugging an error, then include it. This only shows in your console if you run this with "dev", but you can still get it from "logs.verbose".
// $.debug(`core/lib::parseArgs("testing \"in progress\"") = ["testing", "in progress"]`) --> <path>/::(<object>.)<function>(<args>) = <value>
// Would probably be more suited for debugging program logic rather than function logic, which can be checked using unit tests.
debug(...args: any[]) {
if (IS_DEV_MODE) oldConsole.debug(chalk.white.bgGray(formatTimestamp()), chalk.white.bgBlue("DEBUG"), ...args);
const text = `[${formatUTCTimestamp()}] [DEBUG] ${args.join(" ")}\n`;
logs.verbose += text;
},
// Used once at the start of the program when the bot loads.
ready(...args: any[]) {
oldConsole.log(chalk.white.bgGray(formatTimestamp()), chalk.black.bgGreen("READY"), ...args);
const text = `[${formatUTCTimestamp()}] [READY] ${args.join(" ")}\n`;
logs.info += text;
logs.verbose += text;
}
};
console.log("Loading globals...");

View File

@ -0,0 +1,74 @@
import {createCanvas, loadImage, Canvas} from "canvas";
import {TextChannel, MessageAttachment} from "discord.js";
import {parseVars} from "../lib";
import {Storage} from "../structures";
import {client} from "../index";
function applyText(canvas: Canvas, text: string) {
const ctx = canvas.getContext("2d");
let fontSize = 70;
do {
ctx.font = `${(fontSize -= 10)}px sans-serif`;
} while (ctx.measureText(text).width > canvas.width - 300);
return ctx.font;
}
client.on("guildMemberAdd", async (member) => {
const {welcomeType, welcomeChannel, welcomeMessage} = Storage.getGuild(member.guild.id);
if (welcomeChannel) {
const channel = member.guild.channels.cache.get(welcomeChannel);
if (channel && channel.type === "text") {
if (welcomeType === "graphical") {
const canvas = createCanvas(700, 250);
const ctx = canvas.getContext("2d");
const background = await loadImage(
"https://raw.githubusercontent.com/keanuplayz/TravBot/dev/assets/wallpaper.png"
);
ctx.drawImage(background, 0, 0, canvas.width, canvas.height);
ctx.strokeStyle = "#74037b";
ctx.strokeRect(0, 0, canvas.width, canvas.height);
ctx.font = "28px sans-serif";
ctx.fillStyle = "#ffffff";
ctx.fillText("Welcome to the server,", canvas.width / 2.5, canvas.height / 3.5);
ctx.font = applyText(canvas, member.displayName);
ctx.fillStyle = "#ffffff";
ctx.fillText(`${member.displayName}!`, canvas.width / 2.5, canvas.height / 1.5);
ctx.beginPath();
ctx.arc(125, 125, 100, 0, Math.PI * 2, true);
ctx.closePath();
ctx.clip();
const avatarURL =
member.user.avatarURL({
dynamic: true,
size: 2048,
format: "png"
}) ?? member.user.defaultAvatarURL;
const avatar = await loadImage(avatarURL);
ctx.drawImage(avatar, 25, 25, 200, 200);
const attachment = new MessageAttachment(canvas.toBuffer("image/png"), "welcome-image.png");
(channel as TextChannel).send(`Welcome \`${member.user.tag}\`!`, attachment);
} else if (welcomeType === "text") {
(channel as TextChannel).send(
parseVars(
welcomeMessage || "Say hello to `%user%`, everyone! We all need a warm welcome sometimes :D",
{
user: member.user.tag
}
)
);
}
} else {
console.error(`"${welcomeChannel}" is not a valid text channel ID!`);
}
}
});

7
src/modules/intercept.ts Normal file
View File

@ -0,0 +1,7 @@
import {client} from "../index";
client.on("message", (message) => {
if (message.content.toLowerCase().includes("remember to drink water")) {
message.react("🚱");
}
});

48
src/modules/lavalink.ts Normal file
View File

@ -0,0 +1,48 @@
import attachClientToLavalink from "discord.js-lavalink-lib";
import {Config} from "../structures";
import {client} from "../index";
// Although the example showed to do "client.music = LavaLink(...)" and "(client as any).music = Lavalink(...)" was done to match that, nowhere in the library is client.music ever actually used nor does the function return anything. In other words, client.music is undefined and is never used.
attachClientToLavalink(client, {
lavalink: {
restnode: {
host: "localhost",
port: 2333,
password: "youshallnotpass"
},
nodes: [
{
host: "localhost",
port: 2333,
password: "youshallnotpass"
}
]
},
prefix: Config.prefix,
helpCmd: "mhelp",
admins: ["717352467280691331"]
});
// Disable the unhandledRejection listener by Lavalink because it captures every single unhandled
// rejection and adds its message with it. Then replace it with a better, more selective error handler.
for (const listener of process.listeners("unhandledRejection")) {
if (listener.toString().includes("discord.js-lavalink-musicbot")) {
process.off("unhandledRejection", listener);
}
}
process.on("unhandledRejection", (reason: any) => {
if (reason?.code === "ECONNREFUSED") {
console.error(
`[discord.js-lavalink-musicbot] Caught unhandled rejection: ${reason.stack}\nIf this is causing issues, head to the support server at https://discord.gg/dNN4azK`
);
}
});
// It's unsafe to process uncaughtException because after an uncaught exception, the system
// becomes corrupted. So disable Lavalink from adding a hook to it.
for (const listener of process.listeners("uncaughtException")) {
if (listener.toString().includes("discord.js-lavalink-musicbot")) {
process.off("uncaughtException", listener);
}
}

View File

@ -0,0 +1,55 @@
jest.useFakeTimers();
import {strict as assert} from "assert";
//import {extractFirstMessageLink} from "./messageEmbed";
/*describe("modules/messageEmbed", () => {
describe("extractFirstMessageLink()", () => {
const guildID = "802906483866631183";
const channelID = "681747101169682147"
const messageID = "996363055050949479";
const post = `channels/${guildID}/${channelID}/${messageID}`;
const commonUrl = `https://discord.com/channels/${post}`;
const combined = [guildID, channelID, messageID];
it('should return work and extract correctly on an isolated link', () => {
const result = extractFirstMessageLink(commonUrl);
assert.deepStrictEqual(result, combined);
})
it('should return work and extract correctly on a link within a message', () => {
const result = extractFirstMessageLink(`sample text${commonUrl}, more sample text`);
assert.deepStrictEqual(result, combined);
})
it('should return null on "!link"', () => {
const result = extractFirstMessageLink(`just some !${commonUrl} text`);
assert.strictEqual(result, null);
})
it('should return null on "<link>"', () => {
const result = extractFirstMessageLink(`just some <${commonUrl}> text`);
assert.strictEqual(result, null);
})
it('should return work and extract correctly on "<link"', () => {
const result = extractFirstMessageLink(`just some <${commonUrl} text`);
assert.deepStrictEqual(result, combined);
})
it('should return work and extract correctly on "link>"', () => {
const result = extractFirstMessageLink(`just some ${commonUrl}> text`);
assert.deepStrictEqual(result, combined);
})
it('should return work and extract correctly on a canary link', () => {
const result = extractFirstMessageLink(`https://canary.discord.com/${post}`);
assert.deepStrictEqual(result, combined);
})
})
});*/
describe("placeholder", () => {
it("placeholder", async () => {
assert.strictEqual(1, 1);
});
});

View File

@ -0,0 +1,67 @@
import {client} from "../index";
import {TextChannel, APIMessage, MessageEmbed} from "discord.js";
import {getPrefix} from "../structures";
import {DiscordAPIError} from "discord.js";
client.on("message", async (message) => {
// Only execute if the message is from a user and isn't a command.
if (message.content.startsWith(getPrefix(message.guild)) || message.author.bot) return;
const messageLink = extractFirstMessageLink(message.content);
if (!messageLink) return;
const [guildID, channelID, messageID] = messageLink;
try {
const channel = client.guilds.cache.get(guildID)?.channels.cache.get(channelID) as TextChannel;
const link_message = await channel.messages.fetch(messageID);
let rtmsg: string | APIMessage = "";
if (link_message.cleanContent) {
rtmsg = new APIMessage(message.channel as TextChannel, {
content: link_message.cleanContent,
disableMentions: "all",
files: link_message.attachments.array()
});
}
const embeds = [...link_message.embeds.filter((v) => v.type == "rich"), ...link_message.attachments.values()];
/// @ts-ignore
if (!link_message.cleanContent && embeds.empty) {
const Embed = new MessageEmbed().setDescription("🚫 The message is empty.");
return message.channel.send(Embed);
}
const infoEmbed = new MessageEmbed()
.setAuthor(
link_message.author.username,
link_message.author.displayAvatarURL({format: "png", dynamic: true, size: 4096})
)
.setTimestamp(link_message.createdTimestamp)
.setDescription(
`${link_message.cleanContent}\n\nSent in **${link_message.guild?.name}** | <#${link_message.channel.id}> ([link](https://discord.com/channels/${guildID}/${channelID}/${messageID}))`
);
if (link_message.attachments.size !== 0) {
const image = link_message.attachments.first();
/// @ts-ignore
infoEmbed.setImage(image.url);
}
await message.channel.send(infoEmbed);
} catch (error) {
if (error instanceof DiscordAPIError) {
message.channel.send("I don't have access to this channel, or something else went wrong.");
}
return console.error(error);
}
});
export function extractFirstMessageLink(message: string): [string, string, string] | null {
const messageLinkMatch = message.match(
/([!<])?https?:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/channels\/(\d{17,19})\/(\d{17,19})\/(\d{17,19})(>)?/
);
if (messageLinkMatch === null) return null;
const [, leftToken, guildID, channelID, messageID, rightToken] = messageLinkMatch;
// "!link" and "<link>" will cancel the embed request.
if (leftToken === "!" || (leftToken === "<" && rightToken === ">")) return null;
else return [guildID, channelID, messageID];
}

View File

@ -1,60 +0,0 @@
import { client } from '..'
import { Message, TextChannel, APIMessage, MessageEmbed } from 'discord.js'
import { getPrefix } from '../core/structures'
import { DiscordAPIError } from 'discord.js'
export default async function quote(message: Message) {
if (message.author.bot) return
// const message_link_regex = message.content.match(/(!)?https?:\/\/\w+\.com\/channels\/(\d+)\/(\d+)\/(\d+)/)
const message_link_regex = message.content.match(/([<!]?)https?:\/\/(?:ptb\.|canary\.|)discord(?:app)?\.com\/channels\/(\d+)\/(\d+)\/(\d+)(>?)/)
if (message_link_regex == null) return
const [, char, guildID, channelID, messageID] = message_link_regex
if (char || message.content.startsWith(getPrefix(message.guild))) return
try {
const channel = client.guilds.cache.get(guildID)?.channels.cache.get(channelID) as TextChannel
const link_message = await channel.messages.fetch(messageID)
let rtmsg: string | APIMessage = ''
if (link_message.cleanContent) {
rtmsg = new APIMessage(message.channel as TextChannel, {
content: link_message.cleanContent,
disableMentions: 'all',
files: link_message.attachments.array()
})
}
const embeds = [
...link_message.embeds.filter(v => v.type == 'rich'),
...link_message.attachments.values()
]
/// @ts-ignore
if (!link_message.cleanContent && embeds.empty) {
const Embed = new MessageEmbed()
.setDescription('🚫 The message is empty.')
return message.channel.send(Embed)
}
const infoEmbed = new MessageEmbed()
.setAuthor(
link_message.author.username,
link_message.author.displayAvatarURL({format: 'png', dynamic: true, size: 4096}))
.setTimestamp(link_message.createdTimestamp)
.setDescription(`${link_message.cleanContent}\n\nSent in **${link_message.guild?.name}** | <#${link_message.channel.id}> ([link](https://discord.com/channels/${guildID}/${channelID}/${messageID}))`);
if (link_message.attachments.size !== 0) {
const image = link_message.attachments.first();
/// @ts-ignore
infoEmbed.setImage(image.url);
}
await message.channel.send(infoEmbed)
} catch (error) {
if (error instanceof DiscordAPIError) {
message.channel.send("I don't have access to this channel, or something else went wrong.")
}
return console.error(error)
}
}

30
src/modules/presence.ts Normal file
View File

@ -0,0 +1,30 @@
import {Presence} from "discord.js";
import {client} from "../index";
declare module "discord.js" {
interface Presence {
patch(data: any): void;
}
}
// The terrible hacks were written by none other than The Noble Programmer On The White PC.
// NOTE: Terrible hack ahead!!! In order to reduce the memory usage of the bot
// we only store the information from presences that we actually end up using,
// which currently is only the (online/idle/dnd/offline/...) status (see
// `src/commands/info.ts`). What data is retrieved from the `data` object
// (which contains the data received from the Gateway) and how can be seen
// here:
// <https://github.com/discordjs/discord.js/blob/cee6cf70ce76e9b06dc7f25bfd77498e18d7c8d4/src/structures/Presence.js#L81-L110>.
const oldPresencePatch = Presence.prototype.patch;
Presence.prototype.patch = function patch(data: any) {
oldPresencePatch.call(this, {status: data.status});
};
// NOTE: Terrible hack continued!!! Unfortunately we can't receive the presence
// data at all when the GUILD_PRESENCES intent is disabled, so while we do
// waste network bandwidth and the CPU time for decoding the incoming packets,
// the function which handles those packets is NOP-ed out, which, among other
// things, skips the code which caches the referenced users in the packet. See
// <https://github.com/discordjs/discord.js/blob/cee6cf70ce76e9b06dc7f25bfd77498e18d7c8d4/src/client/actions/PresenceUpdate.js#L7-L41>.
(client["actions"] as any)["PresenceUpdate"].handle = () => {};

Some files were not shown because too many files have changed in this diff Show More