Compare commits

...

130 Commits

Author SHA1 Message Date
Alyxia Sother 646198c7bc
Update to OL v2.2.1 2022-04-03 12:21:11 +02:00
Alyxia Sother 7c7cd6c7cf
Fix trimmed array not being joined in .info 2022-03-29 18:58:47 +02:00
Alyxia Sother 389aa35124
Fix the emote registry (again) 2022-02-17 11:07:58 +01:00
Alyxia Sother 5fc65e3c71
revert: Fix some leftover type errors 2021-12-29 20:26:31 +00:00
Alyxia Sother 3170d85376
Fix a CRITICAL BUG in the emote dump module 2021-12-29 20:17:39 +00:00
Alyxia Sother e7cc94408b
Fix some leftover type errors 2021-12-29 20:17:21 +00:00
Alyxia Sother e7217cecb5
Update to Discord.JS v13.5.0 (OL v2.2.0) 2021-12-29 15:12:28 +01:00
smartfridge 08e9ae5218
Fix random errors 2021-12-13 00:39:32 +01:00
Alyxia Sother 69a8452574
R.I.P. .translate, may you return another day 2021-11-03 13:52:39 +01:00
Alyxia Sother 78f3490003
Fixed .calc by moving to WolframAlpha API 2021-11-03 13:45:58 +01:00
Keanu Timmermans 8093224c46
Fixed .eco embeds. 2021-11-03 12:18:26 +01:00
Keanu Timmermans 3751d01756
Fix eco not functioning whatsoever. 2021-10-31 18:51:24 +01:00
Keanu Timmermans 64dde60dab
Fixed eco user embed not sending. 2021-10-31 17:59:29 +01:00
Keanu Timmermans ea58f3d52e
Remove the .neko command, as requested by top.gg. 2021-10-31 14:54:13 +01:00
Alyxia Sother ad82aef396
[eco] Undid accidental reverts
as reported by @Hades785
2021-10-30 12:50:57 +02:00
Alyxia Sother 0e66735565
[Scripts] Added repl script 2021-10-30 12:13:45 +02:00
Alyxia Sother 80deec025d
[Meta] Updated dependencies 2021-10-30 12:13:28 +02:00
Alyxia Sother 45cb482826
Ran `npm prune --production` 2021-10-29 15:44:05 +02:00
Alyxia Sother 4056c4ac0b
Fixed some miscellaneous errors 2021-10-29 15:44:03 +02:00
Alyxia Sother 203c541025
No more admin perms in the invite link!!! 2021-10-29 15:27:52 +02:00
Alyxia Sother fbb687d3d6
Upgrade to Discord.JS v13.2.0
Co-Authored-By: Dmytro Meleshko <dmytro.meleshko@gmail.com>
2021-10-29 15:22:59 +02:00
WatDuhHekBro 36bc488757
Updated to discord.js v13 preview 2021-10-29 15:16:41 +02:00
Keanu Timmermans 8ffbc367b1
Add BOT_VERSION to globals for .info bot. 2021-09-17 22:57:21 +02:00
Alyxia Sother 52c1420508
Implement disabling of message embeds
As requested by @Juby210
2021-08-21 13:31:28 +02:00
Alyxia Sother 985db250d9
Add autorole system
See https://lists.sr.ht/~keanucode/travbot-v3/%3C20210815204227.2899-1-lexisoth2005%40gmail.com%3E/raw
2021-08-15 22:47:44 +02:00
Dmytro Meleshko 73278b7e88
get rid of Gulp for simple tasks such as deletion of the build dir 2021-07-13 18:22:53 +02:00
Dmytro Meleshko 31c68a5d09 don't use networking in the owoify command 2021-07-07 10:51:26 +02:00
WatDuhHekBro e86abbef3e
Merge pull request #43 from EL20202/master
Make .eco daily's cooldown use the new Discord timestamps
2021-07-03 18:25:46 -05:00
EL2020 2c946c8558 fixed minor oversight with timestamp 2021-07-03 18:56:53 -04:00
EL2020 3844a4929d made .eco daily's cooldown message use the new timestamps 2021-07-03 18:53:30 -04:00
Keanu Timmermans 2cb94cc6ac
Fix EL's PR. 2021-06-23 23:24:55 +02:00
WatDuhHekBro 2969dfd814
Merge pull request #42 from EL20202/master
Minor modifications to the '.eco' command
2021-06-23 00:07:44 -05:00
EL2020 ce414cb266 made .eco daily display new balance 2021-06-22 22:13:56 -04:00
EL2020 62c5dd8602 made .eco's channel lock message more clear 2021-06-22 20:04:17 -04:00
Keanu Timmermans 1330b499c8
Fix message links for long Nitro messages. 2021-06-14 11:25:11 +02:00
WatDuhHekBro 2040dbdefd
Updated node version for Docker 2021-05-21 14:42:15 -05:00
Alyxia Sother ac81b6a103
WIP: Make Lavalink optional
At the moment, there's a broken instance of the Lavalink system running.
When executing `.play`, our home-hosted `musicbot` will start playing,
but the production TravBot instance will error.

I haven't implemented this as a choice in the setup yet, that's for
another time.
Right now, all I need is for it to be optional.
2021-05-21 11:54:20 +00:00
WatDuhHekBro 1e673a3969
Added DM channel purge 2021-05-18 14:13:41 -05:00
WatDuhHekBro 180acb318c
Updated package-lock.json 2021-05-17 14:23:09 -05:00
WatDuhHekBro 9d4610249d
Added gulp auto-clean instruction 2021-05-17 14:09:11 -05:00
WatDuhHekBro 077164ed23
Merge pull request #41 from lexisother/master
Added pat command
2021-05-17 12:56:17 -05:00
Alyxia Sother 6003367a6b
Apply suggestions from code review
Co-authored-by: WatDuhHekBro <watduhhekbro@protonmail.com>
2021-05-17 19:49:10 +02:00
Alyxia Sother 58858c5d09
Added pat command 2021-05-17 17:24:54 +00:00
WatDuhHekBro f643f61f29
Cleaned up logging invocations 2021-05-08 08:32:45 -05:00
WatDuhHekBro 736070d615
Began reworking the say command 2021-05-06 09:15:31 -05:00
Keanu Timmermans e249d4b86d
Added pat shop item and increased prices. 2021-04-26 15:25:48 +02:00
Keanu Timmermans e844c61ece
Merge pull request #40 from lexisother/master
Mirror the Docker image to GitHub
2021-04-26 14:42:58 +02:00
Lexi Sother 2c674cef95
Mirror the Docker image to GitHub 2021-04-26 12:29:10 +00:00
Keanu Timmermans a44798edb1
Merge pull request #39 from Hades785/master 2021-04-26 13:24:35 +02:00
フズキ fe9a4f9d7e
Added command to set default VC name
Co-authored-by: Lexi Sother <lexisoth2005@gmail.com>
2021-04-26 13:16:37 +02:00
Keanu Timmermans f0a342faec
Move emote registry dump to "public" directory. 2021-04-22 11:45:44 +02:00
WatDuhHekBro 8c6ffb963e
Merge branch 'master' of https://github.com/keanuplayz/TravBot-v3 2021-04-21 12:39:38 -05:00
WatDuhHekBro 548969daba
Added non-pinging mention to whois 2021-04-21 12:38:52 -05:00
WatDuhHekBro 80fa59a433
Merge pull request #38 from EL20202/master
Added a few names to whois
2021-04-20 23:25:16 -05:00
WatDuhHekBro 81f6779068
Merge pull request #37 from MrHappyHam/master
Update whois.ts
2021-04-20 23:25:08 -05:00
EL2020 d2a558dff4 Added a few names to whois 2021-04-20 23:11:07 -04:00
MrHappyHam a06ec300f7
Update whois.ts 2021-04-20 20:40:21 -06:00
WatDuhHekBro 576e55fbdf
Fixed info guild short-circuiting 2021-04-20 10:50:57 -05:00
Keanu Timmermans c4b077757f
Updated dependencies. 2021-04-19 18:50:31 +02:00
WatDuhHekBro c8dadad450
Removed typescript badge and updated workflows 2021-04-18 15:26:41 -05:00
Keanu Timmermans cc50aea4de
Merge pull request #36 from keanuplayz/typescript
Merge typescript into master and set master as the default branch
2021-04-18 22:15:06 +02:00
WatDuhHekBro 8094dbd6c8
Improved searching for users by name 2021-04-17 10:21:17 -05:00
WatDuhHekBro 564a419b40
Shamelessly added myself to the whois registry 2021-04-15 18:14:21 -05:00
WatDuhHekBro b3ce4e5134 Moved command handler code to Onion Lasers 2021-04-13 07:38:52 -05:00
Keanu Timmermans 6243570eb3
Fixed bug in message collector. 2021-04-12 20:46:20 +02:00
Keanu Timmermans dd572e637d
Merge branch 'typescript' into HEAD 2021-04-12 20:43:26 +02:00
Keanu Timmermans 2dd776c86d
Added functionality to remove docs embed. 2021-04-12 20:42:16 +02:00
WatDuhHekBro 793822f3d0 Fixed help command 2021-04-12 12:46:48 -05:00
WatDuhHekBro 9bf44c160a Improved stream notifications 2021-04-12 12:43:13 -05:00
WatDuhHekBro 728f115de9 The actual GitHub actions fix (hopefully) 2021-04-12 09:08:04 -05:00
WatDuhHekBro 6361b83c05 Fixed GitHub Actions and addressed CodeQL issues 2021-04-12 09:02:19 -05:00
Keanu Timmermans 3cd05cd48c
Fixed duplicate help entries. 2021-04-12 15:43:43 +02:00
WatDuhHekBro 6ea052ae6f Small fixes and rework of poll 2021-04-12 04:07:04 -05:00
WatDuhHekBro 8142709581 Fixed help/thonk bugs and removed more unused vars 2021-04-12 02:45:35 -05:00
Keanu Timmermans 4241f57f46
Merge pull request #35 from keanuplayz/omnibus 2021-04-12 09:07:20 +02:00
WatDuhHekBro 06e122931f Merge branch 'omnibus' of https://github.com/keanuplayz/TravBot-v3 into omnibus 2021-04-11 05:46:10 -05:00
WatDuhHekBro a493536a23 Refactored paginate and added poll to library 2021-04-11 05:45:50 -05:00
Keanu Timmermans 0a265dcd5c
Removed unused run args and Command imports. 2021-04-11 11:11:21 +02:00
Keanu Timmermans 51d19d5787
Added Discord.JS Docs command. 2021-04-11 10:58:06 +02:00
WatDuhHekBro c980a182f8 Updated library functions 2021-04-11 03:02:56 -05:00
WatDuhHekBro 3798c27df9 Removed lenient command handling 2021-04-10 14:08:36 -05:00
WatDuhHekBro 15012c7d17 Reduced clunkiness of rest type and applied changes to commands 2021-04-10 12:07:55 -05:00
WatDuhHekBro 26e0bb5824 Added rest subcommand type 2021-04-10 11:30:27 -05:00
WatDuhHekBro e1e6910b1d Reduced channel.send() to send() 2021-04-10 08:34:55 -05:00
WatDuhHekBro e8def0aec3 Added guild subcommand type and various changes 2021-04-10 08:21:25 -05:00
WatDuhHekBro 54ce28d8d4 Added more library functions to command handler 2021-04-10 06:41:48 -05:00
WatDuhHekBro bd67f3b8cc Merge branch 'typescript' of https://github.com/keanuplayz/TravBot-v3 into omnibus 2021-04-10 02:41:05 -05:00
WatDuhHekBro 4c3437a177 Finally made the commands directory configurable 2021-04-10 02:38:46 -05:00
WatDuhHekBro 653cc6f8a6 Turned the help command into a paginated embed 2021-04-09 23:33:22 -05:00
WatDuhHekBro 72ff144cc0 Split command resolution part of help command 2021-04-09 23:06:16 -05:00
Keanu Timmermans da62401f59
Fixed messageEmbed test, added parseVars lib test. 2021-04-09 17:17:25 +02:00
Keanu Timmermans 2465bb6324
Merge pull request #28 from keanuplayz/experimental-core-rollout 2021-04-09 15:30:07 +02:00
WatDuhHekBro a7aea6a28e Added streaminfo message and system info module 2021-04-08 16:43:58 -05:00
WatDuhHekBro 20fb2135c7 Implemented various ideas from backlog 2021-04-08 06:37:49 -05:00
WatDuhHekBro dd6f04fb25 Merge branch 'typescript' of https://github.com/keanuplayz/TravBot-v3 into experimental-core-rollout 2021-04-07 20:33:12 -05:00
WatDuhHekBro 1dc63ef188 Merge branch 'typescript' of https://github.com/keanuplayz/TravBot-v3 into experimental-core-rollout 2021-04-07 09:14:05 -05:00
WatDuhHekBro e7dfab7592 Updated the documentation 2021-04-07 05:58:09 -05:00
WatDuhHekBro 5a64aed45d Filled out design decisions doc 2021-04-07 04:58:13 -05:00
WatDuhHekBro 9137231768 Merge branch 'typescript' of https://github.com/keanuplayz/TravBot-v3 into experimental-core-rollout 2021-04-07 01:43:39 -05:00
WatDuhHekBro 397287ec3c Retroactively added version history 2021-04-07 01:00:57 -05:00
WatDuhHekBro 7284342514 Began revising documentation and copied changelog 2021-04-06 08:22:41 -05:00
WatDuhHekBro cd9aaa5f4b Added comma 2021-04-06 01:48:17 -05:00
WatDuhHekBro eac2f1d8fa
Merge branch 'typescript' into experimental-core-rollout 2021-04-06 01:36:42 -05:00
WatDuhHekBro db33203657 Cleaned up guild checks and return statements 2021-04-06 01:15:17 -05:00
WatDuhHekBro 5402883a2f Resolved all lingering post-merge errors 2021-04-05 07:21:27 -05:00
WatDuhHekBro 259b6907b8 Merge branch 'typescript' of https://github.com/keanuplayz/TravBot-v3 into experimental-core-rollout 2021-04-05 06:59:28 -05:00
WatDuhHekBro 4a78ce808b Added more subcommand types 2021-04-05 06:45:28 -05:00
WatDuhHekBro 03f37680e7 Fully isolated command handler from rest of code 2021-04-05 04:40:04 -05:00
WatDuhHekBro 44cae5c0cb Fixed some bugs and added proper event handler 2021-04-05 03:46:50 -05:00
WatDuhHekBro 5c3896c2db Separated command handler from utility modules and fixed lingering errors in commands 2021-04-04 23:43:10 -05:00
WatDuhHekBro 6ed4c0988f Implemented rough draft of info resolver method 2021-04-04 22:40:31 -05:00
WatDuhHekBro 2a4d08d0bc Added special case for bot DMs 2021-04-04 20:01:29 -05:00
WatDuhHekBro 6eea068909 Reworked Command.execute and subcommand recursion 2021-04-04 19:35:10 -05:00
WatDuhHekBro 63441b4aca Added more type guards/properties to Command class 2021-04-04 17:28:32 -05:00
WatDuhHekBro f650faee89 Reorganized code dealing with the command class 2021-04-03 05:26:22 -05:00
WatDuhHekBro 9adc5eea6e Started attempting to split up core handler 2021-04-01 05:44:44 -05:00
WatDuhHekBro df3e4e8e6e Made some minor changes to modules 2021-03-31 02:00:03 -05:00
WatDuhHekBro 974985586d Rearranged command categories 2021-03-30 22:22:25 -05:00
WatDuhHekBro 945102b7cf Reworked event loading 2021-03-30 21:56:25 -05:00
WatDuhHekBro 3ef487c4a4 Reorganized lib/libd functions and Lavalink 2021-03-30 21:19:04 -05:00
WatDuhHekBro 86ccb74ac2 Highly biased code review 2021-03-30 18:14:15 -05:00
WatDuhHekBro 02c18f57c7 Reworked paginate function 2021-03-30 07:16:31 -05:00
WatDuhHekBro 475ecb3d5d Reworked permission handling 2021-03-30 05:54:52 -05:00
WatDuhHekBro 51fa9457b4 Fully separated utility functions from command menu 2021-03-30 05:25:07 -05:00
WatDuhHekBro 10c1cd9cff Separated custom logger from command menu 2021-03-30 04:02:01 -05:00
WatDuhHekBro 00addd468c Merge branch 'typescript' of https://github.com/keanuplayz/TravBot-v3 into experimental-core 2021-03-30 03:23:11 -05:00
WatDuhHekBro 3a47acf05f Merge branch 'typescript' of https://github.com/keanuplayz/TravBot-v3 into experimental-core 2021-01-28 18:37:50 -06:00
WatDuhHekBro 30697e5020 First attempt at getting husky to work 2021-01-26 09:57:05 -06:00
WatDuhHekBro 1fd8634ef1 Tinkered with pre-commit, jest, and tsconfig 2021-01-26 07:22:23 -06:00
WatDuhHekBro 5ed9d79715 Changed command loading to use a glob pattern 2021-01-26 03:52:39 -06:00
126 changed files with 13092 additions and 8139 deletions

View File

@ -2,8 +2,7 @@ name: CodeQL + Docker Image
on:
push:
branches:
- typescript
- docker
- master
jobs:
analyze:
@ -16,10 +15,13 @@ jobs:
fetch-depth: 2
- name: Setup Node.JS
uses: actions/setup-node@v2-beta
uses: actions/setup-node@v2
with:
node-version: "12"
- run: npm ci
node-version: "14"
# https://github.com/npm/cli/issues/558#issuecomment-580018468
# Error: "npm ERR! fsevents not accessible from jest-haste-map"
# (supposed to just be a warning b/c optional dependency, but CI environment causes it to fail)
- run: npm i
- name: Build codebase
run: npm run build
@ -57,3 +59,13 @@ jobs:
docker buildx build \
--tag keanucode/travbot-v3:latest \
--platform linux/amd64,linux/arm/v7,linux/arm64 --push .
- name: Sync From Docker Hub to GitHub
uses: onichandame/docker-registry-sync-action@master
with:
source_repository: docker.io/keanucode/travbot-v3:latest
source_username: ${{ secrets.DOCKER_USERNAME }}
source_password: ${{ secrets.DOCKER_PASSWORD }}
target_repository: docker.pkg.github.com/keanuplayz/travbot-v3/travbot-v3:latest
target_username: ${{ secrets.GH_USERNAME }}
target_password: ${{ secrets.GH_TOKEN }}

3
.gitignore vendored
View File

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

1
.husky/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
_

6
.husky/pre-commit Executable 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/

272
CHANGELOG.md Normal file
View File

@ -0,0 +1,272 @@
# 3.2.3
- Fixed `info guild` bug on servers without an icon
- Added non-pinging mention to `whois`
- Moved location of emote registry
- Added command to set default VC name
- Added pat shop item
- Reworked `say` command making use of webhooks to replicate ac2pic's Nitroless idea (Part 1)
- Fixed `poll` duration
- Fixed `eco pay` user searching
- Fixed `admin set welcome type none`
# 3.2.2
- Moved command handler code to [Onion Lasers](https://github.com/WatDuhHekBro/OnionLasers)
- Reworked `poll`
- Extended stream notifications feature
- Fixed various bugs
- Improved searching for users by name
# 3.2.1
- `vaporwave`: Transforms input into full-width text
- `eco post`: A play on `eco get`
- `admin set prefix <prefix> (<@bot>)`: Allows you to target a bot when setting a prefix if two bots have conflicting prefixes
- `party`: Sets the bot's status to streaming with a certain URL
- `eco award`: Awards users with Mons, only accessible by that person
- `thonk`: A result can now be discarded if the person who called the command reacts with ❌
- `scanemotes forcereset`: Removes the cooldown on `scanemotes`, only accessible by bot support and up
- `urban`: Bug fixes
- Changed `help` to display a paginated embed
- Various changes to core
- Added `guild` subcommand type (only accessible when `id: "guild"`)
- Further reduced `channel.send()` to `send()` because it's used in *every, single, command*
- Added a `RestCommand` type, declaratively states that the following command will do `args.join(" ")`, preventing any other subcommands from being added
- Is no longer lenient to arguments when no proper subcommand fits (now it doesn't silently fail anymore), you now have to explicitly declare a `RestCommand` to get an arbitrary number of arguments
# 3.2.0 - Internal refactor, more subcommand types, and more command type guards (2021-04-09)
- 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,4 +1,69 @@
###############
# Solution #1 #
###############
# https://github.com/geekduck/docker-node-canvas
# Took 20m 55s
#FROM node:12
#
#RUN apt-get update \
# && apt-get install -qq build-essential libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev
#
#RUN mkdir -p /opt/node/js \
# && cd /opt/node \
# && npm i canvas
#
#WORKDIR /opt/node/js
#
#ENTRYPOINT ["node"]
###############
# Solution #2 #
###############
# https://github.com/Automattic/node-canvas/issues/729#issuecomment-352991456
# Took 22m 50s
#FROM ubuntu:xenial
#
#RUN apt-get update && apt-get install -y \
# curl \
# git
#
#RUN curl -sL https://deb.nodesource.com/setup_8.x | bash - \
# && curl -sL https://deb.nodesource.com/setup_8.x | bash - \
# && curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
# && echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list
#
#RUN apt-get update && apt-get install -y \
# nodejs \
# yarn \
# libcairo2-dev \
# libjpeg-dev \
# libpango1.0-dev \
# libgif-dev \
# libpng-dev \
# build-essential \
# g++
###############
# Solution #3 #
###############
# https://github.com/Automattic/node-canvas/issues/866#issuecomment-330001221
# Took 7m 29s
FROM node:current-alpine
FROM mhart/alpine-node:latest
RUN apk add --no-cache \
build-base \
g++ \
cairo-dev \
jpeg-dev \
pango-dev \
bash \
imagemagick
# The rest of the commands to execute
COPY . .
@ -6,4 +71,4 @@ RUN npm i
RUN npm run build
CMD ["npm", "start"]
CMD ["npm", "start"]

View File

@ -1,6 +1,22 @@
# TravBot-v3
Fourth revision of TravBot, version number 3.0.0.
<p align="center">
<!-- The image could potentially be a hyperlink to invite TravBot. -->
<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://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 +24,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

View File

@ -1,286 +1,60 @@
# 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)
- [Utility Functions](#utility-functions)
- [Testing](#testing)
# 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.
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.
`<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.*
- `%author%` pings the person who sent the message.
- `%prefix%` gets the bot's current prefix in the selected server.
*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.*
```js
import Command from '../core/command';
import { CommonLibrary } from '../core/lib';
# Utility Functions
export default new Command({
run: '%author%, make sure to use the prefix! (%prefix)',
});
```
## [src/lib](../src/lib.ts) - General utility functions
...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({
//...
});
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]);
});
```
- `$.prompt()`: Prompts the user about a decision before following through.
```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`.
- `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]]`.
# Other Core Functions
# Testing
- `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.
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,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`.

85
docs/Overview.md Normal file
View File

@ -0,0 +1,85 @@
# 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)
- [Notes](#notes)
# 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). TravBot uses the [Onion Lasers Command Handler](https://github.com/WatDuhHekBro/OnionLasers) to load and setup commands. Also, if you ever want to see the definition of a function or its surrounding types and you're using VSCode, put your cursor at the word you want to go to and press `[F12]`.
# Setting up the development environment
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: `npm run dev` will automatically delete any leftover files, preventing errors that might occur because of it. However, sometimes you'd like to test stuff without that build step. To do that, run `npm run dev-fast`. You'll then have to manually delete the `dist` folder to clear any old files.*
*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 create a new `.ts` file named as the command name. Then, use and expand upon the following template.
```ts
import {Command, NamedCommand, RestCommand} from "onion-lasers";
export default new NamedCommand({
async run({send, message, channel, guild, author, member, client, args}) {
// code
}
});
```
# 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.](https://github.com/WatDuhHekBro/OnionLasers/blob/master/README.md#static-event-loading))* The client can be imported from the index file.
```ts
import {client} from "..";
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";
```
# Notes
## Logger
All calls to `console.error`, `console.warn`, `console.log`, and `console.debug` will also add to an in-memory log you can download, noted by verbosity levels `Error`, `Warn`, `Info`, and `Verbose` respectively.
- `Error`: This indicates stuff that could or is breaking at least some functionality of the bot.
- `Warn`: This indicates stuff that should probably be fixed but isn't going to break the bot.
- `Info`: Used for general events such as joining/leaving guilds for example, but try not to go overboard on logging everything.
- `Verbose`: This is used as a sort of separator for logging potentially error-prone events so that if an error occurs, you can find the context that error originated from.
- In order to make reading the logs easier, context should be provided with each call. For example, if a call is being made from the storage module, you'd do something like `console.log("[storage]", "the message")`.
- If a message is clear enough as to what the context was though, it's probably unnecessary to include this prefix. However, you should definitely attach context prefixes to error objects, who knows where those might originate.

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"
};

11221
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,49 +1,58 @@
{
"name": "d.js-v12-bot",
"version": "0.0.1",
"description": "A Discord bot built on Discord.JS v12",
"name": "travebot",
"version": "3.2.3",
"description": "TravBot Discord bot.",
"main": "dist/index.js",
"private": true,
"scripts": {
"build": "rimraf dist && tsc --project tsconfig.prod.json && npm prune --production",
"start": "node .",
"once": "tsc && npm start",
"dev": "tsc-watch --onSuccess \"npm run dev-instance\"",
"dev-fast": "tsc-watch --onSuccess \"node . dev\"",
"dev-instance": "rimraf dist && tsc && 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",
"inquirer": "^7.3.3",
"mathjs": "^9.3.0",
"canvas": "^2.8.0",
"chalk": "^4.1.2",
"discord.js": "^13.3.0",
"figlet": "^1.5.2",
"glob": "^7.2.0",
"inquirer": "^8.2.0",
"moment": "^2.29.1",
"ms": "^2.1.3",
"node-wolfram-alpha": "^1.2.5",
"onion-lasers": "npm:onion-lasers-v13@^2.2.1",
"pet-pet-gif": "^1.0.9",
"relevant-urban": "^2.0.0",
"translate-google": "^1.4.3",
"weather-js": "^2.0.0"
},
"devDependencies": {
"@types/figlet": "^1.5.0",
"@types/inquirer": "^6.5.0",
"@types/mathjs": "^6.0.11",
"@types/mocha": "^8.2.0",
"@types/figlet": "^1.5.4",
"@types/glob": "^7.2.0",
"@types/inquirer": "^8.1.3",
"@types/jest": "^27.0.2",
"@types/mathjs": "^9.4.1",
"@types/ms": "^0.7.31",
"@types/node": "^14.14.20",
"@types/ws": "^7.4.0",
"mocha": "^8.2.1",
"prettier": "2.1.2",
"ts-node": "^9.1.1",
"tsc-watch": "^4.2.9",
"typescript": "^3.9.7"
"@types/node": "^16.11.6",
"@types/ws": "^8.2.0",
"husky": "^7.0.4",
"jest": "^27.3.1",
"prettier": "2.4.1",
"rimraf": "^3.0.2",
"ts-jest": "^27.0.7",
"tsc-watch": "^4.5.0",
"typescript": "^4.4.4"
},
"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 **/*"
"optionalDependencies": {
"fsevents": "^2.3.2"
},
"author": "Keanu Timmermans",
"license": "MIT",
"keywords": [
"discord.js",
"bot"
],
"author": "Keanu Timmermans",
"license": "MIT"
]
}

14
repl.js Normal file
View File

@ -0,0 +1,14 @@
const discord = require("discord.js");
let bot = new discord.Client({
intents: [
discord.Intents.FLAGS.GUILDS,
discord.Intents.FLAGS.GUILD_MEMBERS,
discord.Intents.FLAGS.GUILD_EMOJIS_AND_STICKERS,
discord.Intents.FLAGS.GUILD_VOICE_STATES,
discord.Intents.FLAGS.GUILD_PRESENCES,
discord.Intents.FLAGS.GUILD_MESSAGES,
discord.Intents.FLAGS.GUILD_MESSAGE_REACTIONS,
discord.Intents.FLAGS.DIRECT_MESSAGES
]
});
bot.login(require("./data/config.json").token);

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 {NamedCommand, RestCommand} from "onion-lasers";
import {random} from "../../lib";
const responses = [
"Most likely,",
@ -24,16 +24,14 @@ 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({
any: new RestCommand({
description: "Question to ask the 8-ball.",
async run($: CommonLibrary): Promise<any> {
const sender = $.message.author;
$.channel.send($(responses).random() + ` <@${sender.id}>`);
async run({send, author}) {
send(`${random(responses)} ${author}`);
}
})
});

View File

@ -1,49 +1,54 @@
import Command from "../../core/command";
import {CommonLibrary} from "../../core/lib";
import {User} from "discord.js";
import {Command, NamedCommand} from "onion-lasers";
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({send, author}) {
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({send, author, args}) {
const mention: User = args[0];
if (!mention) return;
if (mention.id == author.id) return 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 send(
`:cookie: ${author} ${parseVars(random(cookies), {
target: mention.toString()
})}`
);
}
})
});

View File

@ -1,14 +1,14 @@
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, getUserByNickname, RestCommand} from "onion-lasers";
import {isAuthorized, getMoneyEmbed} from "./modules/eco-utils";
import {DailyCommand, PayCommand, GuildCommand, LeaderboardCommand} from "./modules/eco-core";
import {BuyCommand, ShopCommand} from "./modules/eco-shop";
import {MondayCommand, AwardCommand} 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));
async run({send, guild, channel, author}) {
if (isAuthorized(guild, channel)) send(getMoneyEmbed(author));
},
subcommands: {
daily: DailyCommand,
@ -18,21 +18,28 @@ export default new Command({
buy: BuyCommand,
shop: ShopCommand,
monday: MondayCommand,
bet: BetCommand
bet: BetCommand,
award: AwardCommand,
post: new NamedCommand({
description: "A play on `eco get`",
run: "`405 Method Not Allowed`"
})
},
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}) {
if (isAuthorized(guild, channel)) channel.send(getMoneyEmbed(args[0]));
async run({send, guild, channel, args}) {
if (isAuthorized(guild, channel)) send(getMoneyEmbed(args[0]));
}
}),
any: new Command({
any: new RestCommand({
description: "See how much money someone else has by using their username.",
async run({guild, channel, args, callMemberByUsername, message}) {
if (isAuthorized(guild, channel))
callMemberByUsername(message, args.join(" "), (member) => {
channel.send(getMoneyEmbed(member.user));
});
async run({send, guild, channel, combined}) {
if (isAuthorized(guild, channel)) {
const user = await getUserByNickname(combined, guild);
if (typeof user !== "string") send(getMoneyEmbed(user));
else send(user);
}
}
})
});

View File

@ -1,20 +1,19 @@
import Command from "../../core/command";
import {NamedCommand, RestCommand} from "onion-lasers";
import figlet from "figlet";
import {Util} from "discord.js";
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;
run: "You have to provide input for me to create a figlet!",
any: new RestCommand({
async run({send, combined}) {
return send(
`\`\`\`\n${Util.cleanCodeBlockContent(
figlet.textSync(combined, {
horizontalLayout: "full"
})
)}\n\`\`\``
);
}
$.channel.send(
"```" +
figlet.textSync(`${input}`, {
horizontalLayout: "full"
}) +
"```"
);
}
})
});

View File

@ -1,14 +1,14 @@
import Command from "../../core/command";
import {NamedCommand} from "onion-lasers";
export default new Command({
export default new NamedCommand({
description: "Insult TravBot! >:D",
async run($) {
$.channel.startTyping();
async run({send, channel, author}) {
channel.sendTyping();
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.`
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.sendTyping();
}, 60000);
}
});

View File

@ -1,13 +1,11 @@
import Command from "../../core/command";
import {NamedCommand, CHANNEL_TYPE} from "onion-lasers";
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({send, guild}) {
const member = guild!.members.cache.random();
if (!member) return send("For some reason, an error occurred fetching a member.");
return send(`I love ${member.nickname ?? member.user.username}!`);
}
});

View File

@ -0,0 +1,196 @@
import {Command, NamedCommand, confirm, poll} from "onion-lasers";
import {pluralise} from "../../../lib";
import {Storage} from "../../../structures";
import {isAuthorized, getMoneyEmbed} from "./eco-utils";
import {User} from "discord.js";
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({send, args, author, channel, guild}) {
if (isAuthorized(guild, channel)) {
const target = args[0];
// handle invalid target
if (target.id == author.id) return send("You can't bet Mons with yourself!");
else if (target.bot && process.argv[2] !== "dev") return send("You can't bet Mons with a bot!");
return send("How much are you betting?");
} else return;
},
number: new Command({
description: "Amount of Mons to bet.",
// handles missing duration argument
async run({send, args, author, channel, guild}) {
if (isAuthorized(guild, channel)) {
const sender = Storage.getUser(author.id);
const target = args[0] as User;
const receiver = Storage.getUser(target.id);
const amount = Math.floor(args[1]);
// handle invalid target
if (target.id == author.id) return send("You can't bet Mons with yourself!");
else if (target.bot && process.argv[2] !== "dev") return send("You can't bet Mons with a bot!");
// handle invalid amount
if (amount <= 0) return send("You must bet at least one Mon!");
else if (sender.money < amount)
return send({
content: "You don't have enough Mons for that.",
embeds: [getMoneyEmbed(author, true)]
});
else if (receiver.money < amount)
return send({
content: "They don't have enough Mons for that.",
embeds: [getMoneyEmbed(target, true)]
});
return send("How long until the bet ends?");
} else return;
},
any: new Command({
description: "Duration of the bet.",
async run({send, 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 sender = Storage.getUser(author.id);
const target = args[0] as User;
const receiver = Storage.getUser(target.id);
const amount = Math.floor(args[1]);
const duration = parseDuration(args[2].trim());
// handle invalid target
if (target.id == author.id) return send("You can't bet Mons with yourself!");
else if (target.bot && !IS_DEV_MODE) return send("You can't bet Mons with a bot!");
// handle invalid amount
if (amount <= 0) return send("You must bet at least one Mon!");
else if (sender.money < amount)
return send({
content: "You don't have enough Mons for that.",
embeds: [getMoneyEmbed(author, true)]
});
else if (receiver.money < amount)
return send({
content: "They don't have enough Mons for that.",
embeds: [getMoneyEmbed(target, true)]
});
// handle invalid duration
if (duration <= 0) return send("Invalid bet duration");
else if (duration <= parseDuration(durationBounds.min))
return send(`Bet duration is too short, maximum duration is ${durationBounds.min}`);
else if (duration >= parseDuration(durationBounds.max))
return send(`Bet duration is too long, maximum duration is ${durationBounds.max}`);
// Ask target whether or not they want to take the bet.
const takeBet = await confirm(
await send(
`<@${target.id}>, do you want to take this bet of ${pluralise(amount, "Mon", "s")}`
),
target.id
);
if (!takeBet) return send(`<@${target.id}> has rejected your bet, <@${author.id}>`);
// [MEDIUM PRIORITY: bet persistence to prevent losses in case of shutdown.]
// Remove amount money from both parts at the start to avoid duplication of money.
sender.money -= amount;
receiver.money -= amount;
// Very hacky solution for persistence but better than no solution, backup returns runs during the bot's setup code.
sender.ecoBetInsurance += amount;
receiver.ecoBetInsurance += amount;
Storage.save();
// Notify both users.
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.
return 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.
// Filter reactions to only collect the pertinent ones.
const results = await poll(
await send(
`VOTE: do you think that <@${
target.id
}> has won the bet?\nhttps://discord.com/channels/${guild!.id}/${channel.id}/${
message.id
}`
),
["✅", "❌"],
// [Pertinence to make configurable on the fly.]
parseDuration("2m")
);
// Count votes
const ok = results["✅"];
const no = results["❌"];
if (ok > no) {
receiver.money += amount * 2;
send(`By the people's votes, ${target} has won the bet that ${author} had sent them.`);
} else if (ok < no) {
sender.money += amount * 2;
send(`By the people's votes, ${target} has lost the bet that ${author} had sent them.`);
} else {
sender.money += amount;
receiver.money += amount;
send(
`By the people's votes, ${target} couldn't be determined to have won or lost the bet that ${author} had sent them.`
);
}
sender.ecoBetInsurance -= amount;
receiver.ecoBetInsurance -= amount;
Storage.save();
}, duration);
} else return;
}
})
})
})
});
/**
* Parses a duration string into milliseconds
* Examples:
* - 3d -> 3 days -> 259200000ms
* - 2h -> 2 hours -> 7200000ms
* - 7m -> 7 minutes -> 420000ms
* - 3s -> 3 seconds -> 3000ms
*/
function parseDuration(duration: string): number {
// extract last char as unit
const unit = duration[duration.length - 1].toLowerCase();
// 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 (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

@ -0,0 +1,215 @@
import {Command, getUserByNickname, NamedCommand, confirm, RestCommand} from "onion-lasers";
import {pluralise} from "../../../lib";
import {Storage} from "../../../structures";
import {isAuthorized, getMoneyEmbed, getSendEmbed, ECO_EMBED_COLOR} from "./eco-utils";
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({send, author, channel, guild}) {
if (isAuthorized(guild, channel)) {
const user = Storage.getUser(author.id);
const now = Date.now();
if (now - user.lastReceived >= 79200000) {
user.money++;
user.lastReceived = now;
Storage.save();
send({
embeds: [
{
title: "Daily Reward",
description: "You received 1 Mon!",
color: ECO_EMBED_COLOR,
fields: [
{
name: "New balance:",
value: pluralise(user.money, "Mon", "s")
}
]
}
]
});
} else
send({
embeds: [
{
title: "Daily Reward",
description: `It's too soon to pick up your daily Mons. Try again at <t:${Math.floor(
(user.lastReceived + 79200000) / 1000
)}:t>.`,
color: ECO_EMBED_COLOR
}
]
});
}
}
});
export const GuildCommand = new NamedCommand({
description: "Get info on the guild's economy as a whole.",
async run({send, guild, channel}) {
if (isAuthorized(guild, channel)) {
const users = Storage.users;
let totalAmount = 0;
for (const ID in users) {
const user = users[ID];
totalAmount += user.money;
}
send({
embeds: [
{
title: `The Bank of ${guild!.name}`,
color: ECO_EMBED_COLOR,
fields: [
{
name: "Accounts",
value: Object.keys(users).length.toString(),
inline: true
},
{
name: "Total Mons",
value: totalAmount.toString(),
inline: true
}
],
thumbnail: {
url: guild?.iconURL() ?? ""
}
}
]
});
}
}
});
export const LeaderboardCommand = new NamedCommand({
description: "See the richest players.",
aliases: ["top"],
async run({send, guild, channel, client}) {
if (isAuthorized(guild, channel)) {
const users = Storage.users;
const ids = Object.keys(users);
ids.sort((a, b) => users[b].money - users[a].money);
const fields = [];
for (let i = 0, limit = Math.min(10, ids.length); i < limit; i++) {
const id = ids[i];
const user = await client.users.fetch(id);
fields.push({
name: `#${i + 1}. ${user.tag}`,
value: pluralise(users[id].money, "Mon", "s")
});
}
send({
embeds: [
{
title: "Top 10 Richest Players",
color: ECO_EMBED_COLOR,
fields: fields,
thumbnail: {
url: guild?.iconURL() ?? ""
}
}
]
});
}
}
});
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({
async run({send, args, author, channel, guild}): Promise<any> {
if (isAuthorized(guild, channel)) {
const amount = Math.floor(args[1]);
const sender = Storage.getUser(author.id);
const target = args[0];
const receiver = Storage.getUser(target.id);
if (amount <= 0) return send("You must send at least one Mon!");
else if (sender.money < amount)
return send({
content: "You don't have enough Mons for that.",
embeds: [getMoneyEmbed(author, true)]
});
else if (target.id === author.id) return send("You can't send Mons to yourself!");
else if (target.bot && !IS_DEV_MODE) return send("You can't send Mons to a bot!");
sender.money -= amount;
receiver.money += amount;
Storage.save();
return send(getSendEmbed(author, target, amount));
}
}
})
}),
number: new Command({
run: "You must use the format `eco pay <user> <amount>`!"
}),
any: new RestCommand({
async run({send, args, author, channel, guild}) {
if (isAuthorized(guild, channel)) {
const last = args.pop();
if (!/\d+/g.test(last) && args.length === 0) return send("You need to enter an amount you're sending!");
const amount = Math.floor(last);
const sender = Storage.getUser(author.id);
if (amount <= 0) return send("You must send at least one Mon!");
else if (sender.money < amount)
return send({
content: "You don't have enough Mons to do that!",
embeds: [getMoneyEmbed(author, true)]
});
else if (!guild)
return send("You have to use this in a server if you want to send Mons with a username!");
// Do NOT use the combined parameter here, it won't account for args.pop() at the start.
const user = await getUserByNickname(args.join(" "), guild);
if (typeof user === "string") return send(user);
else if (user.id === author.id) return send("You can't send Mons to yourself!");
else if (user.bot && !IS_DEV_MODE) return send("You can't send Mons to a bot!");
const confirmed = await confirm(
await send({
content: `Are you sure you want to send ${pluralise(amount, "Mon", "s")} to this person?`,
embeds: [
{
color: ECO_EMBED_COLOR,
author: {
name: user.tag,
icon_url: user.displayAvatarURL({
format: "png",
dynamic: true
})
}
}
]
}),
author.id
);
if (confirmed) {
const receiver = Storage.getUser(user.id);
sender.money -= amount;
receiver.money += amount;
Storage.save();
send(getSendEmbed(author, user, amount));
}
}
return;
}
})
});

View File

@ -0,0 +1,78 @@
import {Command, NamedCommand} from "onion-lasers";
import {Storage} from "../../../structures";
import {isAuthorized, getMoneyEmbed} from "./eco-utils";
import {User} from "discord.js";
import {pluralise} from "../../../lib";
const WEEKDAY = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
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({send, guild, channel, author}) {
if (isAuthorized(guild, channel)) {
const user = Storage.getUser(author.id);
const now = new Date();
const weekday = now.getUTCDay();
// If it's a UTC Monday
if (weekday === 1) {
// If the user hasn't already claimed their Monday reward (checks the last 24 hours because that'll block up the entire day)
if (now.getTime() - user.lastMonday >= 86400000) {
user.money++;
user.lastMonday = now.getTime();
Storage.save();
send({content: "It is **Mon**day, my dudes.", embeds: [getMoneyEmbed(author, true)]});
} else send("You've already claimed your **Mon**day reward for this week.");
} else {
const weekdayName = WEEKDAY[weekday];
const hourText = now.getUTCHours().toString().padStart(2, "0");
const minuteText = now.getUTCMinutes().toString().padStart(2, "0");
send(
`Come back when it's **Mon**day. Right now, it's ${weekdayName}, ${hourText}:${minuteText} (UTC).`
);
}
}
}
});
export const AwardCommand = new NamedCommand({
description: "Only usable by Mon, awards one or a specified amount of Mons to the user.",
usage: "<user> (<amount>)",
aliases: ["give"],
run: "You need to specify a user!",
user: new Command({
async run({send, author, args}) {
if (author.id === "394808963356688394" || IS_DEV_MODE) {
const target = args[0] as User;
const user = Storage.getUser(target.id);
user.money++;
Storage.save();
send({content: `1 Mon given to ${target.username}.`, embeds: [getMoneyEmbed(target, true)]});
} else {
send("This command is restricted to the bean.");
}
},
number: new Command({
async run({send, author, args}) {
if (author.id === "394808963356688394" || IS_DEV_MODE) {
const target = args[0] as User;
const amount = Math.floor(args[1]);
if (amount > 0) {
const user = Storage.getUser(target.id);
user.money += amount;
Storage.save();
send({
content: `${pluralise(amount, "Mon", "s")} given to ${target.username}.`,
embeds: [getMoneyEmbed(target, true)]
});
} else {
send("You need to enter a number greater than 0.");
}
} else {
send("This command is restricted to the bean.");
}
}
})
})
});

View File

@ -1,5 +1,5 @@
import {Message} from "discord.js";
import $ from "../../../core/lib";
import {random} from "../../../lib";
export interface ShopItem {
cost: number;
@ -11,18 +11,18 @@ export interface ShopItem {
export const ShopItems: ShopItem[] = [
{
cost: 1,
cost: 3,
title: "Hug",
description: "Hug Monika.",
description: "Hug Monica.",
usage: "hug",
run(message, cost) {
message.channel.send(`Transaction of ${cost} Mon completed successfully. <@394808963356688394>`);
message.channel.send(`Transaction of ${cost} Mons completed successfully. <@394808963356688394>`);
}
},
{
cost: 2,
cost: 5,
title: "Handholding",
description: "Hold Monika's hand.",
description: "Hold Monica's hand.",
usage: "handhold",
run(message, cost) {
message.channel.send(`Transaction of ${cost} Mons completed successfully. <@394808963356688394>`);
@ -31,19 +31,29 @@ export const ShopItems: ShopItem[] = [
{
cost: 1,
title: "Cute",
description: "Calls Monika cute.",
description: "Calls Monica cute.",
usage: "cute",
run(message) {
message.channel.send("<:MoniCheeseBlushRed:637513137083383826>");
}
},
{
cost: 3,
cost: 2,
title: "Pat",
description: "Pat Monica's head.",
usage: "pat",
run(message, cost) {
message.channel.send(`Transaction of ${cost} Mons completed successfully. <@394808963356688394>`);
}
},
{
cost: 15,
title: "Laser Bridge",
description: "Buys what is technically a laser bridge.",
usage: "laser bridge",
run(message) {
message.channel.send($(lines).random(), {
message.channel.send({
content: random(lines),
files: [
{
attachment:

View File

@ -0,0 +1,85 @@
import {Command, NamedCommand, paginate, RestCommand} from "onion-lasers";
import {pluralise, split} from "../../../lib";
import {Storage, getPrefix} from "../../../structures";
import {isAuthorized, ECO_EMBED_COLOR} from "./eco-utils";
import {ShopItems, ShopItem} from "./eco-shop-items";
import {EmbedField, MessageEmbedOptions} from "discord.js";
export const ShopCommand = new NamedCommand({
description: "Displays the list of items you can buy in the shop.",
async run({send, guild, channel, author}) {
if (isAuthorized(guild, channel)) {
function getShopEmbed(selection: ShopItem[], title: string): MessageEmbedOptions {
const fields: EmbedField[] = [];
for (const item of selection)
fields.push({
name: `**${item.title}** (${getPrefix(guild)}eco buy ${item.usage})`,
value: `${item.description} Costs ${pluralise(item.cost, "Mon", "s")}.`,
inline: false
});
return {
color: ECO_EMBED_COLOR,
title: title,
fields: fields,
footer: {
text: "Mon Shop | TravBot Services"
}
};
}
const shopPages = split(ShopItems, 5);
const pageAmount = shopPages.length;
paginate(send, author.id, pageAmount, (page, hasMultiplePages) => {
return {
embeds: [
getShopEmbed(
shopPages[page],
hasMultiplePages ? `Shop (Page ${page + 1} of ${pageAmount})` : "Shop"
)
]
};
});
}
}
});
export const BuyCommand = new NamedCommand({
description: "Buys an item from the shop.",
usage: "<item>",
run: "You need to specify an item to buy.",
any: new RestCommand({
async run({send, guild, channel, message, author, combined}) {
if (isAuthorized(guild, channel)) {
let found = false;
let amount = 1; // The amount the user is buying.
// For now, no shop items support being bought multiple times. Uncomment these 2 lines when it's supported/needed.
//if (/\d+/g.test(args[args.length - 1]))
//amount = parseInt(args.pop());
for (let item of ShopItems) {
if (item.usage === combined) {
const user = Storage.getUser(author.id);
const cost = item.cost * amount;
if (cost > user.money) {
send("Not enough Mons!");
} else {
user.money -= cost;
Storage.save();
item.run(message, cost, amount);
}
found = true;
break;
}
}
if (!found) send(`There's no item in the shop that goes by \`${combined}\`!`);
}
}
})
});

View File

@ -0,0 +1,106 @@
import {pluralise} from "../../../lib";
import {Storage} from "../../../structures";
import {User, Guild, TextChannel, DMChannel, NewsChannel, Channel, TextBasedChannel} from "discord.js";
export const ECO_EMBED_COLOR = 0xf1c40f;
export function getMoneyEmbed(user: User, inline: boolean = false): object {
const profile = Storage.getUser(user.id);
console.log(profile);
if (inline) {
return {
color: ECO_EMBED_COLOR,
author: {
name: user.username,
icon_url: user.displayAvatarURL({
format: "png",
dynamic: true
})
},
fields: [
{
name: "Balance",
value: pluralise(profile.money, "Mon", "s")
}
]
};
} else {
return {
embeds: [
{
color: ECO_EMBED_COLOR,
author: {
name: user.username,
icon_url: user.displayAvatarURL({
format: "png",
dynamic: true
})
},
fields: [
{
name: "Balance",
value: pluralise(profile.money, "Mon", "s")
}
]
}
]
};
}
}
export function getSendEmbed(sender: User, receiver: User, amount: number): object {
return {
embeds: [
{
color: ECO_EMBED_COLOR,
author: {
name: sender.username,
icon_url: sender.displayAvatarURL({
format: "png",
dynamic: true
})
},
title: "Transaction",
description: `${sender.toString()} has sent ${pluralise(
amount,
"Mon",
"s"
)} to ${receiver.toString()}!`,
fields: [
{
name: `Sender: ${sender.tag}`,
value: pluralise(Storage.getUser(sender.id).money, "Mon", "s")
},
{
name: `Receiver: ${receiver.tag}`,
value: pluralise(Storage.getUser(receiver.id).money, "Mon", "s")
}
],
footer: {
text: receiver.username,
icon_url: receiver.displayAvatarURL({
format: "png",
dynamic: true
})
}
}
]
};
}
export function isAuthorized(guild: Guild | null, channel: TextBasedChannel): boolean {
if (IS_DEV_MODE) {
return true;
}
if (guild?.id !== "637512823676600330") {
channel.send("Sorry, this command can only be used in Monika's emote server.");
return false;
} else if (channel?.id !== "669464416420364288") {
channel.send("Sorry, this command can only be used in <#669464416420364288>.");
return false;
} else {
return true;
}
}

View File

@ -1,28 +0,0 @@
/// @ts-nocheck
import {URL} from "url";
import FileManager from "../../core/storage";
import Command from "../../core/command";
import {CommonLibrary, getContent} from "../../core/lib";
const endpoints = FileManager.read("endpoints");
export default new Command({
description: "Provides you with a random image with the selected argument.",
async run($: CommonLibrary): Promise<any> {
console.log(endpoints.sfw);
$.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]]}`);
const content = await getContent(url.toString());
$.channel.send(content.url);
}
})
});

View File

@ -1,67 +1,67 @@
import Command from "../../core/command";
import {CommonLibrary} from "../../core/lib";
import {NamedCommand} from "onion-lasers";
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({send}) {
send(`ok ${random(responses)}`);
}
});

View File

@ -1,12 +1,21 @@
/// @ts-nocheck
import Command from "../../core/command";
import {CommonLibrary, getContent} from "../../core/lib";
import {NamedCommand, RestCommand} from "onion-lasers";
import {random} from "../../lib";
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);
}
run: "You need to specify some text to owoify.",
any: new RestCommand({
async run({send, combined}) {
// Copied from <https://github.com/Nekos-life/neko-website/blob/78b2532de2d91375d6de45e4446fc766ba169472/app.py#L78-L87>.
const faces = ["owo", "UwU", ">w<", "^w^"];
const owoified = combined
.replace(/[rl]/g, "w")
.replace(/[RL]/g, "W")
.replace(/ove/g, "uv")
.replace(/n/g, "ny")
.replace(/N/g, "NY")
.replace(/\!/g, ` ${random(faces)} `);
send(owoified);
}
})
});

13
src/commands/fun/party.ts Normal file
View File

@ -0,0 +1,13 @@
import {NamedCommand} from "onion-lasers";
export default new NamedCommand({
description: "Initiates a celebratory stream from the bot.",
async run({send, client}) {
send("This calls for a celebration!");
client.user!.setActivity({
type: "STREAMING",
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
name: "Celebration!"
});
}
});

43
src/commands/fun/pat.ts Normal file
View File

@ -0,0 +1,43 @@
import {MessageAttachment, User} from "discord.js";
import {NamedCommand, Command, RestCommand, getUserByNickname} from "onion-lasers";
import petPetGif from "pet-pet-gif";
export default new NamedCommand({
description: "Generates a pat GIF of the provided attachment image OR the avatar of the mentioned user.",
usage: "(@user)",
async run({message, send, author}) {
if (message.attachments.size !== 0) {
const attachment = message.attachments.first()!;
const gif = await petPetGif(attachment.url);
const file = new MessageAttachment(gif, "pat.gif");
send({files: [file]});
} else {
const gif = await petPetGif(author.displayAvatarURL({format: "png"}));
const file = new MessageAttachment(gif, "pat.gif");
send({files: [file]});
}
},
id: "user",
user: new Command({
description: "User to generate a GIF of.",
async run({send, args}) {
const user: User = args[0];
const gif = await petPetGif(user.displayAvatarURL({format: "png"}));
const file = new MessageAttachment(gif, "pat.gif");
send({files: [file]});
}
}),
any: new RestCommand({
description: "User to generate a GIF of.",
async run({send, combined, guild}) {
const user = await getUserByNickname(combined, guild);
if (typeof user === "string") send(user);
else {
const gif = await petPetGif(user.displayAvatarURL({format: "png"}));
const file = new MessageAttachment(gif, "pat.gif");
send({files: [file]});
}
}
})
});

View File

@ -1,28 +1,67 @@
import {MessageEmbed} from "discord.js";
import Command from "../../core/command";
import {CommonLibrary} from "../../core/lib";
import {MessageEmbed, Message, User} from "discord.js";
import {NamedCommand, RestCommand, poll, CHANNEL_TYPE, SendFunction, Command} from "onion-lasers";
import {pluralise} from "../../lib";
export default new Command({
export default new NamedCommand({
description: "Create a poll.",
usage: "<question>",
usage: "(<seconds>) <question>",
run: "Please provide a question.",
any: new Command({
channelType: CHANNEL_TYPE.GUILD,
number: new Command({
run: "Please provide a question in addition to the provided duration.",
any: new RestCommand({
description: "Question for the poll.",
async run({send, message, author, args, combined}) {
execPoll(send, message, author, combined, args[0] * 1000);
}
})
}),
any: new RestCommand({
description: "Question for the poll.",
async run($: CommonLibrary): Promise<any> {
const embed = new MessageEmbed()
.setAuthor(
`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);
await msg.react("✅");
await msg.react("⛔");
$.message.delete({
timeout: 1000
});
async run({send, message, author, combined}) {
execPoll(send, message, author, combined);
}
})
});
const AGREE = "✅";
const DISAGREE = "⛔";
async function execPoll(send: SendFunction, message: Message, user: User, question: string, duration = 60000) {
const icon =
user.avatarURL({
dynamic: true,
size: 2048
}) || user.defaultAvatarURL;
const msg = await send({
embeds: [
new MessageEmbed()
.setAuthor(`Poll created by ${message.author.username}`, icon)
.setColor(0xffffff)
.setFooter("React to vote.")
.setDescription(question)
]
});
const results = await poll(msg, [AGREE, DISAGREE], duration);
send({
embeds: [
new MessageEmbed()
.setAuthor(`The results of ${message.author.username}'s poll:`, icon)
.setTitle(question)
.setDescription(
`${AGREE} ${pluralise(
results[AGREE],
"",
"people who agree",
"person who agrees"
)}\n${DISAGREE} ${pluralise(
results[DISAGREE],
"",
"people who disagree",
"person who disagrees"
)}`
)
]
});
msg.delete();
}

View File

@ -1,37 +1,41 @@
import Command from "../../core/command";
import {Random} from "../../core/lib";
import {Command, NamedCommand} from "onion-lasers";
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({
embed: {
title: "Ravioli ravioli...",
image: {
url: `https://raw.githubusercontent.com/keanuplayz/TravBot/master/assets/ravi${Random.int(
1,
10
)}.png`
async run({send}) {
send({
embeds: [
{
title: "Ravioli ravioli...",
image: {
url: `https://raw.githubusercontent.com/keanuplayz/TravBot/master/assets/ravi${Random.int(
1,
10
)}.png`
}
}
}
]
});
},
number: new Command({
async run($) {
const arg: number = $.args[0];
async run({send, args}) {
const arg: number = args[0];
if (arg >= 1 && arg <= 9) {
$.channel.send({
embed: {
title: "Ravioli ravioli...",
image: {
url: `https://raw.githubusercontent.com/keanuplayz/TravBot/master/assets/ravi${arg}.png`
send({
embeds: [
{
title: "Ravioli ravioli...",
image: {
url: `https://raw.githubusercontent.com/keanuplayz/TravBot/master/assets/ravi${arg}.png`
}
}
}
]
});
} else {
$.channel.send("Please provide a number between 1 and 9.");
send("Please provide a number between 1 and 9.");
}
}
})

View File

@ -1,190 +0,0 @@
import Command from "../../../core/command";
import $ from "../../../core/lib";
import {Storage} from "../../../core/structures";
import {isAuthorized, getMoneyEmbed, getSendEmbed, ECO_EMBED_COLOR} from "./eco-utils";
import {User} from "discord.js";
export const BetCommand = new Command({
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> {
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!");
return channel.send("How much are you betting?");
}
},
number: new Command({
description: "Amount of Mons to bet.",
// handles missing duration argument
async run({args, author, channel, guild}): Promise<any> {
if (isAuthorized(guild, channel)) {
const sender = Storage.getUser(author.id);
const target = args[0] as User;
const receiver = Storage.getUser(target.id);
const amount = Math.floor(args[1]);
// 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!");
// handle invalid amount
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?");
}
},
any: new Command({
description: "Duration of the bet.",
async run({client, args, author, message, channel, guild, askYesOrNo}): Promise<any> {
if (isAuthorized(guild, channel)) {
// [Pertinence to make configurable on the fly.]
// Lower and upper bounds for bet
const durationBounds = { min:"1m", max:"1d" };
const sender = Storage.getUser(author.id);
const target = args[0] as User;
const receiver = Storage.getUser(target.id);
const amount = Math.floor(args[1]);
const duration = parseDuration(args[2].trim());
// 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!");
// handle invalid amount
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");
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))
return channel.send(`Bet duration is too long, maximum duration is ${durationBounds.max}`);
// 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")}`),
target.id
);
if (takeBet) {
// [MEDIUM PRIORITY: bet persistence to prevent losses in case of shutdown.]
// Remove amount money from both parts at the start to avoid duplication of money.
sender.money -= amount;
receiver.money -= amount;
// Very hacky solution for persistence but better than no solution, backup returns runs during the bot's setup code.
sender.ecoBetInsurance += amount;
receiver.ecoBetInsurance += amount;
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.`);
// Wait for the duration of the bet.
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}`);
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;
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}>`);
}
}
})
})
})
});
/**
* Parses a duration string into milliseconds
* Examples:
* - 3d -> 3 days -> 259200000ms
* - 2h -> 2 hours -> 7200000ms
* - 7m -> 7 minutes -> 420000ms
* - 3s -> 3 seconds -> 3000ms
*/
function parseDuration(duration: string): number {
// extract last char as unit
const unit = duration[duration.length - 1].toLowerCase();
// 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 (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,209 +0,0 @@
import Command from "../../../core/command";
import $ from "../../../core/lib";
import {Storage} from "../../../core/structures";
import {isAuthorized, getMoneyEmbed, getSendEmbed, ECO_EMBED_COLOR} from "./eco-utils";
export const DailyCommand = new Command({
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}) {
if (isAuthorized(guild, channel)) {
const user = Storage.getUser(author.id);
const now = Date.now();
if (now - user.lastReceived >= 79200000) {
user.money++;
user.lastReceived = now;
Storage.save();
channel.send({
embed: {
title: "Daily Reward",
description: "You received 1 Mon!",
color: ECO_EMBED_COLOR
}
});
} else
channel.send({
embed: {
title: "Daily Reward",
description: `It's too soon to pick up your daily Mons. You have about ${(
(user.lastReceived + 79200000 - now) /
3600000
).toFixed(1)} hours to go.`,
color: ECO_EMBED_COLOR
}
});
}
}
});
export const GuildCommand = new Command({
description: "Get info on the guild's economy as a whole.",
async run({guild, channel}) {
if (isAuthorized(guild, channel)) {
const users = Storage.users;
let totalAmount = 0;
for (const ID in users) {
const user = users[ID];
totalAmount += user.money;
}
channel.send({
embed: {
title: `The Bank of ${guild!.name}`,
color: ECO_EMBED_COLOR,
fields: [
{
name: "Accounts",
value: Object.keys(users).length,
inline: true
},
{
name: "Total Mons",
value: totalAmount,
inline: true
}
],
thumbnail: {
url: guild?.iconURL() ?? ""
}
}
});
}
}
});
export const LeaderboardCommand = new Command({
description: "See the richest players.",
aliases: ["top"],
async run({guild, channel, client}) {
if (isAuthorized(guild, channel)) {
const users = Storage.users;
const ids = Object.keys(users);
ids.sort((a, b) => users[b].money - users[a].money);
const fields = [];
for (let i = 0, limit = Math.min(10, ids.length); i < limit; i++) {
const id = ids[i];
const user = await client.users.fetch(id);
fields.push({
name: `#${i + 1}. ${user.username}#${user.discriminator}`,
value: $(users[id].money).pluralise("Mon", "s")
});
}
channel.send({
embed: {
title: "Top 10 Richest Players",
color: ECO_EMBED_COLOR,
fields: fields,
thumbnail: {
url: guild?.iconURL() ?? ""
}
}
});
}
}
});
export const PayCommand = new Command({
description: "Send money to someone.",
usage: "<user> <amount>",
run: "Who are you sending this money to?",
user: new Command({
run: "You need to enter an amount you're sending!",
number: new Command({
async run({args, author, channel, guild}): Promise<any> {
if (isAuthorized(guild, channel)) {
const amount = Math.floor(args[1]);
const sender = Storage.getUser(author.id);
const target = args[0];
const receiver = Storage.getUser(target.id);
if (amount <= 0) return channel.send("You must send at least one Mon!");
else if (sender.money < amount)
return channel.send("You don't have enough Mons for that.", getMoneyEmbed(author));
else if (target.id === author.id) return channel.send("You can't send Mons to yourself!");
else if (target.bot && process.argv[2] !== "dev")
return channel.send("You can't send Mons to a bot!");
sender.money -= amount;
receiver.money += amount;
Storage.save();
return channel.send(getSendEmbed(author, target, amount));
}
}
})
}),
number: new Command({
run: "You must use the format `eco pay <user> <amount>`!"
}),
any: new Command({
async run({args, author, channel, guild, prompt}) {
if (isAuthorized(guild, channel)) {
const last = args.pop();
if (!/\d+/g.test(last) && args.length === 0)
return channel.send("You need to enter an amount you're sending!");
const amount = Math.floor(last);
const sender = Storage.getUser(author.id);
if (amount <= 0) return channel.send("You must send at least one Mon!");
else if (sender.money < amount)
return channel.send("You don't have enough Mons to do that!", getMoneyEmbed(author));
else if (!guild)
return channel.send("You have to use this in a server if you want to send Mons with a username!");
const username = args.join(" ");
const member = (
await guild.members.fetch({
query: username,
limit: 1
})
).first();
if (!member)
return channel.send(
`Couldn't find a user by the name of \`${username}\`! If you want to send Mons to someone in a different server, you have to use their user ID!`
);
else if (member.user.id === author.id) return channel.send("You can't send Mons to yourself!");
else if (member.user.bot && process.argv[2] !== "dev")
return channel.send("You can't send Mons to a bot!");
const target = member.user;
return prompt(
await channel.send(
`Are you sure you want to send ${$(amount).pluralise(
"Mon",
"s"
)} to this person?\n*(This message will automatically be deleted after 10 seconds.)*`,
{
embed: {
color: ECO_EMBED_COLOR,
author: {
name: `${target.username}#${target.discriminator}`,
icon_url: target.displayAvatarURL({
format: "png",
dynamic: true
})
}
}
}
),
author.id,
() => {
const receiver = Storage.getUser(target.id);
sender.money -= amount;
receiver.money += amount;
Storage.save();
channel.send(getSendEmbed(author, target, amount));
}
);
}
}
})
});

View File

@ -1,34 +0,0 @@
import Command from "../../../core/command";
import {Storage} from "../../../core/structures";
import {isAuthorized, getMoneyEmbed} from "./eco-utils";
const WEEKDAY = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
export const MondayCommand = new Command({
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)) {
const user = Storage.getUser(author.id);
const now = new Date();
const weekday = now.getUTCDay();
// If it's a UTC Monday
if (weekday === 1) {
// If the user hasn't already claimed their Monday reward (checks the last 24 hours because that'll block up the entire day)
if (now.getTime() - user.lastMonday >= 86400000) {
user.money++;
user.lastMonday = now.getTime();
Storage.save();
channel.send("It is **Mon**day, my dudes.", getMoneyEmbed(author));
} else channel.send("You've already claimed your **Mon**day reward for this week.");
} else {
const weekdayName = WEEKDAY[weekday];
const hourText = now.getUTCHours().toString().padStart(2, "0");
const minuteText = now.getUTCMinutes().toString().padStart(2, "0");
channel.send(
`Come back when it's **Mon**day. Right now, it's ${weekdayName}, ${hourText}:${minuteText} (UTC).`
);
}
}
}
});

View File

@ -1,85 +0,0 @@
import Command from "../../../core/command";
import $ from "../../../core/lib";
import {Storage, getPrefix} from "../../../core/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({
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") {
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")}.`,
inline: false
});
return {
embed: {
color: ECO_EMBED_COLOR,
title: title,
fields: fields,
footer: {
text: "Mon Shop | TravBot Services"
}
}
};
}
// 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})`));
$.paginate(msg, author.id, pageAmount, (page) => {
msg.edit(getShopEmbed(shopPages[page], `Shop (Page ${page + 1} of ${pageAmount})`));
});
}
}
}
});
export const BuyCommand = new Command({
description: "Buys an item from the shop.",
usage: "<item>",
async run({guild, channel, args, message, author}) {
if (isAuthorized(guild, channel)) {
let found = false;
let amount = 1; // The amount the user is buying.
// For now, no shop items support being bought multiple times. Uncomment these 2 lines when it's supported/needed.
//if (/\d+/g.test(args[args.length - 1]))
//amount = parseInt(args.pop());
let requested = args.join(" "); // The item the user is buying.
for (let item of ShopItems) {
if (item.usage === requested) {
const user = Storage.getUser(author.id);
const cost = item.cost * amount;
if (cost > user.money) {
channel.send("Not enough Mons!");
} else {
user.money -= cost;
Storage.save();
item.run(message, cost, amount);
}
found = true;
break;
}
}
if (!found) channel.send(`There's no item in the shop that goes by \`${requested}\`!`);
}
}
});

View File

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

View File

@ -1,4 +1,4 @@
import Command from "../../core/command";
import {NamedCommand, RestCommand} from "onion-lasers";
const letters: {[letter: string]: string[]} = {
a: "aáàảãạâấầẩẫậăắằẳẵặ".split(""),
@ -31,11 +31,30 @@ 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));
}
usage: "([text])",
async run({send, author}) {
const msg = await send(transform(phrase));
msg.createReactionCollector({
filter: (reaction, user) => {
if (user.id === author.id && reaction.emoji.name === "❌") msg.delete();
return false;
},
time: 60000
});
},
any: new RestCommand({
async run({send, author, combined}) {
phrase = combined;
const msg = await send(transform(phrase));
msg.createReactionCollector({
filter: (reaction, user) => {
if (user.id === author.id && reaction.emoji.name === "❌") msg.delete();
return false;
},
time: 60000
});
}
})
});

View File

@ -1,27 +1,31 @@
import Command from "../../core/command";
import {NamedCommand, RestCommand} from "onion-lasers";
import {MessageEmbed} from "discord.js";
// Anycasting Alert
const urban = require("relevant-urban");
import urban from "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.");
run: "Please input a word.",
any: new RestCommand({
async run({send, combined}) {
// [Bug Fix]: Use encodeURIComponent() when emojis are used: "TypeError [ERR_UNESCAPED_CHARACTERS]: Request path contains unescaped characters"
urban(encodeURIComponent(combined))
.then((res) => {
const embed = new MessageEmbed()
.setColor(0x1d2439)
.setTitle(res.word)
.setURL(res.urbanURL)
.setDescription(`**Definition:**\n*${res.definition}*\n\n**Example:**\n*${res.example}*`)
// [Bug Fix] When an embed field is empty (if the author field is missing, like the top entry for "british"): "RangeError [EMBED_FIELD_VALUE]: MessageEmbed field values may not be empty."
.addField("Author", res.author || "N/A", true)
.addField("Rating", `**\`Upvotes: ${res.thumbsUp} | Downvotes: ${res.thumbsDown}\`**`);
if (res.tags && res.tags.length > 0 && res.tags.join(" ").length < 1024)
embed.addField("Tags", res.tags.join(", "), true);
send({embeds: [embed]});
})
.catch(() => {
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)
.setTitle(res.word)
.setURL(res.urbanURL)
.setDescription(`**Definition:**\n*${res.definition}*\n\n**Example:**\n*${res.example}*`)
.addField("Author", res.author, true)
.addField("Rating", `**\`Upvotes: ${res.thumbsUp} | Downvotes: ${res.thumbsDown}\`**`);
if (res.tags.length > 0 && res.tags.join(" ").length < 1024) {
embed.addField("Tags", res.tags.join(", "), true);
}
$.channel.send(embed);
}
})
});

View File

@ -0,0 +1,34 @@
import {NamedCommand, RestCommand} from "onion-lasers";
const vaporwave = (() => {
const map = new Map<string, string>();
const vaporwave =
"_ ";
const normal = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`{|}~ ";
if (vaporwave.length !== normal.length) console.error("Vaporwave text failed to load properly!");
for (let i = 0; i < vaporwave.length; i++) map.set(normal[i], vaporwave[i]);
return map;
})();
function getVaporwaveText(text: string): string {
let output = "";
for (const c of text) {
const transformed = vaporwave.get(c);
if (transformed) output += transformed;
}
return output;
}
export default new NamedCommand({
description: "Transforms your text into .",
run: "You need to enter some text!",
any: new RestCommand({
async run({send, combined}) {
const text = getVaporwaveText(combined);
if (text !== "") send(text);
else send("Make sure to enter at least one valid character.");
}
})
});

View File

@ -1,39 +1,38 @@
import Command from "../../core/command";
import {NamedCommand, RestCommand} from "onion-lasers";
import {MessageEmbed} from "discord.js";
// Anycasting Alert
const weather = require("weather-js");
import {find} from "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;
run: "You need to provide a city.",
any: new RestCommand({
async run({send, combined}) {
find(
{
search: combined,
degreeType: "C"
},
function (error, result) {
if (error) return send(error.toString());
if (result.length === 0) return send("No city found by that name.");
var current = result[0].current;
var location = result[0].location;
const embed = new MessageEmbed()
.setDescription(`**${current.skytext}**`)
.setAuthor(`Weather for ${current.observationpoint}`)
.setThumbnail(current.imageUrl)
.setColor(0x00ae86)
.addField("Timezone", `UTC${location.timezone}`, true)
.addField("Degree Type", "C", true)
.addField("Temperature", `${current.temperature} Degrees`, true)
.addField("Feels like", `${current.feelslike} Degrees`, true)
.addField("Winds", current.winddisplay, true)
.addField("Humidity", `${current.humidity}%`, true);
return send({
embeds: [embed]
});
}
);
}
weather.find(
{
search: $.args.join(" "),
degreeType: "C"
},
function (err: any, result: any) {
if (err) $.channel.send(err);
var current = result[0].current;
var location = result[0].location;
const embed = new MessageEmbed()
.setDescription(`**${current.skytext}**`)
.setAuthor(`Weather for ${current.observationpoint}`)
.setThumbnail(current.imageUrl)
.setColor(0x00ae86)
.addField("Timezone", `UTC${location.timezone}`, true)
.addField("Degree Type", "C", true)
.addField("Temperature", `${current.temperature} Degrees`, true)
.addField("Feels like", `${current.feelslike} Degrees`, true)
.addField("Winds", current.winddisplay, true)
.addField("Humidity", `${current.humidity}%`, true);
$.channel.send({
embed
});
}
);
}
})
});

View File

@ -1,5 +1,5 @@
import {User} from "discord.js";
import Command from "../../core/command";
import {Command, NamedCommand, getUserByNickname, RestCommand} from "onion-lasers";
// Quotes must be used here or the numbers will change
const registry: {[id: string]: string} = {
@ -30,59 +30,63 @@ 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."
"606395763404046349": "Me.",
"237359961842253835": "Good question.",
"320680803124248576":
"The resident meat lump and certified non-weeb. Inquire directly for details and keep that honey glaze to yourself.",
"689538764950994990":
"The slayer of memes, a vigilante of the voidborn, and the self-proclaimed prophet of Xereptheí.\n> And thus, I shall remain dormant once more. For when judgement day arrives, those whose names are sung shall pierce the heavens.",
"273599683132260354":
"Does memes, art crimes, programming, programming accessories, and is accessory to meme, programming, and art crimes. Also, tiny potato.",
"156532969119547393": "Someone pretty cool for a bird made out of fire.",
"388522171393245184": "The bat. Likes pats. If mean, apply whacks. 🗞️",
"138840343855497216": "your face is a whois entry"
};
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({send, author}) {
const id = author.id;
if (id in registry) {
$.channel.send(registry[id]);
send({content: `${author} ${registry[id]}`, allowedMentions: {parse: []}});
} else {
$.channel.send("You haven't been added to the registry yet!");
send("You haven't been added to the registry yet!");
}
},
id: "user",
user: new Command({
async run($) {
const user: User = $.args[0];
async run({send, args}) {
const user: User = args[0];
const id = user.id;
if (id in registry) {
$.channel.send(`\`${user.username}\` - ${registry[id]}`);
send({content: `${user} ${registry[id]}`, allowedMentions: {parse: []}});
} else {
$.channel.send(`\`${user.username}#${user.discriminator}\` hasn't been added to the registry yet!`);
send({content: `${user} hasn't been added to the registry yet!`, allowedMentions: {parse: []}});
}
}
}),
any: new Command({
async run($) {
if ($.guild) {
const query: string = $.args.join(" ");
const member = await $.getMemberByUsername($.guild, query);
any: new RestCommand({
async run({send, guild, combined}) {
const user = await getUserByNickname(combined, guild);
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 (typeof user !== "string") {
if (user.id in registry) {
send({content: `${user} ${registry[user.id]}`, allowedMentions: {parse: []}});
} else {
$.channel.send(`Couldn't find a user by the name of \`${query}\`!`);
send({content: `${user} hasn't been added to the registry yet!`, allowedMentions: {parse: []}});
}
} 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.*)"
);
send(user);
}
}
})

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

@ -1,221 +0,0 @@
import {MessageEmbed, version as djsversion} 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";
const {version} = require("../../package.json");
export default new Command({
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`.",
subcommands: {
avatar: new Command({
description: "Shows your own, or another user's avatar.",
usage: "(<user>)",
async run($: CommonLibrary): Promise<any> {
$.channel.send($.author.displayAvatarURL({dynamic: true, size: 2048}));
},
user: new Command({
description: "Shows your own, or another user's avatar.",
async run($: CommonLibrary): Promise<any> {
$.channel.send(
$.args[0].displayAvatarURL({
dynamic: true,
size: 2048
})
);
}
})
}),
bot: new Command({
description: "Displays info about the bot.",
async run($: CommonLibrary): Promise<any> {
const core = os.cpus()[0];
const embed = new MessageEmbed()
.setColor($.guild?.me?.displayHexColor || "BLUE")
.addField("General", [
`** 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")}`,
`** Node.JS:** ${process.version}`,
`** Version:** v${version}`,
`** Discord.JS:** ${djsversion}`,
"\u200b"
])
.addField("System", [
`** Platform:** ${process.platform}`,
`** Uptime:** ${ms(os.uptime() * 1000, {
long: true
})}`,
`** CPU:**`,
`\u3000 • Cores: ${os.cpus().length}`,
`\u3000 • Model: ${core.model}`,
`\u3000 • Speed: ${core.speed}MHz`,
`** Memory:**`,
`\u3000 • Total: ${formatBytes(process.memoryUsage().heapTotal)}`,
`\u3000 • Used: ${formatBytes(process.memoryUsage().heapUsed)}`
])
.setTimestamp();
const avatarURL = $.client.user?.displayAvatarURL({
dynamic: true,
size: 2048
});
if (avatarURL) embed.setThumbnail(avatarURL);
$.channel.send(embed);
}
}),
guild: new Command({
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.");
}
},
any: new Command({
description: "Display info about a guild by finding its name or ID.",
async run($: CommonLibrary): Promise<any> {
// 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 (guild) {
$.channel.send(await getGuildInfo(guild, $.guild));
} else {
$.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));
if (guild) {
$.channel.send(await getGuildInfo(guild, $.guild));
} else {
$.channel.send(`None of the servers I'm in matches the query \`${query}\`!`);
}
}
}
})
})
},
user: new Command({
description: "Displays info about mentioned user.",
async run($: CommonLibrary): Promise<any> {
// 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);
}
})
});
async function getGuildInfo(guild: Guild, currentGuild: Guild | null) {
const members = await guild.members.fetch({
withPresences: true,
force: true
});
const roles = guild.roles.cache.sort((a, b) => b.position - a.position).map((role) => role.toString());
const channels = guild.channels.cache;
const emojis = guild.emojis.cache;
const iconURL = guild.iconURL({dynamic: true});
const embed = new MessageEmbed().setDescription(`**Guild information for __${guild.name}__**`).setColor("BLUE");
const displayRoles = !!(currentGuild && guild.id === currentGuild.id);
if (iconURL) {
embed
.setThumbnail(iconURL)
.addField("General", [
`** Name:** ${guild.name}`,
`** ID:** ${guild.id}`,
`** Owner:** ${guild.owner?.user.tag} (${guild.ownerID})`,
`** Region:** ${regions[guild.region]}`,
`** Boost Tier:** ${guild.premiumTier ? `Tier ${guild.premiumTier}` : "None"}`,
`** Explicit Filter:** ${filterLevels[guild.explicitContentFilter]}`,
`** Verification Level:** ${verificationLevels[guild.verificationLevel]}`,
`** Time Created:** ${moment(guild.createdTimestamp).format("LT")} ${moment(
guild.createdTimestamp
).format("LL")} ${moment(guild.createdTimestamp).fromNow()}`,
"\u200b"
])
.addField("Statistics", [
`** Role Count:** ${roles.length}`,
`** Emoji Count:** ${emojis.size}`,
`** Regular Emoji Count:** ${emojis.filter((emoji) => !emoji.animated).size}`,
`** Animated Emoji Count:** ${emojis.filter((emoji) => emoji.animated).size}`,
`** Member Count:** ${guild.memberCount}`,
`** Humans:** ${members.filter((member) => !member.user.bot).size}`,
`** Bots:** ${members.filter((member) => member.user.bot).size}`,
`** Text Channels:** ${channels.filter((channel) => channel.type === "text").size}`,
`** Voice Channels:** ${channels.filter((channel) => channel.type === "voice").size}`,
`** Boost Count:** ${guild.premiumSubscriptionCount || "0"}`,
`\u200b`
])
.addField("Presence", [
`** Online:** ${members.filter((member) => member.presence.status === "online").size}`,
`** Idle:** ${members.filter((member) => member.presence.status === "idle").size}`,
`** Do Not Disturb:** ${members.filter((member) => member.presence.status === "dnd").size}`,
`** Offline:** ${members.filter((member) => member.presence.status === "offline").size}`,
displayRoles ? "\u200b" : ""
])
.setTimestamp();
// Only add the roles if the guild the bot is sending the message to is the same one that's being requested.
if (displayRoles) {
embed.addField(
`Roles [${roles.length - 1}]`,
roles.length < 10 ? roles.join(", ") : roles.length > 10 ? trimArray(roles) : "None"
);
}
}
return embed;
}

View File

@ -0,0 +1,478 @@
import {Command, NamedCommand, getPermissionLevel, getPermissionName, CHANNEL_TYPE, RestCommand} from "onion-lasers";
import {Config, Storage} from "../../structures";
import {Permissions, TextChannel, User, Role, Channel, Util} 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({send, author, member}) {
const permLevel = getPermissionLevel(author, member);
return 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>) (<@bot>)",
async run({send, guild}) {
Storage.getGuild(guild!.id).prefix = null;
Storage.save();
send(
`The custom prefix for this guild has been removed. My prefix is now back to \`${Config.prefix}\`.`
);
},
any: new Command({
async run({send, guild, args}) {
Storage.getGuild(guild!.id).prefix = args[0];
Storage.save();
send(`The custom prefix for this guild is now \`${args[0]}\`.`);
},
user: new Command({
description: "Specifies the bot in case of conflicting prefixes.",
async run({send, guild, client, args}) {
if ((args[1] as User).id === client.user!.id) {
Storage.getGuild(guild!.id).prefix = args[0];
Storage.save();
send(`The custom prefix for this guild is now \`${args[0]}\`.`);
}
}
})
})
}),
messageembeds: new NamedCommand({
description: "Enable or disable sending message previews.",
usage: "enable/disable",
run: "Please specify `enable` or `disable`.",
subcommands: {
true: new NamedCommand({
description: "Enable sending of message previews.",
async run({send, guild}) {
Storage.getGuild(guild!.id).messageEmbeds = true;
Storage.save();
send("Sending of message previews has been enabled.");
}
}),
false: new NamedCommand({
description: "Disable sending of message previews.",
async run({send, guild}) {
Storage.getGuild(guild!.id).messageEmbeds = false;
Storage.save();
send("Sending of message previews has been disabled.");
}
})
}
}),
autoroles: new NamedCommand({
description: "Configure your server's autoroles.",
usage: "<roles...>",
async run({send, guild}) {
Storage.getGuild(guild!.id).autoRoles = [];
Storage.save();
send("Reset this server's autoroles.");
},
id: "role",
any: new RestCommand({
description: "The roles to set as autoroles.",
async run({send, guild, args}) {
const guildd = Storage.getGuild(guild!.id);
for (const role of args) {
if (!role.toString().match(/^<@&(\d{17,})>$/)) {
return send("Not all arguments are a role mention!");
}
const id = role.toString().match(/^<@&(\d{17,})>$/)![1];
guildd.autoRoles!.push(id);
}
Storage.save();
return send("Saved.");
}
})
}),
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({send, guild}) {
Storage.getGuild(guild!.id).welcomeType = "none";
Storage.save();
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({send, guild}) {
Storage.getGuild(guild!.id).welcomeType = "text";
Storage.save();
send("Set this server's welcome type to `text`.");
}
}),
graphical: new NamedCommand({
async run({send, guild}) {
Storage.getGuild(guild!.id).welcomeType = "graphical";
Storage.save();
send("Set this server's welcome type to `graphical`.");
}
}),
none: new NamedCommand({
async run({send, guild}) {
Storage.getGuild(guild!.id).welcomeType = "none";
Storage.save();
send("Set this server's welcome type to `none`.");
}
})
}
}),
channel: new NamedCommand({
description: "Sets the welcome channel for your server. Type `#` to reference the channel.",
usage: "(<channel mention>)",
async run({send, channel, guild}) {
Storage.getGuild(guild!.id).welcomeChannel = channel.id;
Storage.save();
send(`Successfully set ${channel} as the welcome channel for this server.`);
},
id: "channel",
channel: new Command({
async run({send, guild, args}) {
const result = args[0] as Channel;
if (result instanceof TextChannel) {
Storage.getGuild(guild!.id).welcomeChannel = result.id;
Storage.save();
send(`Successfully set this server's welcome channel to ${result}.`);
} else {
send(`\`${result.id}\` is not a valid text channel!`);
}
}
})
}),
message: new NamedCommand({
description:
"Sets a custom welcome message for your server. Use `%user%` as the placeholder for the user.",
usage: "(<message>)",
async run({send, guild}) {
Storage.getGuild(guild!.id).welcomeMessage = null;
Storage.save();
send("Reset your server's welcome message to the default.");
},
any: new RestCommand({
async run({send, guild, combined}) {
Storage.getGuild(guild!.id).welcomeMessage = combined;
Storage.save();
send(`Set your server's welcome message to \`${combined}\`.`);
}
})
})
}
}),
stream: new NamedCommand({
description: "Set a channel to send stream notifications. Type `#` to reference the channel.",
usage: "(<channel mention>)",
async run({send, channel, guild}) {
const targetGuild = Storage.getGuild(guild!.id);
if (targetGuild.streamingChannel) {
targetGuild.streamingChannel = null;
send("Removed your server's stream notifications channel.");
} else {
targetGuild.streamingChannel = channel.id;
send(`Set your server's stream notifications channel to ${channel}.`);
}
Storage.save();
},
id: "channel",
channel: new Command({
async run({send, guild, args}) {
const result = args[0] as Channel;
if (result instanceof TextChannel) {
Storage.getGuild(guild!.id).streamingChannel = result.id;
Storage.save();
send(`Successfully set this server's stream notifications channel to ${result}.`);
} else {
send(`\`${result.id}\` is not a valid text channel!`);
}
}
})
}),
streamrole: new NamedCommand({
description: "Sets/removes a stream notification role (and the corresponding category name)",
usage: "set/remove <...>",
run: "You need to enter in a role.",
subcommands: {
set: new NamedCommand({
usage: "<role> <category>",
id: "role",
role: new Command({
run: "You need to enter a category name.",
any: new RestCommand({
async run({send, guild, args, combined}) {
const role = args[0] as Role;
Storage.getGuild(guild!.id).streamingRoles[role.id] = combined;
Storage.save();
send(
`Successfully set the category \`${combined}\` to notify \`${role.name}\`.`
);
}
})
})
}),
remove: new NamedCommand({
usage: "<role>",
id: "role",
role: new Command({
async run({send, guild, args}) {
const role = args[0] as Role;
const guildStorage = Storage.getGuild(guild!.id);
const category = guildStorage.streamingRoles[role.id];
delete guildStorage.streamingRoles[role.id];
Storage.save();
send(
`Successfully removed the category \`${category}\` to notify \`${role.name}\`.`
);
}
})
})
}
}),
name: new NamedCommand({
aliases: ["defaultname"],
description:
"Sets the name that the channel will be reset to once no more members are in the channel.",
usage: "(<name>)",
async run({send, guild, message}) {
const voiceChannel = message.member?.voice.channel;
if (!voiceChannel) return send("You are not in a voice channel.");
const guildStorage = Storage.getGuild(guild!.id);
delete guildStorage.channelNames[voiceChannel.id];
Storage.save();
return send(`Successfully removed the default channel name for ${voiceChannel}.`);
},
any: new RestCommand({
async run({send, guild, message, combined}) {
const voiceChannel = message.member?.voice.channel;
const guildID = guild!.id;
const guildStorage = Storage.getGuild(guildID);
const newName = combined;
if (!voiceChannel) return send("You are not in a voice channel.");
if (!guild!.me?.permissions.has(Permissions.FLAGS.MANAGE_CHANNELS))
return send("I can't change channel names without the `Manage Channels` permission.");
guildStorage.channelNames[voiceChannel.id] = newName;
Storage.save();
return await send(`Set default channel name to "${newName}".`);
}
})
})
}
}),
diag: new NamedCommand({
description: 'Requests a debug log with the "info" verbosity level.',
permission: PERMISSIONS.BOT_SUPPORT,
async run({send}) {
send(getLogBuffer("info"));
},
any: new Command({
description: `Select a verbosity to listen to. Available levels: \`[${Object.keys(logs).join(", ")}]\``,
async run({send, args}) {
const type = args[0];
if (type in logs) send(getLogBuffer(type));
else
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({send}) {
send("Setting status to `online`...");
},
any: new Command({
description: `Select a status to set to. Available statuses: \`[${statuses.join(", ")}]\`.`,
async run({send, client, args}) {
if (!statuses.includes(args[0])) {
return send("That status doesn't exist!");
} else {
client.user?.setStatus(args[0]);
return 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({send, message, channel, guild, client}) {
// It's probably better to go through the bot's own messages instead of calling bulkDelete which requires MANAGE_MESSAGES.
if (guild!.me?.permissions.has(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 send(`Found ${travMessages.size} messages to delete.`).then((m) => {
setTimeout(() => {
m.delete();
}, 5000);
});
await (channel as TextChannel).bulkDelete(travMessages);
} else {
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, args}) {
message.delete();
const fetched = await channel.messages.fetch({
limit: args[0]
});
return await (channel as TextChannel).bulkDelete(fetched);
}
})
}),
// TODO: Reimplement this entire command, for `send` doesn't allow
// types like `unknown` to be sent anymore. Perhaps try to echo
// whatever `evaled` is into an empty buffer and send this.
// (see: `Buffer.alloc(...)`) This is unlikely to work though, since
// `Buffer.alloc(...)` requires a length, which we can't retrieve from
// an `unknown` variable.
// eval: new NamedCommand({
// description: "Evaluate code.",
// usage: "<code>",
// permission: PERMISSIONS.BOT_OWNER,
// run: "You have to enter some code to execute first.",
// any: new RestCommand({
// // 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({send, message, channel, guild, author, member, client, args, combined}) {
// try {
// let evaled: unknown = eval(combined);
// // If promises like message.channel.send() are invoked, await them so unnecessary error reports don't leak into the command handler.
// // Also, it's more useful to see the value rather than Promise { <pending> }.
// if (evaled instanceof Promise) evaled = await evaled;
// if (typeof evaled !== "string") evaled = inspect(evaled);
// // Also await this send call so that if the message is empty, it doesn't leak into the command handler.
// await send(clean(evaled), {code: "js", split: true});
// } catch (err) {
// send(clean(err), {code: "js", split: true});
// }
// }
// })
// }),
nick: new NamedCommand({
description: "Change the bot's nickname.",
permission: PERMISSIONS.BOT_SUPPORT,
channelType: CHANNEL_TYPE.GUILD,
run: "You have to specify a nickname to set for the bot",
any: new RestCommand({
async run({send, guild, combined}) {
await guild!.me?.setNickname(combined);
send(`Nickname set to \`${combined}\``);
}
})
}),
guilds: new NamedCommand({
description: "Shows a list of all guilds the bot is a member of.",
permission: PERMISSIONS.BOT_SUPPORT,
async run({send, client}) {
const guildList = Util.splitMessage(
Array.from(client.guilds.cache.map((e) => e.name).values()).join("\n")
);
for (let guildListPart of guildList) {
send(guildListPart);
}
}
}),
activity: new NamedCommand({
description: "Set the activity of the bot.",
permission: PERMISSIONS.BOT_SUPPORT,
usage: "<type> <string>",
async run({send, client}) {
client.user?.setActivity(".help", {
type: "LISTENING"
});
send("Activity set to default.");
},
any: new RestCommand({
description: `Select an activity type to set. Available levels: \`[${activities.join(", ")}]\``,
async run({send, client, args}) {
const type = args[0];
if (activities.includes(type)) {
client.user?.setActivity(args.slice(1).join(" "), {
type: args[0].toUpperCase()
});
send(`Set activity to \`${args[0].toUpperCase()}\` \`${args.slice(1).join(" ")}\`.`);
} else
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({send, channel}) {
Config.systemLogsChannel = channel.id;
Config.save();
send(`Successfully set ${channel} as the system logs channel.`);
},
channel: new Command({
async run({send, args}) {
const targetChannel = args[0] as Channel;
if (targetChannel instanceof TextChannel) {
Config.systemLogsChannel = targetChannel.id;
Config.save();
send(`Successfully set ${targetChannel} as the system logs channel.`);
} else {
send(`\`${targetChannel.id}\` is not a valid text channel!`);
}
}
})
})
}
});

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

@ -0,0 +1,150 @@
import {
RestCommand,
NamedCommand,
CHANNEL_TYPE,
getPermissionName,
getCommandList,
getCommandInfo,
paginate
} from "onion-lasers";
import {requireAllCasesHandledFor} from "../../lib";
import {MessageEmbed} from "discord.js";
const EMBED_COLOR = "#158a28";
const LEGEND = "Legend: `<type>`, `[list/of/stuff]`, `(optional)`, `(<optional type>)`, `([optional/list/...])`\n";
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({send, author}) {
const commands = await getCommandList();
const helpMenuPages: [string, string][] = []; // An array of (category, description) tuples.
// Prevent the description of one category from overflowing by splitting it into multiple pages if needed.
for (const category of commands.keys()) {
const commandList = commands.get(category)!;
let output = LEGEND;
for (const command of commandList) {
const field = `\n \`${command.name}\`: ${command.description}`;
const newOutput = output + field;
// Push then reset the output if it overflows, otherwise, continue as normal.
if (newOutput.length > 2048) {
helpMenuPages.push([category, output]);
output = LEGEND + field;
} else {
output = newOutput;
}
}
// Then push whatever's remaining.
helpMenuPages.push([category, output]);
}
paginate(send, author.id, helpMenuPages.length, (page, hasMultiplePages) => {
const [category, output] = helpMenuPages[page];
return {
embeds: [
new MessageEmbed()
.setTitle(
hasMultiplePages ? `${category} (Page ${page + 1} of ${helpMenuPages.length})` : category
)
.setDescription(output)
.setColor(EMBED_COLOR)
]
};
});
},
any: new RestCommand({
async run({send, args}) {
const resultingBlob = await getCommandInfo(args);
if (typeof resultingBlob === "string") return send(resultingBlob);
const [result, category] = resultingBlob;
let append = "";
const command = result.command;
const header = result.args.length > 0 ? `${result.header} ${result.args.join(" ")}` : result.header;
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 = list.length > 0 ? list.join("\n") : "None";
} else {
append = `\`${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 send({
embeds: [
new MessageEmbed()
.setTitle(header)
.setDescription(command.description)
.setColor(EMBED_COLOR)
.addFields(
{
name: "Aliases",
value: aliases,
inline: true
},
{
name: "Category",
value: category,
inline: true
},
{
name: "Permission Required",
value: `\`${getPermissionName(result.permission)}\` (Level ${result.permission})`,
inline: true
},
{
name: "Channel Type",
value: getChannelTypeName(result.channelType),
inline: true
},
{
name: "NSFW Only?",
value: result.nsfw ? "Yes" : "No",
inline: true
},
{
name: "Usages",
value: append
}
)
]
});
}
})
});
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);
}
}

View File

@ -0,0 +1,34 @@
import {CHANNEL_TYPE, Command, NamedCommand} from "onion-lasers";
import {registerWebhook, deleteWebhook} from "../../modules/webhookStorageManager";
// Because adding webhooks involves sending tokens, you'll want to prevent this from being used in non-private contexts.
export default new NamedCommand({
channelType: CHANNEL_TYPE.DM,
description: "Manage webhooks stored by the bot.",
usage: "register/delete <webhook URL>",
run: "You need to use `register`/`delete`.",
subcommands: {
register: new NamedCommand({
description: "Adds a webhook to the bot's storage.",
any: new Command({
async run({send, args}) {
if (registerWebhook(args[0])) {
send("Registered webhook with bot.");
} else {
send("Invalid webhook URL.");
}
}
})
}),
delete: new NamedCommand({
description: "Removes a webhook from the bot's storage.",
any: new Command({
async run({send, args}) {
if (deleteWebhook(args[0])) {
send("Deleted webhook.");
} else send("Invalid webhook URL/ID.");
}
})
})
}
});

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,17 +0,0 @@
import Command from "../../core/command";
import {processEmoteQueryFormatted} from "./subcommands/emote-utils";
import {botHasPermission} from "../../core/lib";
import {Permissions} from "discord.js";
export default new Command({
description: "Send the specified emote.",
run: "Please provide a command name.",
any: new Command({
description: "The emote(s) to send.",
usage: "<emotes...>",
async run({guild, channel, message, args}) {
const output = processEmoteQueryFormatted(args);
if (output.length > 0) channel.send(output);
}
})
});

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,114 +0,0 @@
import Command from "../../core/command";
import {CommonLibrary} from "../../core/lib";
import {Message, Channel, TextChannel} from "discord.js";
import {processEmoteQueryArray} from "./subcommands/emote-utils";
export default new Command({
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> {
let target: Message | undefined;
let distance = 1;
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!);
}
// 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.
const URLPattern = /^(?:https:\/\/discord.com\/channels\/(\d{17,19})\/(\d{17,19})\/(\d{17,19}))$/;
const copyIDPattern = /^(?:(\d{17,19})-(\d{17,19}))$/;
// https://discord.com/channels/<Guild ID>/<Channel ID>/<Message ID> ("Copy Message Link" Button)
if (URLPattern.test(last)) {
const match = URLPattern.exec(last)!;
const guildID = match[1];
const channelID = match[2];
const messageID = match[3];
let guild = $.guild;
let channel: Channel | undefined = $.channel;
if (guild?.id !== guildID) {
try {
guild = await $.client.guilds.fetch(guildID);
} catch {
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 ($.message.id !== messageID) {
try {
target = await (channel as TextChannel).messages.fetch(messageID);
} catch {
return $.channel.send(`\`${messageID}\` is an invalid message ID!`);
}
}
$.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;
if (channel.id !== channelID) channel = $.guild?.channels.cache.get(channelID);
if (!channel) return $.channel.send(`\`${channelID}\` is an invalid channel ID!`);
if ($.message.id !== messageID) {
try {
target = await (channel as TextChannel).messages.fetch(messageID);
} catch {
return $.channel.send(`\`${messageID}\` is an invalid message ID!`);
}
}
$.args.pop();
}
// <Message ID>
else if (/^\d{17,19}$/.test(last)) {
try {
target = await $.channel.messages.fetch(last);
} catch {
return $.channel.send(`No valid message found by the ID \`${last}\`!`);
}
$.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 (!target) {
// 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({
limit: distance + 1
})
).last();
}
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!);
}, 5000);
}
}
});

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,114 +0,0 @@
import {GuildEmoji} from "discord.js";
import {client} from "../../../index";
// Levenshtein distance coefficients for all transformation types.
// TODO: Investigate what values result in the most optimal matching strategy.
const directMatchWeight = 0.0;
const uppercaseWeight = 0.2;
const lowercaseWeight = 0.5;
const substitutionWeight = 1.0;
const deletionWeight = 1.5;
const insertionWeight = 1.5;
// Maximum Levenshtein distance for an emote to be considered a suitable match candidate.
const maxAcceptedDistance = 3.0;
// Algorithm taken from https://en.wikipedia.org/wiki/Levenshtein_distance#Iterative_with_two_matrix_rows
// Modified for separate handling of uppercasing and lowercasing transformations.
function levenshtein(s: string, t: string): number {
const m = s.length;
const n = t.length;
let v0 = new Array(n + 1);
let v1 = new Array(n + 1);
let i, j;
for (i = 0; i <= n; i++) v0[i] = i;
for (i = 0; i < m; i++) {
v1[0] = i + 1;
for (j = 0; j < n; j++) {
let r;
if (s[i] === t[j]) r = directMatchWeight;
else if (s[i] === t[j].toUpperCase()) r = uppercaseWeight;
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);
}
const tmp = v1;
v1 = v0, v0 = tmp;
}
return v0[n];
}
function searchSimilarEmotes(query: string): GuildEmoji[] {
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.sort((b, a) => b.dist - a.dist);
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]?$/;
const discordEmoteMentionRegex = /^<a?:\w+:\d+>$/;
const emoteNameWithSelectorRegex = /^(.+)~(\d+)$/;
function processEmoteQuery(query: string[], isFormatted: boolean): string[] {
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 formatted mode is enabled, parse whitespace and newline elements.
if (isFormatted) {
if (emote == "-") return " ";
if (emote == "+") return "\n";
}
// Selector number used for disambiguating multiple emotes with same name.
let selector = 0;
// If the query has emoteName~123 format, extract the actual name and the selector number.
const queryWithSelector = emote.match(emoteNameWithSelectorRegex);
if (queryWithSelector) {
emote = queryWithSelector[1];
selector = +queryWithSelector[2];
}
// 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);
if (directMatchEmote) return directMatchEmote.toString();
}
// Find all similar emote candidates within certian threshold and select Nth top one according to the selector.
const similarEmotes = searchSimilarEmotes(emote);
if (similarEmotes.length > 0) {
selector = Math.min(selector, similarEmotes.length - 1);
return similarEmotes[selector].toString();
}
// Return some "missing/invalid emote" indicator.
return "❓";
});
}
export const processEmoteQueryArray = (query: string[]): string[] => processEmoteQuery(query, false);
export const processEmoteQueryFormatted = (query: string[]): string => processEmoteQuery(query, true).join("");

View File

@ -1,391 +0,0 @@
import Command from "../../core/command";
import {Storage} from "../../core/structures";
import {User} from "discord.js";
import moment from "moment";
const DATE_FORMAT = "D MMMM YYYY";
const DOW_FORMAT = "dddd";
const TIME_FORMAT = "HH:mm:ss";
type DST = "na" | "eu" | "sh";
const TIME_EMBED_COLOR = 0x191970;
const DAYLIGHT_SAVINGS_REGIONS: {[region in DST]: string} = {
na: "North America",
eu: "Europe",
sh: "Southern Hemisphere"
};
const DST_NOTE_INFO = `*Note: To make things simple, the way the bot will handle specific points in time when switching Daylight Savings is just to switch at UTC 00:00, ignoring local timezones. After all, there's no need to get this down to the exact hour.*
North America
- Starts: 2nd Sunday of March
- Ends: 1st Sunday of November
Europe
- Starts: Last Sunday of March
- Ends: Last Sunday of October
Southern Hemisphere
- Starts: 1st Sunday of October
- Ends: 1st Sunday of April`;
const DST_NOTE_SETUP = `Which daylight savings region most closely matches your own?
North America (1)
- Starts: 2nd Sunday of March
- Ends: 1st Sunday of November
Europe (2)
- Starts: Last Sunday of March
- Ends: Last Sunday of October
Southern Hemisphere (3)
- Starts: 1st Sunday of October
- Ends: 1st Sunday of April`;
const DAYS_OF_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
// Returns an integer of the specific day the Sunday falls on, -1 if not found
// Also modifies the date object to the specified day as a side effect
function getSunday(date: Date, order: number) {
const daysInCurrentMonth = DAYS_OF_MONTH[date.getUTCMonth()];
let occurrencesLeft = order - 1;
// Search for the last Sunday of the month
if (order === 0) {
for (let day = daysInCurrentMonth; day >= 1; day--) {
date.setUTCDate(day);
if (date.getUTCDay() === 0) {
return day;
}
}
} else if (order > 0) {
for (let day = 1; day <= daysInCurrentMonth; day++) {
date.setUTCDate(day);
if (date.getUTCDay() === 0) {
if (occurrencesLeft > 0) {
occurrencesLeft--;
} else {
return day;
}
}
}
}
return -1;
}
// region: [firstMonth (0-11), firstOrder, secondMonth (0-11), secondOrder]
const DST_REGION_TABLE = {
na: [2, 2, 10, 1],
eu: [2, 0, 9, 0],
sh: [3, 1, 9, 1] // this one is reversed for the sake of code simplicity
};
// capturing: northern hemisphere is concave, southern hemisphere is convex
function hasDaylightSavings(region: DST) {
const [firstMonth, firstOrder, secondMonth, secondOrder] = DST_REGION_TABLE[region];
const date = new Date();
const now = date.getTime();
const currentYear = date.getUTCFullYear();
const firstDate = new Date(Date.UTC(currentYear, firstMonth));
const secondDate = new Date(Date.UTC(currentYear, secondMonth));
getSunday(firstDate, firstOrder);
getSunday(secondDate, secondOrder);
const insideBounds = now >= firstDate.getTime() && now < secondDate.getTime();
return region !== "sh" ? insideBounds : !insideBounds;
}
function getTimeEmbed(user: User) {
const {timezone, daylightSavingsRegion} = Storage.getUser(user.id);
let localDate = "N/A";
let dayOfWeek = "N/A";
let localTime = "N/A";
let timezoneOffset = "N/A";
if (timezone !== null) {
const daylightSavingsOffset = daylightSavingsRegion && hasDaylightSavings(daylightSavingsRegion) ? 1 : 0;
const daylightTimezone = timezone + daylightSavingsOffset;
const now = moment().utcOffset(daylightTimezone * 60);
localDate = now.format(DATE_FORMAT);
dayOfWeek = now.format(DOW_FORMAT);
localTime = now.format(TIME_FORMAT);
timezoneOffset = daylightTimezone >= 0 ? `+${daylightTimezone}` : daylightTimezone.toString();
}
const embed = {
embed: {
color: TIME_EMBED_COLOR,
author: {
name: user.username,
icon_url: user.displayAvatarURL({
format: "png",
dynamic: true
})
},
fields: [
{
name: "Local Date",
value: localDate
},
{
name: "Day of the Week",
value: dayOfWeek
},
{
name: "Local Time",
value: localTime
},
{
name: daylightSavingsRegion !== null ? "Current Timezone Offset" : "Timezone Offset",
value: timezoneOffset
},
{
name: "Observes Daylight Savings?",
value: daylightSavingsRegion ? "Yes" : "No"
}
]
}
};
if (daylightSavingsRegion) {
embed.embed.fields.push(
{
name: "Daylight Savings Active?",
value: hasDaylightSavings(daylightSavingsRegion) ? "Yes" : "No"
},
{
name: "Daylight Savings Region",
value: DAYLIGHT_SAVINGS_REGIONS[daylightSavingsRegion]
}
);
}
return embed;
}
export default new Command({
description: "Show others what time it is for you.",
aliases: ["tz"],
async run({channel, author}) {
channel.send(getTimeEmbed(author));
},
subcommands: {
// Welcome to callback hell. We hope you enjoy your stay here!
setup: new Command({
description: "Registers your timezone information for the bot.",
async run({author, channel, ask, askYesOrNo, askMultipleChoice}) {
const profile = Storage.getUser(author.id);
profile.timezone = null;
profile.daylightSavingsRegion = null;
let hour: number;
ask(
await channel.send(
"What hour (0 to 23) is it for you right now?\n*(Note: Make sure to use Discord's inline reply feature or this won't work!)*"
),
author.id,
(reply) => {
hour = parseInt(reply);
if (isNaN(hour)) {
return false;
}
return hour >= 0 && hour <= 23;
},
async () => {
// You need to also take into account whether or not it's the same day in UTC or not.
// The problem this setup avoids is messing up timezones by 24 hours.
// For example, the old logic was just (hour - hourUTC). When I setup my timezone (UTC-6) at 18:00, it was UTC 00:00.
// That means that that formula was doing (18 - 0) getting me UTC+18 instead of UTC-6 because that naive formula didn't take into account a difference in days.
// (day * 24 + hour) - (day * 24 + hour)
// Since the timezones will be restricted to -12 to +14, you'll be given three options.
// The end of the month should be calculated automatically, you should have enough information at that point.
// But after mapping out the edge cases, I figured out that you can safely gather accurate information just based on whether the UTC day matches the user's day.
// 21:xx (-12, -d) -- 09:xx (+0, 0d) -- 23:xx (+14, 0d)
// 22:xx (-12, -d) -- 10:xx (+0, 0d) -- 00:xx (+14, +d)
// 23:xx (-12, -d) -- 11:xx (+0, 0d) -- 01:xx (+14, +d)
// 00:xx (-12, 0d) -- 12:xx (+0, 0d) -- 02:xx (+14, +d)
// For example, 23:xx (-12) - 01:xx (+14) shows that even the edge cases of UTC-12 and UTC+14 cannot overlap, so the dataset can be reduced to a yes or no option.
// - 23:xx same day = +0, 23:xx diff day = -1
// - 00:xx same day = +0, 00:xx diff day = +1
// - 01:xx same day = +0, 01:xx diff day = +1
// First, create a tuple list matching each possible hour-dayOffset-timezoneOffset combination. In the above example, it'd go something like this:
// [[23, -1, -12], [0, 0, -11], ..., [23, 0, 12], [0, 1, 13], [1, 1, 14]]
// Then just find the matching one by filtering through dayOffset (equals zero or not), then the hour from user input.
// Remember that you don't know where the difference in day might be at this point, so you can't do (hour - hourUTC) safely.
// In terms of the equation, you're missing a variable in (--> day <-- * 24 + hour) - (day * 24 + hour). That's what the loop is for.
// Now you might be seeing a problem with setting this up at the end/beginning of a month, but that actually isn't a problem.
// Let's say that it's 00:xx of the first UTC day of a month. hourSumUTC = 24
// UTC-12 --> hourSumLowerBound (hourSumUTC - 12) = 12
// UTC+14 --> hourSumUpperBound (hourSumUTC + 14) = 38
// Remember, the nice thing about making these bounds relative to the UTC hour sum is that there can't be any conflicts even at the edges of months.
// And remember that we aren't using this question: (day * 24 + hour) - (day * 24 + hour). We're drawing from a list which does not store its data in absolute terms.
// That means there's no 31st, 1st, 2nd, it's -1, 0, +1. I just need to make sure to calculate an offset to subtract from the hour sums.
const date = new Date(); // e.g. 2021-05-01 @ 05:00
const day = date.getUTCDate(); // e.g. 1
const hourSumUTC = day * 24 + date.getUTCHours(); // e.g. 29
const timezoneTupleList: [number, number, number][] = [];
const uniques: number[] = []; // only for temporary use
const duplicates = [];
// Setup the tuple list in a separate block.
for (let timezoneOffset = -12; timezoneOffset <= 14; timezoneOffset++) {
const hourSum = hourSumUTC + timezoneOffset; // e.g. 23, UTC-6 (17 to 43)
const hour = hourSum % 24; // e.g. 23
// This works because you get the # of days w/o hours minus UTC days without hours.
// Since it's all relative to UTC, it'll end up being -1, 0, or 1.
const dayOffset = Math.floor(hourSum / 24) - day; // e.g. -1
timezoneTupleList.push([hour, dayOffset, timezoneOffset]);
if (uniques.includes(hour)) {
duplicates.push(hour);
} else {
uniques.push(hour);
}
}
// I calculate the list beforehand and check for duplicates to reduce unnecessary asking.
if (duplicates.includes(hour)) {
const isSameDay = await askYesOrNo(
await channel.send(
`Is the current day of the month the ${moment().utc().format("Do")} for you?`
),
author.id
);
// Filter through isSameDay (aka !hasDayOffset) then hour from user-generated input.
// isSameDay is checked first to reduce the amount of conditionals per loop.
if (isSameDay) {
for (const [hourPoint, dayOffset, timezoneOffset] of timezoneTupleList) {
if (dayOffset === 0 && hour === hourPoint) {
profile.timezone = timezoneOffset;
}
}
} else {
for (const [hourPoint, dayOffset, timezoneOffset] of timezoneTupleList) {
if (dayOffset !== 0 && hour === hourPoint) {
profile.timezone = timezoneOffset;
}
}
}
} else {
// If it's a unique hour, just search through the tuple list and find the matching entry.
for (const [hourPoint, dayOffset, timezoneOffset] of timezoneTupleList) {
if (hour === hourPoint) {
profile.timezone = timezoneOffset;
}
}
}
// I should note that error handling should be added sometime because await throws an exception on Promise.reject.
const hasDST = await askYesOrNo(
await channel.send("Does your timezone change based on daylight savings?"),
author.id
);
const finalize = () => {
Storage.save();
channel.send(
"You've finished setting up your timezone! Just check to see if this looks right, and if it doesn't, run this setup again.",
getTimeEmbed(author)
);
};
if (hasDST) {
const finalizeDST = (region: DST) => {
profile.daylightSavingsRegion = region;
// If daylight savings is active, subtract the timezone offset by one to store the standard time.
if (hasDaylightSavings(region)) {
profile.timezone!--;
}
finalize();
};
askMultipleChoice(await channel.send(DST_NOTE_SETUP), author.id, [
() => finalizeDST("na"),
() => finalizeDST("eu"),
() => finalizeDST("sh")
]);
} else {
finalize();
}
},
() => "you need to enter in a valid integer between 0 to 23"
);
}
}),
delete: new Command({
description: "Delete your timezone information.",
async run({channel, author, prompt}) {
prompt(
await channel.send(
"Are you sure you want to delete your timezone information?\n*(This message will automatically be deleted after 10 seconds.)*"
),
author.id,
() => {
const profile = Storage.getUser(author.id);
profile.timezone = null;
profile.daylightSavingsRegion = null;
Storage.save();
}
);
}
}),
utc: new Command({
description: "Displays UTC time.",
async run({channel}) {
const time = moment().utc();
channel.send({
embed: {
color: TIME_EMBED_COLOR,
fields: [
{
name: "Local Date",
value: time.format(DATE_FORMAT)
},
{
name: "Day of the Week",
value: time.format(DOW_FORMAT)
},
{
name: "Local Time",
value: time.format(TIME_FORMAT)
}
]
}
});
}
}),
daylight: new Command({
description: "Provides information on the daylight savings region",
run: DST_NOTE_INFO
})
},
user: new Command({
description: "See what time it is for someone else.",
async run({channel, args}) {
channel.send(getTimeEmbed(args[0]));
}
}),
any: new Command({
description: "See what time it is for someone else (by their username).",
async run({channel, args, message, callMemberByUsername}) {
callMemberByUsername(message, args.join(" "), (member) => {
channel.send(getTimeEmbed(member.user));
});
}
})
});

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

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

View File

@ -0,0 +1,34 @@
import {NamedCommand, RestCommand} from "onion-lasers";
import {WolframClient} from "node-wolfram-alpha";
import {MessageEmbed} from "discord.js";
import {Config} from "../../structures";
export default new NamedCommand({
description: "Calculates a specified math expression.",
run: "Please provide a calculation.",
any: new RestCommand({
async run({send, combined}) {
if (Config.wolfram === null) return send("There's no Wolfram token in the config.");
const wClient = new WolframClient(Config.wolfram);
let resp;
try {
resp = await wClient.query(combined);
} catch (e: any) {
return send("Something went wrong.");
}
if (!resp.data.queryresult.pods) return send("No pods were returned. Your query was likely invalid.");
else {
// TODO: Please don't hardcode the pod to fetch, try to figure out
// which is the right one based on some comparisons instead
const embed = new MessageEmbed()
.setColor(0xffffff)
.setTitle("Math Calculation")
.addField("Input", `\`\`\`\n${combined}\`\`\``)
.addField("Output", `\`\`\`\n${resp.data.queryresult.pods[1].subpods[0].plaintext}\`\`\``);
return send({embeds: [embed]});
}
}
})
});

View File

@ -1,6 +1,6 @@
import Command from "../../core/command";
import {NamedCommand} from "onion-lasers";
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,21 @@
import {NamedCommand, RestCommand} from "onion-lasers";
export default new NamedCommand({
description: "Renames current voice channel.",
usage: "<name>",
run: "Please provide a new voice channel name.",
any: new RestCommand({
async run({send, message, combined}) {
const voiceChannel = message.member?.voice.channel;
if (!voiceChannel) return send("You are not in a voice channel.");
if (!voiceChannel.guild.me?.permissions.has("MANAGE_CHANNELS"))
return send("I am lacking the required permissions to perform this action.");
const prevName = voiceChannel.name;
const newName = combined;
await voiceChannel.setName(newName);
return await send(`Changed channel name from "${prevName}" to "${newName}".`);
}
})
});

View File

@ -0,0 +1,30 @@
import {NamedCommand, RestCommand} from "onion-lasers";
import {URL} from "url";
import {getContent} from "../../lib";
export default new NamedCommand({
description: "Provides you with info from the Discord.JS docs.",
run: "You need to specify a term to query the docs with.",
any: new RestCommand({
description: "What to query the docs with.",
async run({send, author, args}) {
var queryString = args[0];
let url = new URL(`https://djsdocs.sorta.moe/v2/embed?src=master&q=${queryString}`);
const content = await getContent(url.toString());
const msg = await send({embeds: [content]});
const react = await msg.react("❌");
const collector = msg.createReactionCollector({
filter: (reaction, user) => {
if (user.id === author.id && reaction.emoji.name === "❌") msg.delete();
return false;
},
time: 60000
});
collector.on("end", () => {
if (!msg.deleted) react.users.remove(msg.author);
});
}
})
});

View File

@ -0,0 +1,16 @@
import {NamedCommand, RestCommand} from "onion-lasers";
import {processEmoteQuery} from "./modules/emote-utils";
export default new NamedCommand({
description:
"Send the specified emote list. Enter + to move an emote list to the next line, - to add a space, and _ to add a zero-width space.",
run: "Please provide a list of emotes.",
any: new RestCommand({
description: "The emote(s) to send.",
usage: "<emotes...>",
async run({send, args}) {
const output = processEmoteQuery(args, true).join("");
if (output.length > 0) send(output);
}
})
});

View File

@ -0,0 +1,270 @@
import {MessageEmbed, version as djsversion, Guild, User, GuildMember, TextChannel, VoiceChannel} from "discord.js";
import ms from "ms";
import os from "os";
import {Command, NamedCommand, getUserByNickname, CHANNEL_TYPE, getGuildByName, RestCommand} from "onion-lasers";
import {formatBytes, trimArray} from "../../lib";
import {verificationLevels, filterLevels} from "../../defs/info";
import moment, {utc} from "moment";
export default new NamedCommand({
description: "Command to provide all sorts of info about the current server, a user, etc.",
async run({send, author, member}) {
send({embeds: [await getUserInfo(author, member)]});
},
subcommands: {
avatar: new NamedCommand({
description: "Shows your own, or another user's avatar.",
usage: "(<user>)",
async run({send, author}) {
send(author.displayAvatarURL({dynamic: true, size: 2048}));
},
id: "user",
user: new Command({
description: "Shows your own, or another user's avatar.",
async run({send, args}) {
send(
args[0].displayAvatarURL({
dynamic: true,
size: 2048
})
);
}
}),
any: new RestCommand({
description: "Shows another user's avatar by searching their name",
channelType: CHANNEL_TYPE.GUILD,
async run({send, guild, combined}) {
const user = await getUserByNickname(combined, guild);
if (typeof user !== "string") {
send(
user.displayAvatarURL({
dynamic: true,
size: 2048
})
);
} else {
send(user);
}
}
})
}),
bot: new NamedCommand({
description: "Displays info about the bot.",
async run({send, guild, client}) {
const core = os.cpus()[0];
const embed = new MessageEmbed()
.setColor(guild?.me?.displayHexColor || "BLUE")
.addField(
"General",
[
`** 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"
)}`,
`** Node.JS:** ${process.version}`,
`** Version:** v${process.env.npm_package_version}`,
`** Discord.JS:** v${djsversion}`,
"\u200b"
].join("\n")
)
.addField(
"System",
[
`** Platform:** ${process.platform}`,
`** Uptime:** ${ms(os.uptime() * 1000, {
long: true
})}`,
`** CPU:**`,
`\u3000 • Cores: ${os.cpus().length}`,
`\u3000 • Model: ${core.model}`,
`\u3000 • Speed: ${core.speed}MHz`,
`** Memory:**`,
`\u3000 • Total: ${formatBytes(process.memoryUsage().heapTotal)}`,
`\u3000 • Used: ${formatBytes(process.memoryUsage().heapUsed)}`
].join("\n")
)
.setTimestamp();
const avatarURL = client.user?.displayAvatarURL({
dynamic: true,
size: 2048
});
if (avatarURL) embed.setThumbnail(avatarURL);
send({embeds: [embed]});
}
}),
guild: new NamedCommand({
description: "Displays info about the current guild or another guild.",
usage: "(<guild name>/<guild ID>)",
channelType: CHANNEL_TYPE.GUILD,
async run({send, guild}) {
send({embeds: [await getGuildInfo(guild!, guild)]});
},
id: "guild",
guild: new Command({
description: "Display info about a guild by its ID.",
async run({send, guild, args}) {
const targetGuild = args[0] as Guild;
send({embeds: [await getGuildInfo(targetGuild, guild)]});
}
}),
any: new RestCommand({
description: "Display info about a guild by finding its name.",
async run({send, guild, combined}) {
const targetGuild = getGuildByName(combined);
if (typeof targetGuild !== "string") {
send({embeds: [await getGuildInfo(targetGuild, guild)]});
} else {
send(targetGuild);
}
}
})
})
},
id: "user",
user: new Command({
description: "Displays info about mentioned user.",
async run({send, guild, args}) {
const user = args[0] as User;
// Transforms the User object into a GuildMember object of the current guild.
const member = guild?.members.resolve(user);
send({embeds: [await getUserInfo(user, member)]});
}
}),
any: new RestCommand({
description: "Displays info about a user by their nickname or username.",
async run({send, guild, combined}) {
const user = await getUserByNickname(combined, guild);
// Transforms the User object into a GuildMember object of the current guild.
const member = guild?.members.resolve(user);
if (typeof user !== "string") send({embeds: [await getUserInfo(user, member)]});
else send(user);
}
})
});
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()}`
].join("\n")
);
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",
[
`** Status:** ${member.presence?.status}`,
`** Game:** ${member.presence?.activities ?? "Not playing a game."}`,
`** 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(", ")
}`
].join("\n")
);
}
return embed;
}
async function getGuildInfo(guild: Guild, currentGuild: Guild | null) {
const members = await guild.members.fetch({
withPresences: true,
force: true
});
const roles = guild.roles.cache.sort((a, b) => b.position - a.position).map((role) => role.toString());
const channels = guild.channels.cache;
const emojis = guild.emojis.cache;
const iconURL = guild.iconURL({dynamic: true});
const embed = new MessageEmbed().setDescription(`**Guild information for __${guild.name}__**`).setColor("BLUE");
const displayRoles = !!(currentGuild && guild.id === currentGuild.id);
const owner = await guild.fetchOwner();
embed
.addField(
"General",
[
`** Name:** ${guild.name}`,
`** ID:** ${guild.id}`,
`** Owner:** ${owner.user.tag} (${guild.ownerId})`,
`** Boost Tier:** ${guild.premiumTier ? `Tier ${guild.premiumTier}` : "None"}`,
`** Explicit Filter:** ${filterLevels[guild.explicitContentFilter]}`,
`** Verification Level:** ${verificationLevels[guild.verificationLevel]}`,
`** Time Created:** ${moment(guild.createdTimestamp).format("LT")} ${moment(
guild.createdTimestamp
).format("LL")} ${moment(guild.createdTimestamp).fromNow()}`,
"\u200b"
].join("\n")
)
.addField(
"Statistics",
[
`** Role Count:** ${roles.length}`,
`** Emoji Count:** ${emojis.size}`,
`** Regular Emoji Count:** ${emojis.filter((emoji) => !emoji.animated).size}`,
`** Animated Emoji Count:** ${emojis.filter((emoji) => !!emoji.animated).size}`,
`** Member Count:** ${guild.memberCount}`,
`** Humans:** ${members.filter((member) => !member.user.bot).size}`,
`** Bots:** ${members.filter((member) => member.user.bot).size}`,
`** Text Channels:** ${channels.filter((channel) => channel instanceof TextChannel).size}`,
`** Voice Channels:** ${channels.filter((channel) => channel instanceof VoiceChannel).size}`,
`** Boost Count:** ${guild.premiumSubscriptionCount || "0"}`,
`\u200b`
].join("\n")
)
.addField(
"Presence",
[
`** Online:** ${members.filter((member) => member.presence?.status === "online").size}`,
`** Idle:** ${members.filter((member) => member.presence?.status === "idle").size}`,
`** Do Not Disturb:** ${members.filter((member) => member.presence?.status === "dnd").size}`,
`** Offline:** ${members.filter((member) => member.presence?.status === "offline").size}`,
displayRoles ? "\u200b" : ""
].join("\n")
)
.setTimestamp();
if (iconURL) embed.setThumbnail(iconURL);
// Only add the roles if the guild the bot is sending the message to is the same one that's being requested.
if (displayRoles) {
embed.addField(
`Roles [${roles.length - 1}]`,
roles.length < 10 ? roles.join(", ") : roles.length > 10 ? trimArray(roles).join(", ") : "None"
);
}
return embed;
}

View File

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

View File

@ -1,47 +1,51 @@
import {GuildEmoji} from "discord.js";
import {MessageEmbed} from "discord.js";
import Command from "../../core/command";
import {CommonLibrary} from "../../core/lib";
import {GuildEmoji, MessageEmbed, User} from "discord.js";
import {NamedCommand, RestCommand, paginate, SendFunction} from "onion-lasers";
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({send, author, client}) {
displayEmoteList(Array.from(client.emojis.cache.values()), send, author);
},
any: new Command({
any: new RestCommand({
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({send, author, 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,}$/.test(args[0])) {
const guildID: string = args[0];
displayEmoteList($, $.client.emojis.cache.filter((emote) => emote.guild.id === guildID).array());
displayEmoteList(
Array.from(client.emojis.cache.filter((emote) => emote.guild.id === guildID).values()),
send,
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 = Array.from(client.emojis.cache.values());
// 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} = {};
let emotes = new Map<string, string>();
for (const emote of emoteCollection) {
emotes.set(emote.id, emote.name);
if (emote.name) {
emotes.set(emote.id, emote.name);
}
}
// 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,26 +59,33 @@ 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, send, author);
} catch (error) {
if (error.code === "ERR_SCRIPT_EXECUTION_TIMEOUT") {
$.channel.send(
// FIXME: `error` is of type `unknown` here.
// Also: <https://stackoverflow.com/questions/40141005/property-code-does-not-exist-on-type-error>
let errorName = "???";
if (error instanceof Error) {
errorName = error.name;
}
if (errorName === "ERR_SCRIPT_EXECUTION_TIMEOUT") {
send(
`The regular expression you entered exceeded the time limit of ${REGEX_TIMEOUT_MS} milliseconds.`
);
} else {
throw new Error(error);
throw new Error(errorName);
}
}
} else {
$.channel.send("Failed to initialize sandbox.");
send("Failed to initialize sandbox.");
}
}
}
})
});
async function displayEmoteList($: CommonLibrary, emotes: GuildEmoji[]) {
async function displayEmoteList(emotes: GuildEmoji[], send: SendFunction, author: User) {
emotes.sort((a, b) => {
if (!a.name || !b.name) return 0;
const first = a.name.toLowerCase();
const second = b.name.toLowerCase();
@ -82,36 +93,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(send, 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 {embeds: [embed]};
});
} else {
$.channel.send("No valid emotes found by that query.");
send("No valid emotes found by that query.");
}
}

View File

@ -0,0 +1,115 @@
import {GuildEmoji} from "discord.js";
import {client} from "../../../index";
// Levenshtein distance coefficients for all transformation types.
// TODO: Investigate what values result in the most optimal matching strategy.
const directMatchWeight = 0.0;
const uppercaseWeight = 0.2;
const lowercaseWeight = 0.5;
const substitutionWeight = 1.0;
const deletionWeight = 1.5;
const insertionWeight = 1.5;
// Maximum Levenshtein distance for an emote to be considered a suitable match candidate.
const maxAcceptedDistance = 3.0;
// Algorithm taken from https://en.wikipedia.org/wiki/Levenshtein_distance#Iterative_with_two_matrix_rows
// Modified for separate handling of uppercasing and lowercasing transformations.
function levenshtein(s: string, t: string): number {
const m = s.length;
const n = t.length;
let v0 = new Array(n + 1);
let v1 = new Array(n + 1);
let i, j;
for (i = 0; i <= n; i++) v0[i] = i;
for (i = 0; i < m; i++) {
v1[0] = i + 1;
for (j = 0; j < n; j++) {
let r;
if (s[i] === t[j]) r = directMatchWeight;
else if (s[i] === t[j].toUpperCase()) r = uppercaseWeight;
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);
}
const tmp = v1;
(v1 = v0), (v0 = tmp);
}
return v0[n];
}
function searchSimilarEmotes(query: string): GuildEmoji[] {
const emoteCandidates: {emote: GuildEmoji; dist: number}[] = [];
for (const emote of client.emojis.cache.values()) {
if (emote.name) {
const dist = levenshtein(emote.name, query);
if (dist <= maxAcceptedDistance) {
emoteCandidates.push({emote, dist});
}
}
}
emoteCandidates.sort((b, a) => b.dist - a.dist);
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]?$/;
const discordEmoteMentionRegex = /^<a?:\w+:\d+>$/;
const emoteNameWithSelectorRegex = /^(.+)~(\d+)$/;
export function searchNearestEmote(query: string, additionalEmotes?: GuildEmoji[]): string {
// Selector number used for disambiguating multiple emotes with same name.
let selector = 0;
// If the query has emoteName~123 format, extract the actual name and the selector number.
const queryWithSelector = query.match(emoteNameWithSelectorRegex);
if (queryWithSelector) {
query = queryWithSelector[1];
selector = +queryWithSelector[2];
}
// 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 === query);
if (directMatchEmote) return directMatchEmote.toString();
}
// Find all similar emote candidates within certain threshold and select Nth top one according to the selector.
const similarEmotes = searchSimilarEmotes(query);
if (similarEmotes.length > 0) {
selector = Math.min(selector, similarEmotes.length - 1);
return similarEmotes[selector].toString();
}
// Return some "missing/invalid emote" indicator.
return "❓";
}
export function processEmoteQuery(query: string[], isFormatted: boolean): string[] {
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 formatted mode is enabled, parse whitespace and newline elements.
if (isFormatted) {
if (emote == "-") return " ";
if (emote == "+") return "\n";
if (emote == "_") return "\u200b";
}
return searchNearestEmote(emote);
});
}

View File

@ -0,0 +1,45 @@
import {NamedCommand, getPermissionLevel, getPermissionName, hasPermission} from "onion-lasers";
import {DMChannel, Permissions} from "discord.js";
export default new NamedCommand({
description:
"Purges the bot's messages in either a guild channel (requiring the BOT_SUPPORT permission level) or a DM channel (no permission required). Limited to the last 100 messages.",
async run({send, message, channel, guild, client, author, member}) {
if (channel instanceof DMChannel) {
const messages = await channel.messages.fetch({
limit: 100
});
for (const message of messages.values()) {
if (message.author.id === client.user!.id) {
message.delete();
}
}
} else if (hasPermission(author, member, PERMISSIONS.BOT_SUPPORT)) {
if (guild!.me?.permissions.has(Permissions.FLAGS.MANAGE_MESSAGES)) message.delete();
const messages = await channel.messages.fetch({
limit: 100
});
const travMessages = messages.filter((msg) => msg.author.id === client.user!.id);
send(`Found ${travMessages.size} messages to delete.`).then((msg) => setTimeout(() => msg.delete(), 5000));
// It's better to go through the bot's own messages instead of calling bulkDelete which requires MANAGE_MESSAGES.
for (const message of messages.values()) {
if (message.author.id === client.user!.id) {
message.delete();
}
}
} else {
const userPermLevel = getPermissionLevel(author, member);
send(
`You don't have access to this command! Your permission level is \`${getPermissionName(
userPermLevel
)}\` (${userPermLevel}), but this command requires a permission level of \`${getPermissionName(
PERMISSIONS.BOT_SUPPORT
)}\` (${PERMISSIONS.BOT_SUPPORT}).`
);
}
}
});

View File

@ -0,0 +1,120 @@
import {NamedCommand, RestCommand} from "onion-lasers";
import {Message, Channel, TextChannel, TextBasedChannel} from "discord.js";
import {processEmoteQuery} from "./modules/emote-utils";
export default new NamedCommand({
aliases: ["r"],
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">)',
run: "You need to enter some emotes first.",
any: new RestCommand({
async run({send, message, channel, guild, client, args}) {
let target: Message | undefined;
let distance = 1;
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!);
}
// 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.
const URLPattern = /^(?:https:\/\/discord.com\/channels\/(\d{17,})\/(\d{17,})\/(\d{17,}))$/;
const copyIDPattern = /^(?:(\d{17,})-(\d{17,}))$/;
// https://discord.com/channels/<Guild ID>/<Channel ID>/<Message ID> ("Copy Message Link" Button)
if (URLPattern.test(last)) {
const match = URLPattern.exec(last)!;
const guildID = match[1];
const channelID = match[2];
const messageID = match[3];
let tmpChannel: TextBasedChannel | undefined = channel;
if (guild?.id !== guildID) {
try {
guild = await client.guilds.fetch(guildID);
} catch {
return send(`\`${guildID}\` is an invalid guild ID!`);
}
}
if (tmpChannel?.id !== channelID)
tmpChannel = guild.channels.cache.get(channelID) as TextBasedChannel;
if (!tmpChannel) return send(`\`${channelID}\` is an invalid channel ID!`);
if (message.id !== messageID) {
try {
target = await tmpChannel.messages.fetch(messageID);
} catch {
return send(`\`${messageID}\` is an invalid message ID!`);
}
}
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 tmpChannel: TextBasedChannel | undefined = channel;
if (tmpChannel?.id !== channelID)
tmpChannel = guild?.channels.cache.get(channelID) as TextBasedChannel;
if (!tmpChannel) return send(`\`${channelID}\` is an invalid channel ID!`);
if (message.id !== messageID) {
try {
target = await tmpChannel.messages.fetch(messageID);
} catch {
return send(`\`${messageID}\` is an invalid message ID!`);
}
}
args.pop();
}
// <Message ID>
else if (/^\d{17,}$/.test(last)) {
try {
target = await channel.messages.fetch(last);
} catch {
return send(`No valid message found by the ID \`${last}\`!`);
}
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 send("Your distance must be between 0 and 99!");
}
}
if (!target) {
// 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({
limit: distance + 1
})
).last();
}
for (const emote of processEmoteQuery(args, false)) {
// 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!);
}, 5000);
}
return;
}
})
});

View File

@ -0,0 +1,75 @@
import {NamedCommand, RestCommand, CHANNEL_TYPE} from "onion-lasers";
import {TextChannel, NewsChannel, Permissions} from "discord.js";
import {searchNearestEmote} from "../utility/modules/emote-utils";
import {resolveWebhook} from "../../modules/webhookStorageManager";
import {parseVarsCallback} from "../../lib";
// Description //
// This is the message-based counterpart to the react command, which replicates Nitro's ability to send emotes in messages.
// This takes advantage of webhooks' ability to change the username and avatar per request.
// Uses "@user says:" as a fallback in case no webhook is set for the channel.
// Limitations / Points of Interest //
// - Webhooks can fetch any emote in existence and use it as long as it hasn't been deleted.
// - The emote name from <:name:id> DOES matter if the user isn't part of that guild. That's the fallback essentially, otherwise, it doesn't matter.
// - The animated flag must be correct. <:name:id> on an animated emote will make it not animated, <a:name:id> will display an invalid image.
// - Rate limits for webhooks shouldn't be that big of an issue (5 requests every 2 seconds).
export default new NamedCommand({
aliases: ["s"],
channelType: CHANNEL_TYPE.GUILD,
description: "Repeats your message with emotes in /slashes/.",
usage: "<message>",
run: "Please provide a message for me to say!",
any: new RestCommand({
description: "Message to repeat.",
async run({send, channel, author, member, message, combined, guild}) {
const webhook = await resolveWebhook(channel as TextChannel | NewsChannel);
if (webhook) {
const resolvedMessage = resolveMessageWithEmotes(combined);
if (resolvedMessage)
webhook.send({
content: resolvedMessage,
username: member!.nickname ?? author.username,
// Webhooks cannot have animated avatars, so requesting the animated version is a moot point.
avatarURL:
author.avatarURL({
format: "png"
}) || author.defaultAvatarURL,
allowedMentions: {parse: []}, // avoids double pings
// "embeds" will not be included because it messes with the default ones that generate
files: Array.from(message.attachments.values())
});
else send("Cannot send an empty message.");
} else {
const resolvedMessage = resolveMessageWithEmotes(combined);
if (resolvedMessage)
send({content: `*${author} says:*\n${resolvedMessage}`, allowedMentions: {parse: []}});
else send("Cannot send an empty message.");
}
if (guild!.me?.permissions.has(Permissions.FLAGS.MANAGE_MESSAGES)) message.delete();
}
})
});
const FETCH_EMOTE_PATTERN = /^(\d{17,})(?: ([^ ]+?))?(?: (a))?$/;
// Send extra emotes only for webhook messages (because the bot user can't fetch any emote in existence while webhooks can).
function resolveMessageWithEmotes(text: string, extraEmotes?: null): string {
return parseVarsCallback(
text,
(variable) => {
if (FETCH_EMOTE_PATTERN.test(variable)) {
// Although I *could* make this ping the CDN to see if gif exists to see whether it's animated or not, it'd take too much time to wait on it.
// Plus, with the way this function is setup, I wouldn't be able to incorporate a search without changing the function to async.
const [_, id, name, animated] = FETCH_EMOTE_PATTERN.exec(variable)!;
return `<${animated ?? ""}:${name ?? "_"}:${id}>`;
}
return searchNearestEmote(variable);
},
"/"
);
}

View File

@ -1,33 +1,30 @@
import Command from "../core/command";
import {CommonLibrary} from "../core/lib";
import {NamedCommand, CHANNEL_TYPE} from "onion-lasers";
import {pluralise} from "../../lib";
import moment from "moment";
import {Collection, TextChannel} from "discord.js";
import {Collection, TextChannel, Util} from "discord.js";
const lastUsedTimestamps: {[id: string]: number} = {};
const lastUsedTimestamps = new Collection<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({send, message, channel, guild}) {
// 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.get(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(
`This command requires a day to cooldown. You'll be able to activate this command ${howLong}.`
);
else lastUsedTimestamps[$.guild.id] = startTime;
return send(`This command requires a day to cooldown. You'll be able to activate this command ${howLong}.`);
else lastUsedTimestamps.set(guild!.id, startTime);
const stats: {
[id: string]: {
name: string;
name: string | null;
formatted: string;
users: number;
bots: number;
@ -35,20 +32,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(
(channel) => channel.type === "text" && channel.viewable
const allTextChannelsInCurrentGuild = guild!.channels.cache.filter(
(channel) => channel instanceof TextChannel && 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 send("Gathering emotes...");
let warnings = 0;
$.channel.startTyping();
channel.sendTyping();
// 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 +63,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 +128,8 @@ export default new Command({
continueReactionLoop = false;
if (reaction.count !== userReactions + botReactions) {
$.warn(
console.warn(
"[scanemotes]",
`[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 +153,14 @@ 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("[scanemotes]", `Finished operation in ${finishTime - startTime} ms.`);
// 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 +179,19 @@ export default new Command({
);
}
$.channel.send(lines, {split: true}).catch($.handler.bind($));
let emoteList = Util.splitMessage(lines.join("\n"));
for (let emoteListPart of emoteList) {
return await send(emoteListPart);
}
},
subcommands: {
forcereset: new NamedCommand({
description: "Forces the cooldown timer to reset.",
permission: PERMISSIONS.BOT_SUPPORT,
async run({send, guild}) {
lastUsedTimestamps.set(guild!.id, 0);
send("Reset the cooldown on `scanemotes`.");
}
})
}
});

View File

@ -1,19 +1,18 @@
import Command from "../../core/command";
import {CommonLibrary} from "../../core/lib";
import {Command, NamedCommand} from "onion-lasers";
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({send, 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}>`);
send(`<${body}>`);
});
});
}

View File

@ -0,0 +1,148 @@
import {NamedCommand, RestCommand} from "onion-lasers";
import {streamList} from "../../modules/streamNotifications";
import {Storage} from "../../structures";
// Alternatively, I could make descriptions last outside of just one stream.
// But then again, users could just copy paste descriptions. :leaSMUG:
// Stream presets (for permanent parts of the description) might come some time in the future.
export default new NamedCommand({
description: "Modifies the current embed for your stream",
run: "You need to specify whether to set the description or the image (`desc` and `img` respectively).",
subcommands: {
description: new NamedCommand({
aliases: ["desc"],
description:
"Sets the description of your stream. You can embed links by writing `[some name](some link)` or remove it",
usage: "(<description>)",
async run({send, author}) {
const userID = author.id;
if (streamList.has(userID)) {
const stream = streamList.get(userID)!;
stream.description = undefined;
stream.update();
send("Successfully removed the stream description.");
} else {
send("You can only use this command when streaming.");
}
},
any: new RestCommand({
async run({send, author, member, combined}) {
const userID = author.id;
if (streamList.has(userID)) {
const stream = streamList.get(userID)!;
stream.description = combined;
stream.update();
send({
content: "Successfully set the stream description to:",
embeds: [
{
description: stream.description,
color: member!.displayColor
}
]
});
} else {
send("You can only use this command when streaming.");
}
}
})
}),
thumbnail: new NamedCommand({
aliases: ["img"],
description: "Sets a thumbnail to display alongside the embed or remove it",
usage: "(<link>)",
async run({send, author}) {
const userID = author.id;
if (streamList.has(userID)) {
const stream = streamList.get(userID)!;
stream.thumbnail = undefined;
stream.update();
send("Successfully removed the stream thumbnail.");
} else {
send("You can only use this command when streaming.");
}
},
any: new RestCommand({
async run({send, author, member, combined}) {
const userID = author.id;
if (streamList.has(userID)) {
const stream = streamList.get(userID)!;
stream.thumbnail = combined;
stream.update();
send({
content: `Successfully set the stream thumbnail to: ${combined}`,
embeds: [
{
description: stream.description,
thumbnail: {url: combined},
color: member!.displayColor
}
]
});
} else {
send("You can only use this command when streaming.");
}
}
})
}),
category: new NamedCommand({
aliases: ["cat", "group"],
description:
"Sets the stream category any future streams will be in (as well as notification roles if set)",
usage: "(<category>)",
async run({send, guild, author}) {
const userID = author.id;
const memberStorage = Storage.getGuild(guild!.id).getMember(userID);
memberStorage.streamCategory = null;
Storage.save();
send("Successfully removed the category for all your current and future streams.");
// Then modify the current category if the user is streaming
if (streamList.has(userID)) {
const stream = streamList.get(userID)!;
stream.category = "None";
stream.update();
}
},
any: new RestCommand({
async run({send, guild, author, combined}) {
const userID = author.id;
const guildStorage = Storage.getGuild(guild!.id);
const memberStorage = guildStorage.getMember(userID);
let found = false;
// Check if it's a valid category
for (const [roleID, categoryName] of Object.entries(guildStorage.streamingRoles)) {
if (combined === categoryName) {
found = true;
memberStorage.streamCategory = roleID;
Storage.save();
send(
`Successfully set the category for your current and future streams to: \`${categoryName}\``
);
// Then modify the current category if the user is streaming
if (streamList.has(userID)) {
const stream = streamList.get(userID)!;
stream.category = categoryName;
stream.update();
}
}
}
if (!found) {
send(
`No valid category found by \`${combined}\`! The available categories are: \`${Object.values(
guildStorage.streamingRoles
).join(", ")}\``
);
}
}
})
})
}
});

View File

@ -0,0 +1,402 @@
import {
Command,
NamedCommand,
askForReply,
confirm,
askMultipleChoice,
getUserByNickname,
RestCommand
} from "onion-lasers";
import {Storage} from "../../structures";
import {User} from "discord.js";
import moment from "moment";
const DATE_FORMAT = "D MMMM YYYY";
const DOW_FORMAT = "dddd";
const TIME_FORMAT = "HH:mm:ss";
type DST = "na" | "eu" | "sh";
const TIME_EMBED_COLOR = 0x191970;
const DAYLIGHT_SAVINGS_REGIONS: {[region in DST]: string} = {
na: "North America",
eu: "Europe",
sh: "Southern Hemisphere"
};
const DST_NOTE_INFO = `*Note: To make things simple, the way the bot will handle specific points in time when switching Daylight Savings is just to switch at UTC 00:00, ignoring local timezones. After all, there's no need to get this down to the exact hour.*
North America
- Starts: 2nd Sunday of March
- Ends: 1st Sunday of November
Europe
- Starts: Last Sunday of March
- Ends: Last Sunday of October
Southern Hemisphere
- Starts: 1st Sunday of October
- Ends: 1st Sunday of April`;
const DST_NOTE_SETUP = `Which daylight savings region most closely matches your own?
North America (1)
- Starts: 2nd Sunday of March
- Ends: 1st Sunday of November
Europe (2)
- Starts: Last Sunday of March
- Ends: Last Sunday of October
Southern Hemisphere (3)
- Starts: 1st Sunday of October
- Ends: 1st Sunday of April`;
const DAYS_OF_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
// Returns an integer of the specific day the Sunday falls on, -1 if not found
// Also modifies the date object to the specified day as a side effect
function getSunday(date: Date, order: number) {
const daysInCurrentMonth = DAYS_OF_MONTH[date.getUTCMonth()];
let occurrencesLeft = order - 1;
// Search for the last Sunday of the month
if (order === 0) {
for (let day = daysInCurrentMonth; day >= 1; day--) {
date.setUTCDate(day);
if (date.getUTCDay() === 0) {
return day;
}
}
} else if (order > 0) {
for (let day = 1; day <= daysInCurrentMonth; day++) {
date.setUTCDate(day);
if (date.getUTCDay() === 0) {
if (occurrencesLeft > 0) {
occurrencesLeft--;
} else {
return day;
}
}
}
}
return -1;
}
// region: [firstMonth (0-11), firstOrder, secondMonth (0-11), secondOrder]
const DST_REGION_TABLE = {
na: [2, 2, 10, 1],
eu: [2, 0, 9, 0],
sh: [3, 1, 9, 1] // this one is reversed for the sake of code simplicity
};
// capturing: northern hemisphere is concave, southern hemisphere is convex
function hasDaylightSavings(region: DST) {
const [firstMonth, firstOrder, secondMonth, secondOrder] = DST_REGION_TABLE[region];
const date = new Date();
const now = date.getTime();
const currentYear = date.getUTCFullYear();
const firstDate = new Date(Date.UTC(currentYear, firstMonth));
const secondDate = new Date(Date.UTC(currentYear, secondMonth));
getSunday(firstDate, firstOrder);
getSunday(secondDate, secondOrder);
const insideBounds = now >= firstDate.getTime() && now < secondDate.getTime();
return region !== "sh" ? insideBounds : !insideBounds;
}
function getTimeEmbed(user: User) {
const {timezone, daylightSavingsRegion} = Storage.getUser(user.id);
let localDate = "N/A";
let dayOfWeek = "N/A";
let localTime = "N/A";
let timezoneOffset = "N/A";
if (timezone !== null) {
const daylightSavingsOffset = daylightSavingsRegion && hasDaylightSavings(daylightSavingsRegion) ? 1 : 0;
const daylightTimezone = timezone + daylightSavingsOffset;
const now = moment().utcOffset(daylightTimezone * 60);
localDate = now.format(DATE_FORMAT);
dayOfWeek = now.format(DOW_FORMAT);
localTime = now.format(TIME_FORMAT);
timezoneOffset = daylightTimezone >= 0 ? `+${daylightTimezone}` : daylightTimezone.toString();
}
const embed = {
color: TIME_EMBED_COLOR,
author: {
name: user.username,
icon_url: user.displayAvatarURL({
format: "png",
dynamic: true
})
},
fields: [
{
name: "Local Date",
value: localDate
},
{
name: "Day of the Week",
value: dayOfWeek
},
{
name: "Local Time",
value: localTime
},
{
name: daylightSavingsRegion !== null ? "Current Timezone Offset" : "Timezone Offset",
value: timezoneOffset
},
{
name: "Observes Daylight Savings?",
value: daylightSavingsRegion ? "Yes" : "No"
}
]
};
if (daylightSavingsRegion) {
embed.fields.push(
{
name: "Daylight Savings Active?",
value: hasDaylightSavings(daylightSavingsRegion) ? "Yes" : "No"
},
{
name: "Daylight Savings Region",
value: DAYLIGHT_SAVINGS_REGIONS[daylightSavingsRegion]
}
);
}
return embed;
}
export default new NamedCommand({
description: "Show others what time it is for you.",
aliases: ["tz"],
async run({send, author}) {
send({embeds: [getTimeEmbed(author)]});
},
subcommands: {
// Welcome to callback hell. We hope you enjoy your stay here!
setup: new NamedCommand({
description: "Registers your timezone information for the bot.",
async run({send, author}) {
const profile = Storage.getUser(author.id);
profile.timezone = null;
profile.daylightSavingsRegion = null;
// Parse and validate reply
const reply = await askForReply(
await send(
"What hour (0 to 23) is it for you right now?\n*(Note: Make sure to use Discord's inline reply feature or this won't work!)*"
),
author.id,
30000
);
if (reply === null) return send("Message timed out.");
const hour = parseInt(reply.content);
const isValidHour = !isNaN(hour) && hour >= 0 && hour <= 23;
if (!isValidHour) return reply.reply("you need to enter in a valid integer between 0 to 23");
// You need to also take into account whether or not it's the same day in UTC or not.
// The problem this setup avoids is messing up timezones by 24 hours.
// For example, the old logic was just (hour - hourUTC). When I setup my timezone (UTC-6) at 18:00, it was UTC 00:00.
// That means that that formula was doing (18 - 0) getting me UTC+18 instead of UTC-6 because that naive formula didn't take into account a difference in days.
// (day * 24 + hour) - (day * 24 + hour)
// Since the timezones will be restricted to -12 to +14, you'll be given three options.
// The end of the month should be calculated automatically, you should have enough information at that point.
// But after mapping out the edge cases, I figured out that you can safely gather accurate information just based on whether the UTC day matches the user's day.
// 21:xx (-12, -d) -- 09:xx (+0, 0d) -- 23:xx (+14, 0d)
// 22:xx (-12, -d) -- 10:xx (+0, 0d) -- 00:xx (+14, +d)
// 23:xx (-12, -d) -- 11:xx (+0, 0d) -- 01:xx (+14, +d)
// 00:xx (-12, 0d) -- 12:xx (+0, 0d) -- 02:xx (+14, +d)
// For example, 23:xx (-12) - 01:xx (+14) shows that even the edge cases of UTC-12 and UTC+14 cannot overlap, so the dataset can be reduced to a yes or no option.
// - 23:xx same day = +0, 23:xx diff day = -1
// - 00:xx same day = +0, 00:xx diff day = +1
// - 01:xx same day = +0, 01:xx diff day = +1
// First, create a tuple list matching each possible hour-dayOffset-timezoneOffset combination. In the above example, it'd go something like this:
// [[23, -1, -12], [0, 0, -11], ..., [23, 0, 12], [0, 1, 13], [1, 1, 14]]
// Then just find the matching one by filtering through dayOffset (equals zero or not), then the hour from user input.
// Remember that you don't know where the difference in day might be at this point, so you can't do (hour - hourUTC) safely.
// In terms of the equation, you're missing a variable in (--> day <-- * 24 + hour) - (day * 24 + hour). That's what the loop is for.
// Now you might be seeing a problem with setting this up at the end/beginning of a month, but that actually isn't a problem.
// Let's say that it's 00:xx of the first UTC day of a month. hourSumUTC = 24
// UTC-12 --> hourSumLowerBound (hourSumUTC - 12) = 12
// UTC+14 --> hourSumUpperBound (hourSumUTC + 14) = 38
// Remember, the nice thing about making these bounds relative to the UTC hour sum is that there can't be any conflicts even at the edges of months.
// And remember that we aren't using this question: (day * 24 + hour) - (day * 24 + hour). We're drawing from a list which does not store its data in absolute terms.
// That means there's no 31st, 1st, 2nd, it's -1, 0, +1. I just need to make sure to calculate an offset to subtract from the hour sums.
const date = new Date(); // e.g. 2021-05-01 @ 05:00
const day = date.getUTCDate(); // e.g. 1
const hourSumUTC = day * 24 + date.getUTCHours(); // e.g. 29
const timezoneTupleList: [number, number, number][] = [];
const uniques: number[] = []; // only for temporary use
const duplicates = [];
// Setup the tuple list in a separate block.
for (let timezoneOffset = -12; timezoneOffset <= 14; timezoneOffset++) {
const hourSum = hourSumUTC + timezoneOffset; // e.g. 23, UTC-6 (17 to 43)
const hour = hourSum % 24; // e.g. 23
// This works because you get the # of days w/o hours minus UTC days without hours.
// Since it's all relative to UTC, it'll end up being -1, 0, or 1.
const dayOffset = Math.floor(hourSum / 24) - day; // e.g. -1
timezoneTupleList.push([hour, dayOffset, timezoneOffset]);
if (uniques.includes(hour)) {
duplicates.push(hour);
} else {
uniques.push(hour);
}
}
// I calculate the list beforehand and check for duplicates to reduce unnecessary asking.
if (duplicates.includes(hour)) {
const isSameDay = await confirm(
await send(`Is the current day of the month the ${moment().utc().format("Do")} for you?`),
author.id
);
// Filter through isSameDay (aka !hasDayOffset) then hour from user-generated input.
// isSameDay is checked first to reduce the amount of conditionals per loop.
if (isSameDay) {
for (const [hourPoint, dayOffset, timezoneOffset] of timezoneTupleList) {
if (dayOffset === 0 && hour === hourPoint) {
profile.timezone = timezoneOffset;
}
}
} else {
for (const [hourPoint, dayOffset, timezoneOffset] of timezoneTupleList) {
if (dayOffset !== 0 && hour === hourPoint) {
profile.timezone = timezoneOffset;
}
}
}
} else {
// If it's a unique hour, just search through the tuple list and find the matching entry.
for (const [hourPoint, _dayOffset, timezoneOffset] of timezoneTupleList) {
if (hour === hourPoint) {
profile.timezone = timezoneOffset;
}
}
}
// I should note that error handling should be added sometime because await throws an exception on Promise.reject.
const hasDST = await confirm(
await send("Does your timezone change based on daylight savings?"),
author.id
);
const finalize = () => {
Storage.save();
send({
content:
"You've finished setting up your timezone! Just check to see if this looks right, and if it doesn't, run this setup again.",
embeds: [getTimeEmbed(author)]
});
};
if (hasDST) {
const finalizeDST = (region: DST) => {
profile.daylightSavingsRegion = region;
// If daylight savings is active, subtract the timezone offset by one to store the standard time.
if (hasDaylightSavings(region)) {
profile.timezone!--;
}
finalize();
};
const index = await askMultipleChoice(await send(DST_NOTE_SETUP), author.id, 3);
switch (index) {
case 0:
finalizeDST("na");
break;
case 1:
finalizeDST("eu");
break;
case 2:
finalizeDST("sh");
break;
}
} else {
finalize();
}
return;
}
}),
delete: new NamedCommand({
description: "Delete your timezone information.",
async run({send, author}) {
const result = await confirm(
await send("Are you sure you want to delete your timezone information?"),
author.id
);
if (result) {
const profile = Storage.getUser(author.id);
profile.timezone = null;
profile.daylightSavingsRegion = null;
Storage.save();
}
}
}),
utc: new NamedCommand({
description: "Displays UTC time.",
async run({send}) {
const time = moment().utc();
send({
embeds: [
{
color: TIME_EMBED_COLOR,
fields: [
{
name: "Local Date",
value: time.format(DATE_FORMAT)
},
{
name: "Day of the Week",
value: time.format(DOW_FORMAT)
},
{
name: "Local Time",
value: time.format(TIME_FORMAT)
}
]
}
]
});
}
}),
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({send, args}) {
send({embeds: [getTimeEmbed(args[0])]});
}
}),
any: new RestCommand({
description: "See what time it is for someone else (by their username).",
async run({send, guild, combined}) {
const user = await getUserByNickname(combined, guild);
if (typeof user !== "string") send({embeds: [getTimeEmbed(user)]});
else send(user);
}
})
});

View File

@ -0,0 +1,65 @@
import {NamedCommand, RestCommand} from "onion-lasers";
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({send, author}) {
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]
);
}
send({embeds: [embed]});
},
subcommands: {
add: new NamedCommand({
run: "You need to specify a note to add.",
any: new RestCommand({
async run({send, author, combined}) {
const user = Storage.getUser(author.id);
user.todoList[Date.now().toString()] = combined;
Storage.save();
send(`Successfully added \`${combined}\` to your todo list.`);
}
})
}),
remove: new NamedCommand({
run: "You need to specify a note to remove.",
any: new RestCommand({
async run({send, author, combined}) {
const user = Storage.getUser(author.id);
let isFound = false;
for (const timestamp in user.todoList) {
const selectedNote = user.todoList[timestamp];
if (selectedNote === combined) {
delete user.todoList[timestamp];
Storage.save();
isFound = true;
send(`Removed \`${combined}\` from your todo list.`);
}
}
if (!isFound) send("That item couldn't be found.");
}
})
}),
clear: new NamedCommand({
async run({send, author}) {
const user = Storage.getUser(author.id);
user.todoList = {};
Storage.save();
send("Cleared todo list.");
}
})
}
});

View File

@ -1,284 +0,0 @@
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";
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;
}
export enum TYPES {
SUBCOMMAND,
USER,
NUMBER,
ANY,
NONE
}
export default 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;
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.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;
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.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);
}
}
subdir.close();
categories.set(category, list);
} else if (selected.name.endsWith(".js")) {
loadCommand(selected.name, listMisc);
}
}
dir.close();
categories.set("Miscellaneous", listMisc);
return commands;
}
async function loadCommand(filename: string, list: string[], category?: string) {
if (!commands) return $.error(`Function "loadCommand" was called without first initializing commands!`);
const prefix = category ?? "";
const header = filename.substring(0, filename.indexOf(".js"));
const command = (await import(`../commands/${prefix}/${header}`)).default as Command | undefined;
if (!command) return $.warn(`Command "${header}" has no default export which is a Command instance!`);
command.originalCommandName = header;
list.push(header);
if (commands.has(header))
$.warn(
`Command "${header}" already exists! Make sure to make each command uniquely identifiable across categories!`
);
else commands.set(header, command);
for (const alias of command.aliases) {
if (commands.has(alias))
$.warn(`Top-level alias "${alias}" from command "${header}" already exists either as a command or alias!`);
else commands.set(alias, command);
}
$.log(`Loading Command: ${header} (${category ? $(category).toTitleCase() : "Miscellaneous"})`);
}
// 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 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> {
},
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> {
}
})
},
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> {
}
})
});`;

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

@ -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)
};

View File

@ -1,76 +0,0 @@
import {GuildMember, Permissions} from "discord.js";
import {Config} from "./structures";
import $ from "./lib";
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;
return false;
}
export function getPermissionLevel(member: GuildMember): number {
for (let i = length - 1; i >= 0; i--) if (PermissionChecker[i](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!`
);
})();

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,6 +1,6 @@
// Flags a user can have.
// They're basically your profile badges.
export const flags: {[index: string]: any} = {
export const flags: {[index: string]: string} = {
DISCORD_EMPLOYEE: "Discord Employee",
DISCORD_PARTNER: "Discord Partner",
BUGHUNTER_LEVEL_1: "Bug Hunter (Level 1)",
@ -16,13 +16,13 @@ export const flags: {[index: string]: any} = {
VERIFIED_DEVELOPER: "Verified Bot Developer"
};
export const filterLevels: {[index: string]: any} = {
export const filterLevels: {[index: string]: string} = {
DISABLED: "Off",
MEMBERS_WITHOUT_ROLES: "No Role",
ALL_MEMBERS: "Everyone"
};
export const verificationLevels: {[index: string]: any} = {
export const verificationLevels: {[index: string]: string} = {
NONE: "None",
LOW: "Low",
MEDIUM: "Medium",
@ -30,7 +30,7 @@ export const verificationLevels: {[index: string]: any} = {
VERY_HIGH: "┻━┻ ミヽ(ಠ益ಠ)ノ彡┻━┻"
};
export const regions: {[index: string]: any} = {
export const regions: {[index: string]: string} = {
brazil: "Brazil",
europe: "Europe",
hongkong: "Hong Kong",

4
src/defs/petpet.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
declare module "pet-pet-gif" {
function petPetGif(image: string): Promise<Buffer>;
export = petPetGif;
}

17
src/defs/urban.d.ts vendored Normal file
View File

@ -0,0 +1,17 @@
interface Definition {
id: number;
word: string;
thumbsUp: number;
thumbsDown: number;
author: string;
urbanURL: string;
example: string;
definition: string;
tags: string[] | null;
sounds: string[] | null;
}
declare module "relevant-urban" {
function urban(query: string): Promise<Definition>;
export = urban;
}

92
src/defs/weather.d.ts vendored Normal file
View File

@ -0,0 +1,92 @@
interface WeatherJSOptions {
search: string;
lang?: string;
degreeType?: string;
timeout?: number;
}
interface WeatherJSResult {
location: {
name: string;
lat: string;
long: string;
timezone: string;
alert: string;
degreetype: string;
imagerelativeurl: string;
};
current: {
temperature: string;
skycode: string;
skytext: string;
date: string;
observationtime: string;
observationpoint: string;
feelslike: string;
humidity: string;
winddisplay: string;
day: string;
shortday: string;
windspeed: string;
imageUrl: string;
};
forecast: [
{
low: string;
high: string;
skycodeday: string;
skytextday: string;
date: string;
day: string;
shortday: string;
precip: string;
},
{
low: string;
high: string;
skycodeday: string;
skytextday: string;
date: string;
day: string;
shortday: string;
precip: string;
},
{
low: string;
high: string;
skycodeday: string;
skytextday: string;
date: string;
day: string;
shortday: string;
precip: string;
},
{
low: string;
high: string;
skycodeday: string;
skytextday: string;
date: string;
day: string;
shortday: string;
precip: string;
},
{
low: string;
high: string;
skycodeday: string;
skytextday: string;
date: string;
day: string;
shortday: string;
precip: string;
}
];
}
declare module "weather-js" {
const find: (
options: WeatherJSOptions,
callback: (error: Error | string | null, result: WeatherJSResult[]) => any
) => void;
}

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
},
$
)
);
}
});

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