Merge pull request #3 from keanuplayz/restructure

Restructure
This commit is contained in:
Keanu Timmermans 2020-07-29 14:00:19 +02:00 committed by GitHub
commit 01d4398b53
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 2253 additions and 4 deletions

12
.gitignore vendored
View file

@ -1,13 +1,17 @@
# Specific to this repository
dist/
data/
tmp/
test*
*.bat
desktop.ini
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
config.json
config.js
data/
test.js
# Runtime data
pids

0
docs/CHANGELOG.md Normal file
View file

37
docs/Specifications.md Normal file
View file

@ -0,0 +1,37 @@
# 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`.)
- `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.

532
package-lock.json generated Normal file
View file

@ -0,0 +1,532 @@
{
"name": "d.js-v12-bot",
"version": "0.0.1",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@discordjs/collection": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-0.1.5.tgz",
"integrity": "sha512-CU1q0UXQUpFNzNB7gufgoisDHP7n+T3tkqTsp3MNUkVJ5+hS3BCvME8uCXAUFlz+6T2FbTCu75A+yQ7HMKqRKw=="
},
"@discordjs/form-data": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@discordjs/form-data/-/form-data-3.0.1.tgz",
"integrity": "sha512-ZfFsbgEXW71Rw/6EtBdrP5VxBJy4dthyC0tpQKGKmYFImlmmrykO14Za+BiIVduwjte0jXEBlhSKf0MWbFp9Eg==",
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
}
},
"@types/color-name": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
"integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ=="
},
"@types/inquirer": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@types/inquirer/-/inquirer-6.5.0.tgz",
"integrity": "sha512-rjaYQ9b9y/VFGOpqBEXRavc3jh0a+e6evAbI31tMda8VlPaSy0AZJfXsvmIe3wklc7W6C3zCSfleuMXR7NOyXw==",
"dev": true,
"requires": {
"@types/through": "*",
"rxjs": "^6.4.0"
}
},
"@types/node": {
"version": "14.0.22",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.0.22.tgz",
"integrity": "sha512-emeGcJvdiZ4Z3ohbmw93E/64jRzUHAItSHt8nF7M4TGgQTiWqFVGB8KNpLGFmUHmHLvjvBgFwVlqNcq+VuGv9g==",
"dev": true
},
"@types/through": {
"version": "0.0.30",
"resolved": "https://registry.npmjs.org/@types/through/-/through-0.0.30.tgz",
"integrity": "sha512-FvnCJljyxhPM3gkRgWmxmDZyAQSiBQQWLI0A0VFL0K7W1oRUrPJSqNO0NvTnLkBcotdlp3lKvaT0JrnyRDkzOg==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/ws": {
"version": "7.2.6",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.2.6.tgz",
"integrity": "sha512-Q07IrQUSNpr+cXU4E4LtkSIBPie5GLZyyMC1QtQYRLWz701+XcoVygGUZgvLqElq1nU4ICldMYPnexlBsg3dqQ==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"requires": {
"event-target-shim": "^5.0.0"
}
},
"ansi-escapes": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.1.tgz",
"integrity": "sha512-JWF7ocqNrp8u9oqpgV+wH5ftbt+cfvv+PTjOvKLT3AdYly/LmORARfEVT1iyjwN+4MqE5UmVKoAdIBqeoCHgLA==",
"requires": {
"type-fest": "^0.11.0"
}
},
"ansi-regex": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg=="
},
"ansi-styles": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz",
"integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==",
"requires": {
"@types/color-name": "^1.1.1",
"color-convert": "^2.0.1"
}
},
"asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
},
"chalk": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz",
"integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==",
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
}
},
"chardet": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz",
"integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="
},
"cli-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
"integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==",
"requires": {
"restore-cursor": "^3.1.0"
}
},
"cli-width": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz",
"integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw=="
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"requires": {
"delayed-stream": "~1.0.0"
}
},
"cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"dev": true,
"requires": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
}
},
"delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="
},
"discord.js": {
"version": "12.2.0",
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-12.2.0.tgz",
"integrity": "sha512-Ueb/0SOsxXyqwvwFYFe0msMrGqH1OMqpp2Dpbplnlr4MzcRrFWwsBM9gKNZXPVBHWUKiQkwU8AihXBXIvTTSvg==",
"requires": {
"@discordjs/collection": "^0.1.5",
"@discordjs/form-data": "^3.0.1",
"abort-controller": "^3.0.0",
"node-fetch": "^2.6.0",
"prism-media": "^1.2.0",
"setimmediate": "^1.0.5",
"tweetnacl": "^1.0.3",
"ws": "^7.2.1"
},
"dependencies": {
"prism-media": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.2.2.tgz",
"integrity": "sha512-I+nkWY212lJ500jLe4tN9tWO7nRiBAVdMv76P9kffZjYhw20raMlW1HSSvS+MLXC9MmbNZCazMrAr+5jEEgTuw=="
},
"ws": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.3.0.tgz",
"integrity": "sha512-iFtXzngZVXPGgpTlP1rBqsUK82p9tKqsWRPg5L56egiljujJT3vGAYnHANvFxBieXrTFavhzhxW52jnaWV+w2w=="
}
}
},
"duplexer": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz",
"integrity": "sha1-rOb/gIwc5mtX0ev5eXessCM0z8E=",
"dev": true
},
"emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
},
"escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ="
},
"event-stream": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz",
"integrity": "sha1-SrTJoPWlTbkzi0w02Gv86PSzVXE=",
"dev": true,
"requires": {
"duplexer": "~0.1.1",
"from": "~0",
"map-stream": "~0.1.0",
"pause-stream": "0.0.11",
"split": "0.3",
"stream-combiner": "~0.0.4",
"through": "~2.3.1"
}
},
"event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="
},
"external-editor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz",
"integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==",
"requires": {
"chardet": "^0.7.0",
"iconv-lite": "^0.4.24",
"tmp": "^0.0.33"
}
},
"figures": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
"integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==",
"requires": {
"escape-string-regexp": "^1.0.5"
}
},
"from": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz",
"integrity": "sha1-g8YK/Fi5xWmXAH7Rp2izqzA6RP4=",
"dev": true
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
},
"iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"requires": {
"safer-buffer": ">= 2.1.2 < 3"
}
},
"inquirer": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.3.1.tgz",
"integrity": "sha512-/+vOpHQHhoh90Znev8BXiuw1TDQ7IDxWsQnFafUEoK5+4uN5Eoz1p+3GqOj/NtzEi9VzWKQcV9Bm+i8moxedsA==",
"requires": {
"ansi-escapes": "^4.2.1",
"chalk": "^4.1.0",
"cli-cursor": "^3.1.0",
"cli-width": "^3.0.0",
"external-editor": "^3.0.3",
"figures": "^3.0.0",
"lodash": "^4.17.16",
"mute-stream": "0.0.8",
"run-async": "^2.4.0",
"rxjs": "^6.6.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0",
"through": "^2.3.6"
}
},
"is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="
},
"isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=",
"dev": true
},
"lodash": {
"version": "4.17.19",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz",
"integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ=="
},
"map-stream": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz",
"integrity": "sha1-5WqpTEyAVaFkBKBnS3jyFffI4ZQ=",
"dev": true
},
"mime-db": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz",
"integrity": "sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg=="
},
"mime-types": {
"version": "2.1.27",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz",
"integrity": "sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==",
"requires": {
"mime-db": "1.44.0"
}
},
"mimic-fn": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="
},
"mute-stream": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="
},
"node-cleanup": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/node-cleanup/-/node-cleanup-2.1.2.tgz",
"integrity": "sha1-esGavSl+Caf3KnFUXZUbUX5N3iw=",
"dev": true
},
"node-fetch": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz",
"integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA=="
},
"onetime": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.0.tgz",
"integrity": "sha512-5NcSkPHhwTVFIQN+TUqXoS5+dlElHXdpAWu9I0HP20YOtIi+aZ0Ct82jdlILDxjLEAWwvm+qj1m6aEtsDVmm6Q==",
"requires": {
"mimic-fn": "^2.1.0"
}
},
"os-tmpdir": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
"integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ="
},
"path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true
},
"pause-stream": {
"version": "0.0.11",
"resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz",
"integrity": "sha1-/lo0sMvOErWqaitAPuLnO2AvFEU=",
"dev": true,
"requires": {
"through": "~2.3"
}
},
"ps-tree": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/ps-tree/-/ps-tree-1.2.0.tgz",
"integrity": "sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==",
"dev": true,
"requires": {
"event-stream": "=3.3.4"
}
},
"restore-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
"integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==",
"requires": {
"onetime": "^5.1.0",
"signal-exit": "^3.0.2"
}
},
"run-async": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz",
"integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ=="
},
"rxjs": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.0.tgz",
"integrity": "sha512-3HMA8z/Oz61DUHe+SdOiQyzIf4tOx5oQHmMir7IZEu6TMqCLHT4LRcmNaUS0NwOz8VLvmmBduMsoaUvMaIiqzg==",
"requires": {
"tslib": "^1.9.0"
}
},
"safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU="
},
"shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"requires": {
"shebang-regex": "^3.0.0"
}
},
"shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true
},
"signal-exit": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz",
"integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA=="
},
"split": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz",
"integrity": "sha1-zQ7qXmOiEd//frDwkcQTPi0N0o8=",
"dev": true,
"requires": {
"through": "2"
}
},
"stream-combiner": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz",
"integrity": "sha1-TV5DPBhSYd3mI8o/RMWGvPXErRQ=",
"dev": true,
"requires": {
"duplexer": "~0.1.1"
}
},
"string-argv": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.1.2.tgz",
"integrity": "sha512-mBqPGEOMNJKXRo7z0keX0wlAhbBAjilUdPW13nN0PecVryZxdHIeM7TqbsSUA7VYuS00HGC6mojP7DlQzfa9ZA==",
"dev": true
},
"string-width": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz",
"integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==",
"requires": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.0"
}
},
"strip-ansi": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
"integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==",
"requires": {
"ansi-regex": "^5.0.0"
}
},
"supports-color": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz",
"integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==",
"requires": {
"has-flag": "^4.0.0"
}
},
"through": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
"integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU="
},
"tmp": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
"integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
"requires": {
"os-tmpdir": "~1.0.2"
}
},
"tsc-watch": {
"version": "4.2.9",
"resolved": "https://registry.npmjs.org/tsc-watch/-/tsc-watch-4.2.9.tgz",
"integrity": "sha512-DlTaoDs74+KUpyWr7dCGhuscAUKCz6CiFduBN7R9RbLJSSN1moWdwoCLASE7+zLgGvV5AwXfYDiEMAsPGaO+Vw==",
"dev": true,
"requires": {
"cross-spawn": "^7.0.3",
"node-cleanup": "^2.1.2",
"ps-tree": "^1.2.0",
"string-argv": "^0.1.1",
"strip-ansi": "^6.0.0"
}
},
"tslib": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.13.0.tgz",
"integrity": "sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q=="
},
"tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="
},
"type-fest": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.11.0.tgz",
"integrity": "sha512-OdjXJxnCN1AvyLSzeKIgXTXxV+99ZuXl3Hpo9XpJAv9MBcHrrJOQ5kV7ypXOuQie+AmWG25hLbiKdwYTifzcfQ=="
},
"typescript": {
"version": "3.9.6",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.6.tgz",
"integrity": "sha512-Pspx3oKAPJtjNwE92YS05HQoY7z2SFyOpHo9MqJor3BXAGNaPUs83CuVp9VISFkSjyRfiTpmKuAYGJB7S7hOxw==",
"dev": true
},
"which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"requires": {
"isexe": "^2.0.0"
}
}
}
}

31
package.json Normal file
View file

@ -0,0 +1,31 @@
{
"name": "d.js-v12-bot",
"version": "0.0.1",
"description": "A Discord bot built on Discord.JS v12",
"main": "dist/index.js",
"dependencies": {
"chalk": "^4.1.0",
"discord.js": "^12.2.0",
"inquirer": "^7.3.1"
},
"devDependencies": {
"@types/inquirer": "^6.5.0",
"@types/node": "^14.0.22",
"@types/ws": "^7.2.6",
"tsc-watch": "^4.2.9",
"typescript": "^3.9.6"
},
"scripts": {
"build": "tsc",
"watch": "tsc --watch",
"autobuild": "tsc && npm start",
"start": "node dist/index.js",
"dev": "tsc-watch --onSuccess \"node dist/index.js dev\""
},
"keywords": [
"discord.js",
"bot"
],
"author": "Keanu Timmermans",
"license": "MIT"
}

72
src/commands/admin.ts Normal file
View file

@ -0,0 +1,72 @@
import Command from "../core/command";
import {CommonLibrary, logs} from "../core/lib";
import {Config, Storage} from "../core/structures";
import {PermissionNames, getPermissionLevel} from "../core/permissions";
function getLogBuffer(type: string)
{
return {files: [{
attachment: Buffer.alloc(logs[type].length, logs[type]),
name: `${Date.now()}.${type}.log`
}]};
}
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]}\`.`);
}
})
})
}
}),
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)}]\``,
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)}]\`.`);
}
})
})
}
});

107
src/commands/help.ts Normal file
View file

@ -0,0 +1,107 @@
import Command from "../core/command";
import {CommonLibrary} from "../core/lib";
import {loadCommands, categories} from "../core/command";
import {PermissionNames} from "../core/permissions";
const types = ["user", "number", "any"];
export default new Command({
description: "Lists all commands. If a command is specified, their arguments are listed as well.",
usage: "([command, [subcommand/type], ...])",
async run($: CommonLibrary): Promise<any>
{
const commands = await loadCommands();
let output = `Legend: \`<type>\`, \`[list/of/subcommands]\`, \`(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();
let command = commands.get(header);
if(!command || header === "test")
return $.channel.send(`No command found by the name \`${header}\`!`);
let permLevel = command.permission ?? Command.PERMISSIONS.NONE;
let usage = command.usage;
let invalid = false;
for(const param of $.args)
{
const type = command.resolve(param);
switch(type)
{
case Command.TYPES.SUBCOMMAND: header += ` ${param}`; 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;
}
command = command.get(param);
permLevel = command.permission ?? permLevel;
}
if(invalid)
return $.channel.send(`No command found by the name \`${header}\`!`);
let append = "";
if(usage === "")
{
const list: string[] = [];
for(const subtag in command.subcommands)
{
const subcmd = command.subcommands[subtag];
const customUsage = subcmd.usage ? ` ${subcmd.usage}` : "";
list.push(`- \`${header} ${subtag}${customUsage}\` - ${subcmd.description}`);
}
for(const type of types)
{
if(command[type])
{
const cmd = command[type];
const customUsage = cmd.usage ? ` ${cmd.usage}` : "";
list.push(`- \`${header} <${type}>${customUsage}\` - ${cmd.description}`);
}
}
append = "Usages:" + (list.length > 0 ? `\n${list.join('\n')}` : " None.");
}
else
append = `Usage: \`${header} ${usage}\``;
$.channel.send(`Command: \`${header}\`\nPermission Required: \`${PermissionNames[permLevel]}\` (${permLevel})\nDescription: ${command.description}\n${append}`, {split: true});
}
})
});

241
src/commands/money.ts Normal file
View file

@ -0,0 +1,241 @@
import Command from "../core/command";
import $, {CommonLibrary} from "../core/lib";
import {Storage} from "../core/structures";
import {User} from "discord.js";
export function getMoneyEmbed(user: User): object
{
const profile = Storage.getUser(user.id);
return {embed: {
color: 0xFFFF00,
author:
{
name: user.username,
icon_url: user.displayAvatarURL({
format: "png",
dynamic: true
})
},
fields:
[
{
name: "Balance",
value: $(profile.money).pluralise("credit", "s")
}
]
}};
}
function getSendEmbed(sender: User, receiver: User, amount: number): object
{
return {embed: {
color: 0xFFFF00,
author:
{
name: sender.username,
icon_url: sender.displayAvatarURL({
format: "png",
dynamic: true
})
},
title: "Transaction",
description: `${sender.toString()} has sent ${$(amount).pluralise("credit", "s")} to ${receiver.toString()}!`,
fields:
[
{
name: `Sender: ${sender.username}#${sender.discriminator}`,
value: $(Storage.getUser(sender.id).money).pluralise("credit", "s")
},
{
name: `Receiver: ${receiver.username}#${receiver.discriminator}`,
value: $(Storage.getUser(receiver.id).money).pluralise("credit", "s")
}
],
footer:
{
text: receiver.username,
icon_url: receiver.displayAvatarURL({
format: "png",
dynamic: true
})
}
}};
}
export default new Command({
description: "See how much money you have. Also provides other commands related to money.",
async run($: CommonLibrary): Promise<any>
{
$.channel.send(getMoneyEmbed($.author));
},
subcommands:
{
get: new Command({
description: "Pick up your daily credits. The cooldown is per user and every 22 hours to allow for some leeway.",
async run($: CommonLibrary): Promise<any>
{
const user = Storage.getUser($.author.id);
const now = Date.now();
if(user.lastReceived === -1)
{
user.money = 100;
user.lastReceived = now;
Storage.save();
$.channel.send("Here's 100 credits to get started, the price of a sandwich in Rookie Harbor.", getMoneyEmbed($.author));
}
else if(now - user.lastReceived >= 79200000)
{
user.money += 25;
user.lastReceived = now;
Storage.save();
$.channel.send("Here's your daily 25 credits.", getMoneyEmbed($.author));
}
else
$.channel.send(`It's too soon to pick up your daily credits. You have about ${((user.lastReceived + 79200000 - now) / 3600000).toFixed(1)} hours to go.`);
}
}),
send: 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($: CommonLibrary): Promise<any>
{
const amount = Math.floor($.args[1]);
const author = $.author;
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 credit!");
else if(sender.money < amount)
return $.channel.send("You don't have enough money to do that!", getMoneyEmbed(author));
else if(target.id === author.id)
return $.channel.send("You can't send money to yourself!");
else if(target.bot && process.argv[2] !== "dev")
return $.channel.send("You can't send money to a bot!");
sender.money -= amount;
receiver.money += amount;
Storage.save();
$.channel.send(getSendEmbed(author, target, amount));
}
})
}),
number: new Command({
run: "You must use the format `money send <user> <amount>`!"
}),
any: new Command({
async run($: CommonLibrary): Promise<any>
{
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 author = $.author;
const sender = Storage.getUser(author.id);
if(amount <= 0)
return $.channel.send("You must send at least one credit!");
else if(sender.money < amount)
return $.channel.send("You don't have enough money to do that!", getMoneyEmbed(author));
else if(!$.guild)
return $.channel.send("You have to use this in a server if you want to send money 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 money 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 money to yourself!");
else if(member.user.bot && process.argv[2] !== "dev")
return $.channel.send("You can't send money to a bot!");
const target = member.user;
$.prompt(await $.channel.send(`Are you sure you want to send ${$(amount).pluralise("credit", "s")} to this person?\n*(This message will automatically be deleted after 10 seconds.)*`, {embed: {
color: "#ffff00",
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));
});
}
})
}),
leaderboard: new Command({
description: "See the richest players tracked by this bot (across servers).",
async run($: CommonLibrary): Promise<any>
{
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("credit", "s")
});
}
$.channel.send({embed: {
title: "Top 10 Richest Players",
color: "#ffff00",
fields: fields
}});
}
})
},
user: new Command({
description: "See how much money someone else has by using their user ID or pinging them.",
async run($: CommonLibrary): Promise<any>
{
$.channel.send(getMoneyEmbed($.args[0]));
}
}),
any: new Command({
description: "See how much money someone else has by using their username.",
async run($: CommonLibrary): Promise<any>
{
if($.guild)
{
const username = $.args.join(" ");
const member = (await $.guild.members.fetch({
query: username,
limit: 1
})).first();
if(member)
$.channel.send(getMoneyEmbed(member.user));
else
$.channel.send(`Couldn't find a user by the name of \`${username}\`!`);
}
}
})
});

248
src/core/command.ts Normal file
View file

@ -0,0 +1,248 @@
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";
interface CommandOptions
{
description?: string;
endpoint?: boolean;
usage?: string;
permission?: PERMISSIONS;
run?: Function|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;
private run: Function|string;
public subcommands: {[key: string]: Command}|null;
public user: Command|null;
public number: Command|null;
public any: Command|null;
[key: string]: any; // Allow for dynamic indexing. The CommandOptions interface will still prevent users from adding unused properties though.
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.run = options?.run || "No action was set on this command!";
this.subcommands = options?.subcommands || null;
this.user = options?.user || null;
this.number = options?.number || null;
this.any = options?.any || null;
}
public execute($: CommonLibrary)
{
if(isType(this.run, String))
{
$.channel.send(parseVars(this.run as string, {
author: $.author.toString()
}, "???"));
}
else
(this.run as Function)($).catch($.handler.bind($));
}
/**
* Set what happens when the command is called.
* - If the command is a function, run it with one argument (the common library).
* - If the command is a string, it'll be sent as a message with %variables% replaced.
*/
public set(run: Function|string)
{
this.run = run;
}
/** The safe way to attach a named subcommand. */
public attach(key: string, command: Command)
{
if(!this.subcommands)
this.subcommands = {};
this.subcommands[key] = command;
}
public resolve(param: string): TYPES
{
if(this.subcommands?.[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;
switch(type)
{
case TYPES.SUBCOMMAND: command = this.subcommands![param]; 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();
/** 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 = getTitleCase(selected.name);
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
{
const header = cmd.name.substring(0, cmd.name.indexOf(".js"));
const command = (await import(`../commands/${selected.name}/${header}`)).default;
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);
$.log(`Loading Command: ${header} (${category})`);
}
}
subdir.close();
categories.set(category, list);
}
else
{
const header = selected.name.substring(0, selected.name.indexOf(".js"));
const command = (await import(`../commands/${header}`)).default;
listMisc.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);
$.log(`Loading Command: ${header} (Miscellaneous)`);
}
}
dir.close();
categories.set("Miscellaneous", listMisc);
return commands;
}
function getTitleCase(name: string): string
{
if(name.length < 1)
return name;
const first = name[0].toUpperCase();
return first + name.substring(1);
}
// 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: '',
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: '',
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: '',
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: '',
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: '',
async run($: CommonLibrary): Promise<any> {
}
})
});`;

50
src/core/event.ts Normal file
View file

@ -0,0 +1,50 @@
import {Client} from "discord.js";
import Storage from "./storage";
import $ from "./lib";
// Last Updated: Discord.js v12.2.0
const EVENTS = ["channelCreate", "channelDelete", "channelPinsUpdate", "channelUpdate", "debug", "warn", "disconnect", "emojiCreate", "emojiDelete", "emojiUpdate", "error", "guildBanAdd", "guildBanRemove", "guildCreate", "guildDelete", "guildUnavailable", "guildIntegrationsUpdate", "guildMemberAdd", "guildMemberAvailable", "guildMemberRemove", "guildMembersChunk", "guildMemberSpeaking", "guildMemberUpdate", "guildUpdate", "inviteCreate", "inviteDelete", "message", "messageDelete", "messageReactionRemoveAll", "messageReactionRemoveEmoji", "messageDeleteBulk", "messageReactionAdd", "messageReactionRemove", "messageUpdate", "presenceUpdate", "rateLimit", "ready", "invalidated", "roleCreate", "roleDelete", "roleUpdate", "typingStart", "userUpdate", "voiceStateUpdate", "webhookUpdate", "shardDisconnect", "shardError", "shardReady", "shardReconnecting", "shardResume"];
interface EventOptions
{
readonly on?: Function;
readonly once?: Function;
}
export default class Event
{
private readonly on: Function|null;
private readonly once: Function|null;
constructor(options: EventOptions)
{
this.on = options.on || null;
this.once = options.once || null;
}
// 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: string)
{
if(this.on)
client.on(event as any, this.on as any);
if(this.once)
client.once(event as any, this.once as any);
}
}
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(EVENTS.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.)`);
}
}

372
src/core/lib.ts Normal file
View file

@ -0,0 +1,372 @@
import {GenericWrapper, NumberWrapper, ArrayWrapper} from "./wrappers";
import {Client, Message, TextChannel, DMChannel, NewsChannel, Guild, User, GuildMember} from "discord.js";
import chalk from "chalk";
import FileManager from "./storage";
import {eventListeners} from "../events/messageReactionRemove";
/** 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;
<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;
// 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 $<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, 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 = (...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}`;
}
// 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 handle = (emote: string, reacterID: string) => {
if(reacterID === senderID)
{
switch(emote)
{
case '⬅️': turn(-1); break;
case '➡️': turn(1); break;
}
}
};
// Listen for reactions and call the handler.
await message.react('⬅️');
await message.react('➡️');
eventListeners.set(message.id, handle);
await message.awaitReactions((reaction, user) => {
handle(reaction.emoji.name, user.id);
return false;
}, {time: duration});
// When time's up, remove the bot's own reactions.
eventListeners.delete(message.id);
message.reactions.cache.get('⬅️')?.users.remove(message.author);
message.reactions.cache.get('➡️')?.users.remove(message.author);
};
// Waits for the sender to either confirm an action or let it pass (and delete the message).
$.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();
};
/**
* 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|undefined = ""): 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 === undefined || invalid === null)
result += `%${token}%`;
else
result += invalid;
token = "";
}
}
inVariable = !inVariable;
}
else if(inVariable)
token += c;
else
result += c;
}
return result;
}
/**
* Split up an array into a specified length.
* [1,2,3,4,5,6,7,8,9,10] split by 3 = [[1,2,3],[4,5,6],[7,8,9],[10]]
*/
export function perforate<T>(list: T[], lengthOfEachSection: number): T[][]
{
const sections: T[][] = [];
const amountOfSections = Math.ceil(list.length / lengthOfEachSection);
for(let index = 0; index < amountOfSections; index++)
sections.push(list.slice(index * lengthOfEachSection, (index + 1) * lengthOfEachSection));
return sections;
}
export function isType(value: any, type: Function): 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 interface GenericJSON
{
[key: string]: any;
}
export abstract class GenericStructure
{
protected __meta__ = "generic";
constructor(tag?: string)
{
this.__meta__ = tag || this.__meta__;
}
public save(asynchronous = true)
{
const tag = this.__meta__;
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)
};

76
src/core/permissions.ts Normal file
View file

@ -0,0 +1,76 @@
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: GuildMember) =>
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: GuildMember) => member.hasPermission(Permissions.FLAGS.ADMINISTRATOR),
// OWNER //
(member: GuildMember) => member.guild.ownerID === member.id,
// BOT_SUPPORT //
(member: GuildMember) => Config.support.includes(member.id),
// BOT_ADMIN //
(member: GuildMember) => Config.admins.includes(member.id),
// BOT_OWNER //
(member: GuildMember) => 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
{
if(permission === PERMISSIONS.NONE)
return true;
for(let i = length-1; i >= permission; i--)
{
const condition = PermissionChecker[i](member);
if(condition)
return true;
}
return false;
}
export function getPermissionLevel(member: GuildMember): number
{
for(let i = length-1; i >= 0; i--)
{
const condition = PermissionChecker[i](member);
if(condition)
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!`);
})()

84
src/core/storage.ts Normal file
View file

@ -0,0 +1,84 @@
import fs from "fs";
import $ from "./lib";
const Storage = {
read(header: string): object
{
this.open("data");
const path = `data/${header}.json`;
let data = {};
if(fs.existsSync(path))
{
const file = fs.readFileSync(path, "utf-8");
try
{
data = JSON.parse(file);
}
catch(error)
{
if(process.argv[2] !== "dev")
{
$.warn(`Malformed JSON data (header: ${header}), backing it up.`, file);
fs.writeFile(`${path}.backup`, file, generateHandler(`Backup file of "${header}" successfully written as ${file}.`));
}
}
}
return data;
},
write(header: string, data: object, asynchronous = true)
{
this.open("data");
const path = `data/${header}.json`;
if(process.argv[2] === "dev" || header === "config")
{
const result = JSON.stringify(data, null, '\t');
if(asynchronous)
fs.writeFile(path, result, generateHandler(`"${header}" sucessfully spaced and written.`));
else
fs.writeFileSync(path, result);
}
else
{
const result = JSON.stringify(data);
if(asynchronous)
fs.writeFile(path, result, generateHandler(`"${header}" sucessfully written.`));
else
fs.writeFileSync(path, result);
}
},
open(path: string, filter?: (value: string, index: number, array: string[]) => unknown): string[]
{
if(!fs.existsSync(path))
fs.mkdirSync(path);
let directory = fs.readdirSync(path);
if(filter)
directory = directory.filter(filter);
return directory;
},
close(path: string)
{
if(fs.existsSync(path) && fs.readdirSync(path).length === 0)
fs.rmdir(path, generateHandler(`"${path}" successfully closed.`));
}
};
export function generateHandler(message: string)
{
return (error: Error|null) => {
if(error)
$.error(error);
else
$.debug(message);
};
};
export default Storage;

117
src/core/structures.ts Normal file
View file

@ -0,0 +1,117 @@
import FileManager from "./storage";
import $, {select, GenericJSON, GenericStructure} from "./lib";
import {watch} from "fs";
class ConfigStructure extends GenericStructure
{
public token: string;
public prefix: string;
public owner: string;
public admins: string[];
public support: string[];
constructor(data: GenericJSON)
{
super("config");
this.token = select(data.token, "<ENTER YOUR TOKEN HERE>", String);
this.prefix = select(data.prefix, "$", String);
this.owner = select(data.owner, "", String);
this.admins = select(data.admins, [], String, true);
this.support = select(data.support, [], String, true);
}
}
class User
{
public money: number;
public lastReceived: number;
constructor(data?: GenericJSON)
{
this.money = select(data?.money, 0, Number);
this.lastReceived = select(data?.lastReceived, -1, Number);
}
}
class Guild
{
public prefix: string|null;
constructor(data?: GenericJSON)
{
this.prefix = select(data?.prefix, null, String);
}
}
class StorageStructure extends GenericStructure
{
public users: {[id: string]: User};
public guilds: {[id: string]: Guild};
constructor(data: GenericJSON)
{
super("storage");
this.users = {};
this.guilds = {};
for(let id in data.users)
if(/\d{17,19}/g.test(id))
this.users[id] = new User(data.users[id]);
for(let id in data.guilds)
if(/\d{17,19}/g.test(id))
this.guilds[id] = new Guild(data.guilds[id]);
}
/** Gets a user's profile if they exist and generate one if not. */
public getUser(id: string): User
{
if(!/\d{17,19}/g.test(id))
$.warn(`"${id}" is not a valid user ID! It will be erased when the data loads again.`);
if(id in this.users)
return this.users[id];
else
{
const user = new User();
this.users[id] = user;
return user;
}
}
/** Gets a guild's settings if they exist and generate one if not. */
public getGuild(id: string): Guild
{
if(!/\d{17,19}/g.test(id))
$.warn(`"${id}" is not a valid guild ID! It will be erased when the data loads again.`);
if(id in this.guilds)
return this.guilds[id];
else
{
const guild = new Guild();
this.guilds[id] = guild;
return guild;
}
}
}
// Exports instances. Don't worry, importing it from different files will load the same instance.
export let Config = new ConfigStructure(FileManager.read("config"));
export let Storage = new StorageStructure(FileManager.read("storage"));
// This part will allow the user to manually edit any JSON files they want while the program is running which'll update the program's cache.
// However, fs.watch is a buggy mess that should be avoided in production. While it helps test out stuff for development, it's not a good idea to have it running outside of development as it causes all sorts of issues.
if(process.argv[2] === "dev")
{
watch("data", (event, filename) => {
$.debug("File Watcher:", event, filename);
const header = filename.substring(0, filename.indexOf(".json"));
switch(header)
{
case "config": Config = new ConfigStructure(FileManager.read("config")); break;
case "storage": Storage = new StorageStructure(FileManager.read("storage")); break;
}
});
}

51
src/core/wrappers.ts Normal file
View file

@ -0,0 +1,51 @@
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 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)];
}
}

102
src/events/message.ts Normal file
View file

@ -0,0 +1,102 @@
import Event from "../core/event";
import Command, {loadCommands} from "../core/command";
import {hasPermission, getPermissionLevel, PermissionNames} from "../core/permissions";
import $ from "../core/lib";
import {Message, Permissions, Collection} from "discord.js";
import {Config, Storage} from "../core/structures";
// 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({
async on(message: Message)
{
// 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;
const prefix = Storage.getGuild(message.guild?.id || "N/A").prefix || Config.prefix;
if(!message.content.startsWith(prefix))
return;
const [header, ...args] = message.content.substring(prefix.length).split(/ +/);
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 || command.user || command.number || command.any)
$.warn(`An endpoint cannot have subcommands! Check ${prefix}${header} again.`);
isEndpoint = true;
break;
}
const type = command.resolve(param);
command = command.get(param);
permLevel = command.permission ?? permLevel;
if(type === Command.TYPES.USER)
{
const id = param.match(/\d+/g)![0];
try {params.push(await message.client.users.fetch(id))}
catch(error) {return message.channel.send(`No user found by the ID \`${id}\`!`)}
}
else if(type === Command.TYPES.NUMBER)
params.push(Number(param));
else if(type !== Command.TYPES.SUBCOMMAND)
params.push(param);
}
if(!message.member)
return $.warn("This command was likely called from a DM channel meaning the member object is null.");
if(!hasPermission(message.member, permLevel))
{
const userPermLevel = getPermissionLevel(message.member);
return message.channel.send(`You don't have access to this command! Your permission level is \`${PermissionNames[userPermLevel]}\` (${userPermLevel}), but this command requires a permission level of \`${PermissionNames[permLevel]}\` (${permLevel}).`);
}
if(isEndpoint)
return message.channel.send("Too many arguments!");
// Execute with dynamic library attached. //
// The purpose of using $.bind($) is to clone the function so as to not modify the original $.
// The cloned function doesn't copy the properties, so Object.assign() is used.
// Object.assign() modifies the first element and returns that, the second element applies its properties and the third element applies its own overriding the second one.
command.execute(Object.assign($.bind($), {
args: params,
author: message.author,
channel: message.channel,
client: message.client,
guild: message.guild,
member: message.member,
message: message
}, $));
}
});

View file

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

18
src/events/ready.ts Normal file
View file

@ -0,0 +1,18 @@
import Event from "../core/event";
import {client} from "../index";
import $ from "../core/lib";
import {Config} from "../core/structures";
export default new Event({
once()
{
if(client.user)
{
$.ready(`Logged in as ${client.user.username}#${client.user.discriminator}.`);
client.user.setActivity({
type: "LISTENING",
name: `${Config.prefix}help`
});
}
}
});

16
src/index.ts Normal file
View file

@ -0,0 +1,16 @@
import {Client} from "discord.js";
import setup from "./setup";
import {Config} from "./core/structures";
import {loadCommands} from "./core/command";
import {loadEvents} from "./core/event";
// This is here in order to make it much less of a headache to access the client from other files.
// This of course won't actually do anything until the setup process is complete and it logs in.
export const client = new Client();
// Begin the command loading here rather than when it's needed like in the message event.
setup.init().then(() => {
loadCommands();
loadEvents(client);
client.login(Config.token).catch(setup.again);
});

60
src/setup.ts Normal file
View file

@ -0,0 +1,60 @@
import {existsSync as exists} from "fs";
import inquirer from "inquirer";
import Storage from "./core/storage";
import {Config} from "./core/structures";
import $, {setConsoleActivated} from "./core/lib";
// This file is called (or at least should be called) automatically as long as a config file doesn't exist yet.
// And that file won't be written until the data is successfully initialized.
const prompts = [{
type: "password",
name: "token",
message: "What's your bot's token?",
mask: true
}, {
type: "input",
name: "prefix",
message: "What do you want your bot's prefix to be?",
default: "$"
}, {
type: "input",
name: "owner",
message: "Enter the owner's user ID here."
}, {
type: "input",
name: "admins",
message: "Enter a list of bot admins (by their IDs) separated by spaces."
}, {
type: "input",
name: "support",
message: "Enter a list of bot troubleshooters (by their IDs) separated by spaces."
}];
export default {
async init()
{
while(!exists("data/config.json"))
{
const answers = await inquirer.prompt(prompts);
Storage.open("data");
Config.token = answers.token as string;
Config.prefix = answers.prefix as string;
Config.owner = answers.owner as string;
const admins = (answers.admins as string);
Config.admins = admins !== "" ? admins.split(" ") : [];
const support = (answers.support as string);
Config.support = support !== "" ? support.split(" ") : [];
Config.save();
}
},
/** Prompt the user to set their token again. */
async again()
{
$.error("It seems that the token you provided is invalid.");
setConsoleActivated(false);
const answers = await inquirer.prompt(prompts.slice(0, 1));
Config.token = answers.token as string;
Config.save(false);
process.exit();
}
};

17
tsconfig.json Normal file
View file

@ -0,0 +1,17 @@
{
"compilerOptions":
{
"rootDir": "src",
"outDir": "dist",
"target": "ES6",
"module": "CommonJS",
"moduleResolution": "node",
"esModuleInterop": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictPropertyInitialization": true,
"removeComments": true
}
}