Compare commits

...

277 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 e42e26ae86
Merge pull request #34 from Mijyuoon/emote-cmd-bugfix-1
Fix a bug in .emote command when the selector number is too large
2021-04-07 20:26:48 -05:00
Mijyuoon 066d210107 Fixed a bug in .emote command when the selector number is too large 2021-04-08 02:41:16 +03: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
Mijyuoon 053b835e89
Improve emote resolving for .emote and .react commands (#33) 2021-04-07 16:01:44 +02: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 9c2ff59a34
Merge pull request #32 from EL20202/typescript
added functionality for reacting to in-line replies
2021-04-07 01:02:22 -05:00
WatDuhHekBro 397287ec3c Retroactively added version history 2021-04-07 01:00:57 -05:00
EL2020 fae06f66d4 added functionality for reacting to in-line replies 2021-04-06 21:04:27 -04:00
WatDuhHekBro f39a0be6c6
Merge pull request #31 from Mijyuoon/Mijyuoon-update-whois
Add a .whois entry that was forgotten to be added
2021-04-06 10:29:51 -05:00
Mijyuoon c82083af5d Added an entry that should've been added 2021-04-06 18:24:42 +03:00
Keanu Timmermans 667389340d
Merge pull request #30 from Hades785/eco-bet 2021-04-06 16:18:34 +02:00
Keanu Timmermans 4978aceec6
Merge pull request #27 from keanuplayz/feature/stream-events 2021-04-06 16:10:21 +02: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
フズキ fd136dc34f
Finally added description fields 2021-04-06 08:39:11 +02:00
WatDuhHekBro eac2f1d8fa
Merge branch 'typescript' into experimental-core-rollout 2021-04-06 01:36:42 -05:00
フズキ 61cb648c70
Merge branch 'typescript' into eco-bet 2021-04-06 08:23:36 +02:00
WatDuhHekBro db33203657 Cleaned up guild checks and return statements 2021-04-06 01:15:17 -05:00
フズキ dc33dbd180
Rename insurance field 2021-04-06 08:02:52 +02:00
WatDuhHekBro 1351f3250b Added hacky persistence and bug fixes on eco bet 2021-04-05 22:57:03 -05:00
WatDuhHekBro 678485160e Added optional channel target for setting channel 2021-04-05 20:55:21 -05:00
WatDuhHekBro b549f7d22f
Merge pull request #29 from EL20202/typescript
added a few more names to whois { including myself :o) }
2021-04-05 20:21:05 -05:00
EL2020 937e6f52f5 added a few names properly this time 2021-04-05 20:33:34 -04: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 418e0d5828
Merge pull request #26 from Mijyuoon/Mijyuoon-update-whois
Add Mijyuoon to .whois list
2021-04-04 16:01:13 -05:00
Mijyuoon 4c7b8200da
Added my almighty ass to .whois list 2021-04-04 23:54:51 +03:00
WatDuhHekBro 3362f9fbbe Prototyped stream notifications like CheeseBot 2021-04-03 21:35:55 -05:00
WatDuhHekBro 4012d2e1cd
Merge pull request #25 from keanuplayz/porting
Porting from TravBot v2
2021-04-03 16:07:08 -05:00
WatDuhHekBro f650faee89 Reorganized code dealing with the command class 2021-04-03 05:26:22 -05:00
WatDuhHekBro c1b298a407 Added welcome message and system logs channel 2021-04-02 23:11:18 -05:00
WatDuhHekBro ee9c88996e Ported events and welcome event 2021-04-01 08:41:49 -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 6a4107c01e Copied over the rest of TravBot v2 commands 2021-03-30 20:40:29 -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 1954b2d999 Fixed small error 2021-03-28 13:58:10 -05:00
WatDuhHekBro 3b1b8ec914 Added a timeout to regex search 2021-03-28 12:49:45 -05:00
WatDuhHekBro b3e1b5e140 Removed problematic feature 2021-03-28 11:01:07 -05:00
WatDuhHekBro 90c41c8df4 Added query to lsemotes and searching other guilds 2021-03-28 09:34:31 -05:00
Keanu Timmermans 705e093999
Added message quoting.
See https://is.gd/lpGqxj
2021-03-20 13:27:57 +01:00
Keanu Timmermans 38e03a85bb
Too lazy to add interceptor; get content instead.
This is for my running joke of reacting with 🚱
to CheeseBot's "Remember to drink water!" message.
2021-03-16 19:07:53 +01:00
Keanu Timmermans 0e1d8f3907
Merge branch 'typescript' of github.com:keanuplayz/TravBot-v3 into typescript 2021-03-13 15:09:27 +01:00
Keanu Timmermans 3f4ee9315f
Added channel lock for eco. 2021-03-13 15:09:18 +01:00
Keanu Timmermans cec38cf4bd Stop deleting the emote invocation. 2021-03-07 22:07:59 +01:00
フズキ c71406a8d0
eco-bet: added a check for bet target's money 2021-02-23 23:25:28 +01:00
フズキ d6548c53db
eco-bet: improvements
- duration bounds
- link to calling message
2021-02-21 14:11:25 +01:00
Dmytro Meleshko 22bd5302c5
don't install the module `os` from npm, it's a built-in one (#24) 2021-02-21 11:24:37 +01:00
Keanu Timmermans 2ff732c927
Merge pull request #23 from dmitmel/typescript 2021-02-16 13:15:23 +01:00
Dmytro Meleshko e22250b3f1 introduce the terrible hack for reducing memory usage 2021-01-29 21:29:46 +02:00
Dmytro Meleshko 303e81cc37 reduce the amount of cache lookups 2021-01-29 21:12:53 +02:00
Dmytro Meleshko 0cba164f3d get rid of @ts-ignore or something 2021-01-29 19:15:32 +02:00
Dmytro Meleshko 593efb3602 raise the ES target level and enable sourcemaps in tsconfig.json 2021-01-29 19:07:39 +02:00
Dmytro Meleshko 417bfc8a18 get rid of the remaining calls to BaseManager#resolve to make all cache access explicit 2021-01-29 18:21:06 +02:00
Keanu Timmermans a21ed7a97f
Fixed info using `heapTotal` for both values. 2021-01-29 14:34:46 +01:00
WatDuhHekBro 3a47acf05f Merge branch 'typescript' of https://github.com/keanuplayz/TravBot-v3 into experimental-core 2021-01-28 18:37:50 -06:00
Keanu Timmermans 74d36e36b2 Improved logic of role display on info.
Co-authored-by: Mijyuoon <mijuoon@gmail.com>
2021-01-29 01:15:42 +01:00
Keanu Timmermans baf17e6c76 Corrected usage of trimarray in info. 2021-01-29 00:57:50 +01:00
フズキ 055a57e928
eco-bet: use askYesOrNo to clean up 2021-01-26 19:54:43 +01:00
フズキ 499aea9e66
eco-bet: save storage modification 2021-01-26 18:11:50 +01:00
WatDuhHekBro 30697e5020 First attempt at getting husky to work 2021-01-26 09:57:05 -06:00
フズキ 38eb0906ee
eco/bet: Clean up comments and add feedback 2021-01-26 16:28:04 +01: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
フズキ 7105c6284f
Add 'bet' command to the 'eco' module 2021-01-25 23:06:12 +01:00
Keanu Timmermans 8b29163c26
Implemented time command...
By @WatDuhHekBro
2021-01-25 15:17:15 +01:00
WatDuhHekBro d7c18d1b06 Fixed time setup accounting for differences in day 2021-01-24 22:46:48 -06:00
WatDuhHekBro 8da5ad0ca6 Addressed issue with inline replies using prefix 2021-01-24 19:12:43 -06:00
WatDuhHekBro 7b4d8b934c Added time command for user-submitted timezones 2021-01-24 08:07:58 -06:00
Keanu Timmermans eec6aa7b96
Upgraded dependencies. 2021-01-06 14:20:38 +01:00
Keanu Timmermans 56dafed616
Merge pull request #21 from keanuplayz/emote-dump 2021-01-03 16:27:45 +01:00
WatDuhHekBro f8d6c0d336 Reverted changes except for the file location 2021-01-02 20:16:15 -06:00
WatDuhHekBro 5537cec29a Removed structure redundancy and saves to data/ 2021-01-01 19:44:43 -06:00
Keanu Timmermans 68d8eed4ed
Added emote-registry to gitignore. 2020-12-31 16:11:51 +00:00
Keanu Timmermans a99386c431
Added (currently broken) emote dumper. 2020-12-31 16:10:56 +00:00
Keanu Timmermans 81ef37e852
Merge pull request #20 from keanuplayz/docker 2020-12-31 15:12:45 +00:00
Keanu Timmermans aeff8ac832
Minor changes. 2020-12-29 13:12:03 +00:00
Keanu Timmermans c5baf34b7e
Attempt to merge two workflows. 2020-12-29 13:01:02 +00:00
WatDuhHekBro 2e2bd57598
Fixed the info command and its problems (kinda) 2020-12-29 12:34:41 +00:00
WatDuhHekBro f84cc726be
Merge pull request #19 from keanuplayz/testing
Attempting to fix the emote name resolution
2020-12-20 18:55:26 -06:00
WatDuhHekBro 799c477baf Tried a different emote capitalization heuristic 2020-12-16 23:37:07 -06:00
WatDuhHekBro 2017e45403 Tried adding some heuristics to searching emotes 2020-12-16 23:12:17 -06:00
Keanu Timmermans 692fd2c164
Merge pull request #18 from keanuplayz/small-stuff 2020-12-16 12:31:48 +01:00
WatDuhHekBro 9ab588468e Added eco monday 2020-12-16 01:24:26 -06:00
WatDuhHekBro 3d3631c65a Fixed some inaccuracies in eco 2020-12-15 17:46:03 -06:00
WatDuhHekBro 5b3b5d0611 Added some eco aliases and fixed eval command 2020-12-15 17:29:40 -06:00
WatDuhHekBro 4752adc7b4 Ported the old eco guild command 2020-12-15 05:15:28 -06:00
WatDuhHekBro 5665a91af4 Added more functionality to the react command 2020-12-15 03:22:32 -06:00
WatDuhHekBro eeed9766b9
Merge pull request #17 from keanuplayz/dev
Ported over the eco command from TravBot 2
2020-12-15 02:30:24 -06:00
WatDuhHekBro 5165c5ec4b Changed max width to 120 columns 2020-12-15 01:56:09 -06:00
WatDuhHekBro 39f89a9f63 Formatting Preview Alpha 2020-12-14 19:44:28 -06:00
WatDuhHekBro 98e47e3796 Fixed the error prevention in the neko command 2020-12-14 02:13:31 -06:00
WatDuhHekBro 927e306e9a Hotfix: Buying an item now saves data properly 2020-12-14 01:54:46 -06:00
WatDuhHekBro 659fdab609
Merge branch 'typescript' into dev 2020-12-14 01:20:04 -06:00
WatDuhHekBro 4f443d3e4f Ported over "eco shop" and "eco buy" 2020-12-14 01:13:16 -06:00
WatDuhHekBro 8417a1ba57 Split up the money command into several parts 2020-11-04 02:04:07 -06:00
Keanu Timmermans 67d0e6ce4b
Merge pull request #8 from keanuplayz/music 2020-10-24 10:27:20 +02:00
Keanu Timmermans 9348bda210
Merge branch 'typescript' into music 2020-10-22 16:44:49 +02:00
Keanu Timmermans 1abab93362 Ported all legacy commands! (except eco) 2020-10-22 14:24:58 +00:00
Keanu Timmermans beab6cb1fc Did a number of things.
- Upgraded dependencies
- Added eval command (admin)
- Added bot info command (info)
2020-10-22 13:41:02 +00:00
Keanu Timmermans cdf2c47f0c Fixed up info guild. 2020-10-22 13:39:33 +00:00
Keanu Timmermans dbebb656b6 Added shorten command. 2020-10-22 13:38:57 +00:00
Keanu Timmermans c959121c03
Delete dependabot.yml 2020-10-21 22:19:05 +02:00
Keanu Timmermans c5dad4fa05
Merge pull request #16 from keanuplayz/dependabot/add-v2-config-file
Create Dependabot config file
2020-10-21 22:14:59 +02:00
dependabot-preview[bot] 0501271868
Create Dependabot config file 2020-10-21 20:11:27 +00:00
Keanu Timmermans 74c42a8fa6 Upgraded Lavalink lib again. 2020-10-21 19:53:16 +00:00
Keanu Timmermans 03e1a835bd
Upgraded Lavalink lib. 2020-10-20 22:02:10 +02:00
Keanu Timmermans 942489630f
Reimplemented music functionality.
...in the most disgusting way possible!
2020-10-20 21:10:03 +02:00
Keanu Timmermans 0c0fc083cf Moved emote cmds to utility category.
Updated Discord.JS version to 12.4.0.
Added channelCreate/remove events.
Removed husky.
2020-10-20 12:04:13 +00:00
Keanu Timmermans 74b4d4272c Added husky. 2020-10-15 09:23:24 +00:00
Keanu Timmermans 57433cc594 Completed lsemotes. Various other changes.
- Replaced deprecated `version` input with `dockerx-version`
- Replaced deprecated `equals` function with `strictEquals`
- Fixed indentation on paginate function.
2020-10-15 09:02:24 +00:00
Lexi 57a4c9f523 Added WIP emote list.
@WatDuhHekBro also fixed the paginate function in lib.ts.
This had some inconsistencies.

Aside from that, to reduce image size, docker now ignores node_modules.

Co-authored-by: WatDuhHekBro <watduhhekbro@gmail.com>
2020-10-13 22:11:26 +02:00
Keanu Timmermans 4d1fdf3a97
Merge pull request #7 from keanuplayz/dependabot/npm_and_yarn/node-fetch-2.6.1
Bump node-fetch from 2.6.0 to 2.6.1
2020-09-11 21:30:29 +02:00
dependabot[bot] cbab1c3435
Bump node-fetch from 2.6.0 to 2.6.1
Bumps [node-fetch](https://github.com/bitinn/node-fetch) from 2.6.0 to 2.6.1.
- [Release notes](https://github.com/bitinn/node-fetch/releases)
- [Changelog](https://github.com/node-fetch/node-fetch/blob/master/docs/CHANGELOG.md)
- [Commits](https://github.com/bitinn/node-fetch/compare/v2.6.0...v2.6.1)

Signed-off-by: dependabot[bot] <support@github.com>
2020-09-11 19:29:43 +00:00
WatDuhHekBro 33520b2a66
Merge pull request #6 from keanuplayz/docker
Added Docker support.
2020-09-11 14:17:25 -05:00
Keanu Timmermans 2960639dbd
Added --push flag 2020-09-10 21:11:32 +02:00
Keanu Timmermans 369937ad2b
Changed v8 to v7. 2020-09-10 21:05:33 +02:00
Keanu Timmermans 005713efca
Attempt to fix Docker action. 2020-09-10 21:03:44 +02:00
Keanu Timmermans e6c435fa45
Attempt to add Docker image build. 2020-09-10 20:38:33 +02:00
Keanu Timmermans 4da672f419
Create Dockerfile.armhf 2020-09-10 18:51:01 +02:00
Keanu Timmermans 7f811800d7
Removed broken Docker workflow.
This has been moved to Docker Hub, since it builds atomatically.
2020-09-09 13:11:17 +02:00
Keanu Timmermans 61825e5fff
Fixed username. 2020-08-31 21:33:35 +02:00
Keanu Timmermans 8293dc3925
Fixed password. 2020-08-31 21:28:50 +02:00
Keanu Timmermans 863057f496
Updated Docker repo 2020-08-31 21:21:02 +02:00
Keanu Timmermans f8cd32914c Added missing npm i to Dockerfile. 2020-08-31 21:18:44 +02:00
Keanu Timmermans a829e3d0bd
Create docker.yml 2020-08-31 21:17:22 +02:00
Keanu Timmermans 19010e7e59 Attempt #1 at adding Docker. 2020-08-31 21:15:41 +02:00
WatDuhHekBro b3209d1cf1 Added documentation + misc patches 2020-08-30 16:26:18 -05:00
WatDuhHekBro 3767a10829 Modularized finding members by their username 2020-08-27 22:46:40 -05:00
WatDuhHekBro 8ca171e924 Added test suite and production builder 2020-08-25 21:49:08 -05:00
Keanu Timmermans 4a6754d21e
Added CodeQL. (#5) 2020-08-20 16:35:35 +02:00
Keanu Timmermans eed1438fb2 Removed fun.ts 2020-08-18 09:22:07 +00:00
Keanu Timmermans bd0984eb69 Moved poll command to fun category. 2020-08-17 17:38:15 +00:00
Keanu Timmermans 5b3df90067 Moved 8ball to Fun category. 2020-08-15 21:02:58 +00:00
WatDuhHekBro 5abda092e0 Moved core/lib/perforate to ArrayWrapper/split 2020-08-14 13:31:05 -05:00
WatDuhHekBro 32256f50fe Ported the scanemotes command 2020-08-14 12:51:27 -05:00
WatDuhHekBro 877a41fac2 Added command aliases 2020-08-14 11:35:53 -05:00
WatDuhHekBro 139630ce9f Various small changes/fixes 2020-08-14 07:50:24 -05:00
WatDuhHekBro 53705e76c5 Added removing emotes in paginate if possible 2020-08-14 05:21:19 -05:00
WatDuhHekBro 77422538df Added pinging bot for prefix and var string prefix 2020-08-14 04:43:45 -05:00
Keanu Timmermans a86e11ed23
Added lsemotes command to util. 2020-08-13 22:39:10 +02:00
Keanu Timmermans 10f4f30137
Added poll to fun. 2020-08-13 22:03:18 +02:00
Keanu Timmermans ed14ccefca
Added 8ball command to fun. 2020-08-13 20:58:41 +02:00
Keanu Timmermans 8dd87c89a9
Largely optimized activity command. 2020-08-13 20:11:14 +02:00
Keanu Timmermans 6a628a4791
Added activity command to admin. 2020-08-13 18:49:55 +02:00
Keanu Timmermans d85040b313
Added guilds command to admin. 2020-08-13 18:13:55 +02:00
Keanu Timmermans 979c00ddc8
Added nick command to admin. 2020-08-13 17:57:22 +02:00
Keanu Timmermans facaf001ad
Added purge command to admin. 2020-08-13 17:06:10 +02:00
Keanu Timmermans 9d0caaf976
Added status command to admin. 2020-08-12 22:33:54 +02:00
WatDuhHekBro 6744faa6e6 Fixed userFlags 2020-08-05 22:21:36 -05:00
WatDuhHekBro 9816298f55 Patched up the info command 2020-08-05 22:11:11 -05:00
Keanu Timmermans 48097b729d
Added WIP info command. 2020-08-05 22:08:26 +02:00
Keanu Timmermans 01d4398b53
Merge pull request #3 from keanuplayz/restructure
Restructure
2020-07-29 14:00:19 +02:00
WatDuhHekBro bf84c2970d Added a permissions system 2020-07-26 05:02:35 -05:00
WatDuhHekBro 2488239598 Modularized command resolution 2020-07-25 21:35:53 -05:00
WatDuhHekBro 4b03912e89 Added command categories 2020-07-25 20:14:11 -05:00
WatDuhHekBro 14f78a91dc Relocated loadCommands and loadEvents 2020-07-25 18:32:49 -05:00
Keanu Timmermans 7193c9e70a
Merge pull request #2 from keanuplayz/dependabot/npm_and_yarn/lodash-4.17.19 2020-07-25 18:16:30 +02:00
Keanu Timmermans d796a40d37
Merge branch 'restructure' into dependabot/npm_and_yarn/lodash-4.17.19 2020-07-25 18:16:09 +02:00
WatDuhHekBro 113fc965a9 Added dynamically loaded events 2020-07-25 06:01:24 -05:00
dependabot[bot] 072d2607d4
Bump lodash from 4.17.15 to 4.17.19
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.15 to 4.17.19.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.15...4.17.19)

Signed-off-by: dependabot[bot] <support@github.com>
2020-07-25 08:37:05 +00:00
WatDuhHekBro 295995aba2 Ported CrossExchange v1.0.1 and removed stonks 2020-07-25 03:15:26 -05:00
Keanu Timmermans b5e1ceaad3 Relicensed under MIT. 2020-07-20 10:53:51 +02:00
Keanu Timmermans a0a322a7ee Removed JS structure. 2020-07-18 23:16:27 +02:00
104 changed files with 16923 additions and 1954 deletions

1
.dockerignore Normal file
View File

@ -0,0 +1 @@
node_modules/

View File

@ -1,3 +0,0 @@
{
"extends": "tesseract"
}

6
.github/codeql/codeql-config.yml vendored Normal file
View File

@ -0,0 +1,6 @@
name: "CodeQL Config"
queries:
- uses: security-and-quality
paths:
- dist

71
.github/workflows/image.yml vendored Normal file
View File

@ -0,0 +1,71 @@
name: CodeQL + Docker Image
on:
push:
branches:
- master
jobs:
analyze:
name: CodeQL Analysis
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
with:
fetch-depth: 2
- name: Setup Node.JS
uses: actions/setup-node@v2
with:
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
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
config-file: ./.github/codeql/codeql-config.yml
languages: javascript
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
build:
name: Build Docker Image
needs: analyze
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
with:
fetch-depth: 2
- name: Install Docker BuildX
id: buildx
uses: crazy-max/ghaction-docker-buildx@v1
with:
buildx-version: latest
- name: Login to Docker Hub
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
- name: Build the image
run: |
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 }}

18
.gitignore vendored
View File

@ -1,13 +1,20 @@
# Specific to this repository
dist/
data/*
data/public/emote-registry.json
!data/public/
tmp/
test*
!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
@ -61,4 +68,5 @@ typings/
# dotenv environment variables file
.env
config.json
config.json
.vscode/

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

80
.prettierignore Normal file
View File

@ -0,0 +1,80 @@
# Specific to prettier (so it doesn't throw a bunch of errors when running "npm run format")
.dockerignore
.gitignore
.prettierignore
.husky/
Dockerfile
LICENSE
# Specific to this repository
dist/
data/
docs/
*.md
tmp/
test*
!test/
*.bat
desktop.ini
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Typescript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
config.json
.vscode/

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

74
Dockerfile Normal file
View File

@ -0,0 +1,74 @@
###############
# 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 . .
RUN npm i
RUN npm run build
CMD ["npm", "start"]

214
LICENSE
View File

@ -1,201 +1,21 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
MIT License
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
Copyright (c) 2020 Keanu Timmermans
1. Definitions.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2020 Keanu Timmermans, Lexi Sother
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

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.

View File

@ -1,5 +0,0 @@
{
"prefix": "!!", // Bot Prefix
"token": "<token>", // Bot Token
"owners": ["<id>"] // Array of bot owner IDs
}

60
docs/Documentation.md Normal file
View File

@ -0,0 +1,60 @@
# Table of Contents
- [Structure](#structure)
- [Version Numbers](#version-numbers)
- [Utility Functions](#utility-functions)
- [Testing](#testing)
# Structure
- `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.
# Version Numbers
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).
## Naming Versions
Because versions are assigned to batches of changes rather than single changes (or even single commits), versioning is used a bit differently in order to avoid wasting version numbers.
`<prototype>.<major>.<minor>-<patch>`
- `<prototype>` is a defined as the overarching version group of TravBot. TravBot-v2 went by `2.x.x` and all versions of TravBot-v3 will go by `3.x.x`.
- `<major>` includes any big overhauls or revisions of the entire codebase.
- `<minor>` includes any feature additions in a specific area of the codebase.
- `<patch>` will be pretty much for any very small changes like a quick bug fix or typos. *Note: Normally, these would probably get grouped up, but if there hasn't been a proper version in a while, this will get pushed as a patch.*
*Note: This system doesn't retroactively apply to TravBot-v2, which is why this version naming system won't make sense for v2's changelog.*
# Utility Functions
## [src/lib](../src/lib.ts) - General utility functions
- `parseArgs()`: Turns `call test "args with spaces" "even more spaces"` into `["call", "test", "args with spaces", "even more spaces"]`, inspired by the command line.
- `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]]`.
# Testing
For TravBot, there'll be two types of tests: standard unit tests and manual integration tests.
- Standard unit tests are executed only on isolated functions and are part of the pre-commit hook.
- Somehow, including the bot in an import chain will cause the system to crash (same error message as [this](https://stackoverflow.com/questions/66102858/discord-clientuser-is-not-a-constructor)). That's why the integration tests are manually done. There would be a list of inputs and outputs to check of each command for tests while simultaneously serving as a help menu with examples of all possible inputs/outputs for others to see.
- An idea which will not be implemented is prompting the user for inputs during the tests. This is no better than manual tests, worse actually, because if this had to run before each commit, it'd quickly become a nightmare.
- Maybe take some ideas from something like [this](https://github.com/stuyy/jest-unit-tests-demo) in the future to get tests to properly work.
- Another possibility is to use `client.emit(...)` then mock the `message.channel.send(...)` function which would listen if the input is correct.

130
docs/MusicStructure.md Normal file
View File

@ -0,0 +1,130 @@
# The commands
```lang-none
- "mhelp" - Display a help embed.
- "play" - Adds a song to the queue and plays it.
- "skip" - Skips the currently playing track.
- "queue" - Shows the current queue.
- "stop" - Stops currently playing media and leaves the voice channel.
- "np" - Displays the currently playing track.
- "pause" - Pauses the currently playing track.
- "resume" - Resumes the currently paused track.
- "volume" - Changes the global volume of the bot.
- "loop" - Loops the current queue.
- "seek" - Seeks through the queue.
```
---
Now that the actual info about the functionality of this thing is out of the way, its storytime!
## The music structure
Originally, I, keanucode, aimed to port the music structure of TravBot-v2 to this version.
This would have been much too difficult of a task for three main reasons:
1. The original code is written badly.
2. The original code is written by *another person*.
3. The original code is written in JS.
These three reasons make porting the structure *considerably* harder.
So, of course, I resorted to different matters. I present: [discord.js-lavalink-musicbot](https://github.com/BluSpring/discord.js-lavalink-musicbot). ([npmjs.org](https://www.npmjs.com/package/discord.js-lavalink-musicbot))
This *pre-built* module utilises [Lavalink](https://github.com/Frederikam/Lavalink), which is an audio sending node based on [Lavaplayer](https://github.com/sedmelluq/lavaplayer) and [JDA-Audio](https://github.com/DV8FromTheWorld/JDA-Audio).
I've previously considered using Lavalink, but it turned out to be more difficult for me to implement than I thought.
So, I tried again with `discord.js-lavalink-musicbot`.
*ahem*...
**The library was written in such a way that it didn't work!**
---
## Fixing the broken library
First off; in the library's interface `LavaLinkNodeOptions`, option `id` was a *required* option:
```ts
interface LavalinkNodeOptions {
host: string;
id: string;
/* ... */
}
```
Here's the catch. `id` was referenced *nowhere* in the library code.
It was *literally* useless.
So, I lazily removed that by adding a `?` to the parameter. (`id?:`)
Next up:
```ts
declare function LavalinkMusic(client: Client, options: MusicbotOptions) {}
```
First up, the TS compiler reports that: `An implementation cannot be declared in ambient contexts. ts(1183)`
Secondly, this function, which makes up the entirety of the library, explicitly returns an `any` type. As you can see, the *declared* function returns... no specific type.
So, that had to be changed to:
```diff
- declare function LavalinkMusic(client: Client, options: MusicbotOptions) {}
+ declare function LavalinkMusic(client: Client, options: MusicbotOptions): any
```
...next up:
```ts
try {
const res = await axios.get(
/* ... */
`https://${music.lavalink.restnode.host}:`
/* ... */
)
```
The library tries to fetch the URL of the Lavalink node. With *HTTPS*.
I think you can see where this is going. An SSL error.
Changed the `https` to `http`, and all is well.
I republished the library under the name "[discord.js-lavalink-lib](https://npmjs.org/package/discord.js-lavalink-lib)" so I can easily install the non-broken version.
---
## Implementing the functionality
There's nothing much to do there, honestly. Only one edit to the original snippet has to be made.
The original example snippet has the following:
```ts
const Discord = require('discord.js');
const client = new Discord.Client();
client.music = new (require('discord.js-lavalink-musicbot'))(client, {
/* ...config... */
});
```
As you can see, this is... kind of disgusting. And on top of that, incompatible with TS.
So, we have to change a few things. First off, since TS is strict, it'll tell you that `music` doesn't exist on `client`. Which is true. The `Client` class has no `music` property.
So, we make `client.` an `any` type using keyword `as`:
```ts
const Discord = require('discord.js');
const client = new Discord.Client();
(client as any).music = LavalinkMusic(client, {
/* ...config... */
});
```
And that's about it. Launch up Lavalink, and start the bot.

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.

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

11559
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,24 +1,58 @@
{
"name": "d.js-v12-bot",
"version": "0.0.1",
"description": "A Discord bot built on Discord.JS v12",
"main": "src/index",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"discord.js",
"bot"
],
"author": "Keanu Timmermans",
"license": "Apache-2.0",
"dependencies": {
"discord.js": "^12.2.0",
"moment": "^2.27.0",
"ms": "^2.1.2"
},
"devDependencies": {
"eslint": "^7.0.0",
"eslint-config-tesseract": "^0.0.2"
}
"name": "travebot",
"version": "3.2.3",
"description": "TravBot Discord bot.",
"main": "dist/index.js",
"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.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",
"weather-js": "^2.0.0"
},
"devDependencies": {
"@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": "^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"
},
"optionalDependencies": {
"fsevents": "^2.3.2"
},
"author": "Keanu Timmermans",
"license": "MIT",
"keywords": [
"discord.js",
"bot"
]
}

14
prettier.config.js Normal file
View File

@ -0,0 +1,14 @@
module.exports = {
printWidth: 120,
tabWidth: 4,
useTabs: false,
semi: true,
singleQuote: false,
quoteProps: "as-needed",
jsxSingleQuote: false,
trailingComma: "none",
bracketSpacing: false,
jsxBracketSameLine: false,
arrowParens: "always",
endOfLine: "auto" // Apparently, the GitHub repository still uses CRLF. I don't know how to force it to use LF, and until someone figures that out, I'm changing this to auto because I don't want more than one line ending commit.
};

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,16 +0,0 @@
/* eslint-disable no-unused-vars */
const Command = require('./../Structures/Command.js');
module.exports = class extends Command {
constructor(...args) {
super(...args, {
aliases: ['hallo']
});
}
async run(message, args) {
message.channel.send('Hello');
}
};

View File

@ -1,49 +0,0 @@
const Command = require('../../Structures/Command');
const { MessageEmbed, version: djsversion } = require('discord.js');
const { version } = require('../../../package.json');
const { utc } = require('moment');
const os = require('os');
const ms = require('ms');
module.exports = class extends Command {
constructor(...args) {
super(...args, {
aliases: ['info', 'bot', 'botinfo'],
category: 'Information'
});
}
run(message) {
const core = os.cpus()[0];
const embed = new MessageEmbed()
.setThumbnail(this.client.user.displayAvatarURL())
.setColor(message.guild.me.displayHexColor || 'BLUE')
.addField('General', [
`** Client:** ${this.client.user.tag} (${this.client.user.id})`,
`** Commands:** ${this.client.commands.size}`,
`** Servers:** ${this.client.guilds.cache.size.toLocaleString()}`,
`** Users:** ${this.client.guilds.cache.reduce((a, b) => a + b.memberCount, 0).toLocaleString()}`,
`** Channels:** ${this.client.channels.cache.size.toLocaleString()}`,
`** Creation Date:** ${utc(this.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: ${this.client.utils.formatBytes(process.memoryUsage().heapTotal)}`,
`\u3000 • Used: ${this.client.utils.formatBytes(process.memoryUsage().heapTotal)}`
])
.setTimestamp();
message.channel.send(embed);
}
};

View File

@ -1,90 +0,0 @@
/* eslint-disable no-undef */
/* eslint-disable no-warning-comments */
const Command = require('../../Structures/Command');
const { MessageEmbed } = require('discord.js');
const moment = require('moment');
const filterLevels = {
DISABLED: 'Off',
MEMBERS_WITHOUT_ROLES: 'No Role',
ALL_MEMBERS: 'Everyone'
};
const verificationLevels = {
NONE: 'None',
LOW: 'Low',
MEDIUM: 'Medium',
HIGH: '(╯°□°)╯︵ ┻━┻',
VERY_HIGH: '┻━┻ ミヽ(ಠ益ಠ)ノ彡┻━┻'
};
const regions = {
brazil: 'Brazil',
europe: 'Europe',
hongkong: 'Hong Kong',
india: 'India',
japan: 'Japan',
russia: 'Russia',
singapore: 'Singapore',
southafrica: 'South Africa',
sydney: 'Sydney',
'us-central': 'US Central',
'us-east': 'US East',
'us-west': 'US West',
'us-south': 'US South'
};
module.exports = class extends Command {
constructor(...args) {
super(...args, {
aliases: ['server', 'guild', 'guildinfo'],
category: 'Information'
});
}
async run(message) {
const roles = message.guild.roles.cache.sort((a, b) => b.position - a.position).map(role => role.toString());
const members = message.guild.members.cache;
const channels = message.guild.channels.cache;
const emojis = message.guild.emojis.cache;
const embed = new MessageEmbed()
.setDescription(`**Guild information for __${message.guild.name}__**`)
.setColor('BLUE')
.setThumbnail(message.guild.iconURL({ dynamic: true }))
.addField('General', [
`** Name:** ${message.guild.name}`,
`** ID:** ${message.guild.id}`,
`** Owner:** ${message.guild.owner.user.tag} (${message.guild.ownerID})`,
`** Region:** ${regions[message.guild.region]}`,
`** Boost Tier:** ${message.guild.premiumTier ? `Tier ${message.guild.premiumTier}` : 'None'}`,
`** Explicit Filter:** ${filterLevels[message.guild.explicitContentFilter]}`,
`** Verification Level:** ${verificationLevels[message.guild.verificationLevel]}`,
`** Time Created:** ${moment(message.guild.createdTimestamp).format('LT')} ${moment(message.guild.createdTimestamp).format('LL')} ${moment(message.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:** ${message.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')}`,
`** Voice Channels:** ${channels.filter(channel => channel.type === 'voice')}`,
`** Boost Count:** ${message.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.stats === 'dnd').size}`,
`** Offline:** ${members.filter(member => member.presence.status === 'offline').size}`,
'\u200b'
])
.addField(`Roles [${roles.length - 1}]`, roles.length < 10 ? roles.join(', ') : roles.length > 10 ? this.client.utils.trimArray(roles) : 'None')
.setTimestamp();
message.channel.send(embed);
}
};

View File

@ -1,60 +0,0 @@
const Command = require('../../Structures/Command');
const { MessageEmbed } = require('discord.js');
const moment = require('moment');
const flags = {
DISCORD_EMPLOYEE: 'Discord Employee',
DISCORD_PARTNER: 'Discord Partner',
BUGHUNTER_LEVEL_1: 'Bug Hunter (Level 1)',
BUGHUNTER_LEVEL_2: 'Bug Hunter (Level 2)',
HYPESQUAD_EVENTS: 'HypeSquad Events',
HOUSE_BRAVERY: 'House of Bravery',
HOUSE_BRILLIANCE: 'House of Brilliance',
HOUSE_BALANCE: 'House of Balance',
EARLY_SUPPORTER: 'Early Supporter',
TEAM_USER: 'Team User',
SYSTEM: 'System',
VERIFIED_BOT: 'Verified Bot',
VERIFIED_DEVELOPER: 'Verified Bot Developer'
};
module.exports = class extends Command {
constructor(...args) {
super(...args, {
aliases: ['user', 'ui'],
category: 'Information'
});
}
async run(message, [target]) {
const member = message.mentions.members.last() || message.guild.members.cache.get(target) || message.member;
const roles = member.roles.cache
.sort((a, b) => b.position - a.position)
.map(role => role.toString())
.slice(0, -1);
const userFlags = member.user.flags.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.map(flag => flags[flag]).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.game || 'Not playing a game.'}`
])
.addField('Member', [
`** Highest Role:** ${member.roles.highest.id === message.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 < 10 ? roles.join(', ') : roles.length > 10 ? this.client.utils.trimArray(roles) : 'None'}`,
]);
return message.channel.send(embed);
}
};

View File

@ -1,56 +0,0 @@
const Command = require('../../Structures/Command');
const { MessageEmbed } = require('discord.js');
module.exports = class extends Command {
constructor(...args) {
super(...args, {
aliases: ['help', 'halp'],
category: 'Utilities'
});
}
async run(message, [command]) {
const embed = new MessageEmbed()
.setColor('BLUE')
.setAuthor(`${message.guild.name} Help Menu`, message.guild.iconURL({ dynamic: true }))
.setThumbnail(this.client.user.displayAvatarURL())
.setFooter(`Requested by ${message.author.username}`, message.author.displayAvatarURL({ dynamic: true }))
.setTimestamp();
if (command) {
const cmd = this.client.commands.get(command) || this.client.command.get(this.aliases.get(command));
if (!cmd) return message.channel.send(`\`${command}\` is not a valid command.`);
embed.setAuthor(`${this.client.utils.capitalise(cmd.name)} Command Help`, this.client.user.displayAvatarURL());
embed.setDescription([
`** Aliases:** ${cmd.aliases.length ? cmd.aliases.map(alias => `\`${alias}\``).join(' ') : 'No Aliases'}`,
`** Description:** ${cmd.description}`,
`** Category:** ${cmd.category}`,
`** Usage:** ${cmd.usage}`
]);
return message.channel.send(embed);
} else {
embed.setDescription([
`These are the available commands for ${message.guild.name}`,
`This bot's prefix is: ${this.client.prefix}`,
`Command Parameters: \`<>\` is a strict & \`[]\` is optional`
]);
let categories;
if (!this.client.owners.includes(message.author.id)) {
categories = this.client.utils.removeDuplicates(this.client.commands.filter(cmd => cmd.category !== 'Owner').map(cmd => cmd.category));
} else {
categories = this.client.utils.removeDuplicates(this.client.commands.map(cmd => cmd.category));
}
for (const category of categories) {
embed.addField(`**${this.client.utils.capitalise(category)}**`, this.client.commands.filter(cmd =>
cmd.category === category).map(cmd => `\`${cmd.name}\``).join(' '));
}
return message.channel.send(embed);
}
}
};

View File

@ -1,22 +0,0 @@
const Command = require('../../Structures/Command');
module.exports = class extends Command {
constructor(...args) {
super(...args, {
aliases: ['pong'],
category: 'Utilities'
});
}
async run(message) {
const msg = await message.channel.send('Pinging...');
const latency = msg.createdTimestamp - message.createdTimestamp;
const choices = ['Is this really my ping?', "Is this okay? I can't look!", "I hope it isn't bad!"];
const response = choices[Math.floor(Math.random() * choices.length)];
msg.edit(`${response} - Bot Latency: \`${latency}ms\`, API Latency: \`${Math.round(this.client.ws.ping)}ms\``);
}
};

View File

@ -1,17 +0,0 @@
const Command = require('../../Structures/Command.js');
const ms = require('ms');
module.exports = class extends Command {
constructor(...args) {
super(...args, {
aliases: ['uptime'],
category: 'Utilities'
});
}
async run(message) {
message.channel.send(`My uptime is \`${ms(this.client.uptime, { long: true })}\``);
}
};

View File

@ -1,25 +0,0 @@
const Event = require('../../Structures/Event');
module.exports = class extends Event {
async run(message) {
const mentionRegex = RegExp(`^<@!${this.client.user.id}>$`);
const mentionRegexPrefix = RegExp(`^<@!${this.client.user.id}> `);
if (!message.guild || message.author.bot) return;
if (message.content.match(mentionRegex)) message.channel.send(`My prefix for ${message.guild.name} is \`${this.client.prefix}\`.`);
const prefix = message.content.match(mentionRegexPrefix) ?
message.content.match(mentionRegexPrefix)[0] : this.client.prefix;
// eslint-disable-next-line no-unused-vars
const [cmd, ...args] = message.content.slice(prefix.length).trim().split(/ +/g);
const command = this.client.commands.get(cmd.toLowerCase()) || this.client.commands.get(this.client.aliases.get(cmd.toLowerCase()));
if (command) {
command.run(message, args);
}
}
};

View File

@ -1,19 +0,0 @@
const Event = require('../Structures/Event');
module.exports = class extends Event {
constructor(...args) {
super(...args, {
once: true
});
}
run() {
console.log([
`Logged in as ${this.client.user.tag}`,
`Loaded ${this.client.commands.size} commands.`,
`Loaded ${this.client.events.size} events.`
].join('\n'));
}
};

View File

@ -1,40 +0,0 @@
const { Client, Collection } = require('discord.js');
const Util = require('./Util.js');
module.exports = class BotClient extends Client {
constructor(options = {}) {
super({
disableMentions: 'everyone'
});
this.validate(options);
this.commands = new Collection();
this.events = new Collection();
this.aliases = new Collection();
this.utils = new Util(this);
this.owners = options.owners;
}
validate(options) {
if (typeof options !== 'object') throw new TypeError('Options should be a type of Object.');
if (!options.token) throw new Error('You must pass a token for the client.');
this.token = options.token;
if (!options.prefix) throw new Error('You must pass a prefix for the client.');
if (typeof options.prefix !== 'string') throw new TypeError('Prefix should be a type of String.');
this.prefix = options.prefix;
}
async start(token = this.token) {
this.utils.loadCommands();
this.utils.loadEvents();
super.login(token);
}
};

View File

@ -1,17 +0,0 @@
module.exports = class Command {
constructor(client, name, options = {}) {
this.client = client;
this.name = options.name || name;
this.aliases = options.aliases || [];
this.description = options.description || 'No description provided.';
this.category = options.category || 'Miscellaneous';
this.usage = `${this.client.prefix}${this.name} ${options.usage || ''}`.trim();
}
// eslint-disable-next-line no-unused-vars
async run(message, args) {
throw new Error(`Command ${this.name} doesn't provide a run method.`);
}
};

View File

@ -1,15 +0,0 @@
/* eslint-disable no-unused-vars */
module.exports = class Event {
constructor(client, name, options = {}) {
this.name = name;
this.client = client;
this.type = options.once ? 'once' : 'on';
this.emitter = (typeof options.emitter === 'string' ? this.client[options.emitter] : options.emitter) || this.client;
}
async run(...args) {
throw new Error(`The run method has not been implemented in ${this.name}`);
}
};

View File

@ -1,81 +0,0 @@
const path = require('path');
const { promisify } = require('util');
const glob = promisify(require('glob'));
const Command = require('./Command.js');
const Event = require('./Event.js');
module.exports = class Util {
constructor(client) {
this.client = client;
}
isClass(input) {
return typeof input === 'function' &&
typeof input.prototype === 'object' &&
input.toString().substring(0, 5) === 'class';
}
get directory() {
return `${path.dirname(require.main.filename)}${path.sep}`;
}
trimArray(arr, maxLen = 10) {
if (arr.length > maxLen) {
const len = arr.length - maxLen;
arr = arr.slice(0, maxLen);
arr.push(`${len} more...`);
}
return arr;
}
formatBytes(bytes) {
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]}`;
}
removeDuplicates(arr) {
return [...new Set(arr)];
}
capitalise(string) {
return string.split(' ').map(str => str.slice(0, 1).toUpperCase() + str.slice(1)).join(' ');
}
async loadCommands() {
return glob(`${this.directory}commands/**/*.js`).then(commands => {
for (const commandFile of commands) {
delete require.cache[commandFile];
const { name } = path.parse(commandFile);
const File = require(commandFile);
if (!this.isClass(File)) throw new TypeError(`Command ${name} doesn't export a class.`);
const command = new File(this.client, name.toLowerCase());
if (!(command instanceof Command)) throw new TypeError(`Command ${name} doesn't belong in commands.`);
this.client.commands.set(command.name, command);
if (command.aliases.length) {
for (const alias of command.aliases) {
this.client.aliases.set(alias, command.name);
}
}
}
});
}
async loadEvents() {
return glob(`${this.directory}events/**/*.js`).then(events => {
for (const eventFile of events) {
delete require.cache[eventFile];
const { name } = path.parse(eventFile);
const File = require(eventFile);
if (!this.isClass(File)) throw new TypeError(`Event ${name} doesn't export a class!`);
const event = new File(this.client, name.toLowerCase());
if (!(event instanceof Event)) throw new TypeError(`Event ${name} doesn't belong in the Events Directory.`);
this.client.events.set(event.name, event);
event.emitter[event.type](name, (...args) => event.run(...args));
}
});
}
};

37
src/commands/fun/8ball.ts Normal file
View File

@ -0,0 +1,37 @@
import {NamedCommand, RestCommand} from "onion-lasers";
import {random} from "../../lib";
const responses = [
"Most likely,",
"It is certain,",
"It is decidedly so,",
"Without a doubt,",
"Definitely,",
"You may rely on it,",
"As I see it, yes,",
"Outlook good,",
"Yes,",
"Signs point to yes,",
"Reply hazy, try again,",
"Ask again later,",
"Better not tell you now,",
"Cannot predict now,",
"Concentrate and ask again,",
"Don't count on it,",
"My reply is no,",
"My sources say no,",
"Outlook not so good,",
"Very doubtful,"
];
export default new NamedCommand({
description: "Answers your question in an 8-ball manner.",
usage: "<question>",
run: "Please provide a question.",
any: new RestCommand({
description: "Question to ask the 8-ball.",
async run({send, author}) {
send(`${random(responses)} ${author}`);
}
})
});

View File

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

45
src/commands/fun/eco.ts Normal file
View File

@ -0,0 +1,45 @@
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 NamedCommand({
description: "Economy command for Monika.",
async run({send, guild, channel, author}) {
if (isAuthorized(guild, channel)) send(getMoneyEmbed(author));
},
subcommands: {
daily: DailyCommand,
pay: PayCommand,
guild: GuildCommand,
leaderboard: LeaderboardCommand,
buy: BuyCommand,
shop: ShopCommand,
monday: MondayCommand,
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({send, guild, channel, args}) {
if (isAuthorized(guild, channel)) send(getMoneyEmbed(args[0]));
}
}),
any: new RestCommand({
description: "See how much money someone else has by using their username.",
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

@ -0,0 +1,19 @@
import {NamedCommand, RestCommand} from "onion-lasers";
import figlet from "figlet";
import {Util} from "discord.js";
export default new NamedCommand({
description: "Generates a figlet of your input.",
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\`\`\``
);
}
})
});

View File

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

11
src/commands/fun/love.ts Normal file
View File

@ -0,0 +1,11 @@
import {NamedCommand, CHANNEL_TYPE} from "onion-lasers";
export default new NamedCommand({
description: "Chooses someone to love.",
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

@ -0,0 +1,79 @@
import {Message} from "discord.js";
import {random} from "../../../lib";
export interface ShopItem {
cost: number;
title: string;
description: string;
usage: string;
run(message: Message, cost: number, amount: number): void;
}
export const ShopItems: ShopItem[] = [
{
cost: 3,
title: "Hug",
description: "Hug Monica.",
usage: "hug",
run(message, cost) {
message.channel.send(`Transaction of ${cost} Mons completed successfully. <@394808963356688394>`);
}
},
{
cost: 5,
title: "Handholding",
description: "Hold Monica's hand.",
usage: "handhold",
run(message, cost) {
message.channel.send(`Transaction of ${cost} Mons completed successfully. <@394808963356688394>`);
}
},
{
cost: 1,
title: "Cute",
description: "Calls Monica cute.",
usage: "cute",
run(message) {
message.channel.send("<:MoniCheeseBlushRed:637513137083383826>");
}
},
{
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({
content: random(lines),
files: [
{
attachment:
"https://raw.githubusercontent.com/keanuplayz/TravBot/dev/assets/TheUltimateLaser.gif"
}
]
});
}
}
];
const lines = [
"It's technically a laser bridge. No refunds.",
"You want a laser bridge? You got one!",
"Now what'd they say about building bridges... Oh wait, looks like I nuked the planet again. Whoops!",
"I saw this redhead the other day who was so excited to buy what I was selling. Needless to say, she was not very happy with me afterward.",
"Sorry, but you'll have to wait until the Laser Bridge Builder leaves early access.",
"Thank you for your purchase! For you see, this is the legendary laser of obliteration that has been defended and preserved for countless generations!",
"They say that a certain troll dwells under this laser bridge, waiting for an unlucky person to fall for th- I mean- Thank you for your purchase!",
"Buy?! Hah! How about our new rental service for just under $9.99 a month? But wait, there's more! For just $99.99, you can rent this laser bridge for an entire year and save 16.67% as opposed to renting it monthly!",
"Good choice. Owning a laser bridge is the penultimate experience that all true seekers strive for!",
'I can already imagine the reviews...\n"9/10 needs more lasers"'
];

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

67
src/commands/fun/ok.ts Normal file
View File

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

View File

@ -0,0 +1,21 @@
import {NamedCommand, RestCommand} from "onion-lasers";
import {random} from "../../lib";
export default new NamedCommand({
description: "OwO-ifies the input.",
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]});
}
}
})
});

67
src/commands/fun/poll.ts Normal file
View File

@ -0,0 +1,67 @@
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 NamedCommand({
description: "Create a poll.",
usage: "(<seconds>) <question>",
run: "Please provide a question.",
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({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();
}

42
src/commands/fun/ravi.ts Normal file
View File

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

60
src/commands/fun/thonk.ts Normal file
View File

@ -0,0 +1,60 @@
import {NamedCommand, RestCommand} from "onion-lasers";
const letters: {[letter: string]: string[]} = {
a: "aáàảãạâấầẩẫậăắằẳẵặ".split(""),
e: "eéèẻẽẹêếềểễệ".split(""),
i: "iíìỉĩị".split(""),
o: "oóòỏõọôốồổỗộơớờởỡợ".split(""),
u: "uúùủũụưứừửữự".split(""),
y: "yýỳỷỹỵ".split(""),
d: "dđ".split("")
};
function transform(str: string) {
let out = "";
for (const c of str) {
const token = c.toLowerCase();
const isUpperCase = token !== c;
if (token in letters) {
const set = letters[token];
const add = set[Math.floor(Math.random() * set.length)];
out += isUpperCase ? add.toUpperCase() : add;
} else {
out += c;
}
}
return out;
}
let phrase = "I have no currently set phrase!";
export default new NamedCommand({
description: "Transforms your text into .",
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
});
}
})
});

31
src/commands/fun/urban.ts Normal file
View File

@ -0,0 +1,31 @@
import {NamedCommand, RestCommand} from "onion-lasers";
import {MessageEmbed} from "discord.js";
import urban from "relevant-urban";
export default new NamedCommand({
description: "Gives you a definition of the inputted 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.");
});
}
})
});

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

@ -0,0 +1,38 @@
import {NamedCommand, RestCommand} from "onion-lasers";
import {MessageEmbed} from "discord.js";
import {find} from "weather-js";
export default new NamedCommand({
description: "Shows weather info of specified location.",
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]
});
}
);
}
})
});

93
src/commands/fun/whois.ts Normal file
View File

@ -0,0 +1,93 @@
import {User} from "discord.js";
import {Command, NamedCommand, getUserByNickname, RestCommand} from "onion-lasers";
// Quotes must be used here or the numbers will change
const registry: {[id: string]: string} = {
"465662909645848577": "You're an idiot, that's what.",
"306499531665833984":
"Kuma, you eldritch fuck, I demand you to release me from this Discord bot and let me see my Chromebook!",
"137323711844974592": "The purple haired gunner man who makes loud noises.",
"208763015657553921": "Minzy's master.",
"229636002443034624": "The ***God*** of being Smug.",
"280876114153308161": "The best girl.",
"175823837835821067": "The somehow sentient pear.",
"145839753118351360": "The blueberry with horns.",
"173917366504259585": "A talented developer.",
"216112465321263105": "The red strawberry cat.",
"394808963356688394": "The cutest, bestest, most caring girl ever.",
"142200534781132800": "The masters of chaos.",
"186496078273708033": "The cute blue cat.",
"241293368267767808": "The cute catgirl.",
"540419616803913738": "The generically Generic hologram man.",
"157598993298227211": "The somehow sentient bowl of nachos.",
"225214401228177408": "The CMD user.",
"224619540263337984": "The guy that did 50% of the work.",
"374298111255773184": "The cutest fox around.",
"150400803503472640": "The big huggy turtle boye.",
"620777734427115523": "The small huggy turtle boye.",
"310801870048198667": "An extremely talented artist and modder.",
"328223274133880833": "The stealthiest hitman.",
"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.~~",
"367439475153829892": "A weeb.",
"760375501775700038": "˙qǝǝʍ ∀",
"389178357302034442": "In his dreams, he is the star. its him. <:itsMe:808174425253871657>",
"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 NamedCommand({
description: "Tells you who you or the specified user is.",
aliases: ["whoami"],
async run({send, author}) {
const id = author.id;
if (id in registry) {
send({content: `${author} ${registry[id]}`, allowedMentions: {parse: []}});
} else {
send("You haven't been added to the registry yet!");
}
},
id: "user",
user: new Command({
async run({send, args}) {
const user: User = args[0];
const id = user.id;
if (id in registry) {
send({content: `${user} ${registry[id]}`, allowedMentions: {parse: []}});
} else {
send({content: `${user} hasn't been added to the registry yet!`, allowedMentions: {parse: []}});
}
}
}),
any: new RestCommand({
async run({send, guild, combined}) {
const user = await getUserByNickname(combined, guild);
if (typeof user !== "string") {
if (user.id in registry) {
send({content: `${user} ${registry[user.id]}`, allowedMentions: {parse: []}});
} else {
send({content: `${user} hasn't been added to the registry yet!`, allowedMentions: {parse: []}});
}
} else {
send(user);
}
}
})
});

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

@ -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

@ -0,0 +1,6 @@
import {NamedCommand} from "onion-lasers";
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

@ -0,0 +1,116 @@
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 NamedCommand({
description: "Lists all emotes the bot has in it's registry,",
usage: "<regex pattern> (-flags)",
async run({send, author, client}) {
displayEmoteList(Array.from(client.emojis.cache.values()), send, author);
},
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({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,}$/.test(args[0])) {
const guildID: string = args[0];
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);
}
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 = new Map<string, string>();
for (const emote of emoteCollection) {
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),
emotes
};
const context = vm.createContext(sandbox);
if (vm.isContext(sandbox)) {
// Restrict an entire query to the timeout specified.
try {
const script = new vm.Script(
"for(const [id, name] of emotes.entries()) if(!regex.test(name)) emotes.delete(id);"
);
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, send, author);
} catch (error) {
// 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(errorName);
}
}
} else {
send("Failed to initialize sandbox.");
}
}
}
})
});
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();
if (first > second) return 1;
else if (first < second) return -1;
else return 0;
});
const sections = split(emotes, 20);
const pages = sections.length;
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) {
paginate(send, author.id, pages, (page, hasMultiplePages) => {
embed.setTitle(hasMultiplePages ? `**Emotes** (Page ${page + 1} of ${pages})` : "**Emotes**");
let desc = "";
for (const emote of sections[page]) {
desc += `${emote} ${emote.name} (**${emote.guild.name}**)\n`;
}
embed.setDescription(desc);
return {embeds: [embed]};
});
} else {
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

@ -0,0 +1,197 @@
import {NamedCommand, CHANNEL_TYPE} from "onion-lasers";
import {pluralise} from "../../lib";
import moment from "moment";
import {Collection, TextChannel, Util} from "discord.js";
const lastUsedTimestamps = new Collection<string, number>();
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.",
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.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 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 | null;
formatted: string;
users: number;
bots: number;
};
} = {};
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 instanceof TextChannel && channel.viewable
) as Collection<string, TextChannel>;
let messagesSearched = 0;
let channelsSearched = 0;
let currentChannelName = "";
const totalChannels = allTextChannelsInCurrentGuild.size;
const statusMessage = await send("Gathering emotes...");
let warnings = 0;
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()) {
// 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,
formatted: `<${emote.animated ? "a" : ""}:${emote.name}:${emote.id}>`,
users: 0,
bots: 0
};
}
const interval = setInterval(() => {
statusMessage.edit(
`Searching channel \`${currentChannelName}\`... (${messagesSearched} messages scanned, ${channelsSearched}/${totalChannels} channels scanned)`
);
}, 5000);
for (const channel of allTextChannelsInCurrentGuild.values()) {
currentChannelName = channel.name;
let selected = channel.lastMessageId ?? message.id;
let continueLoop = true;
while (continueLoop) {
// Unfortunately, any kind of .fetch call is limited to 100 items at once by Discord's API.
const messages = await channel.messages.fetch({
limit: 100,
before: selected
});
if (messages.size > 0) {
for (const msg of messages.values()) {
// It's very important to not capture an array of matches then do \d+ on each item because emote names can have numbers in them, causing the bot to not count them correctly.
const search = /<a?:.+?:(\d+?)>/g;
const text = msg.content;
let match: RegExpExecArray | null;
while ((match = search.exec(text))) {
const emoteID = match[1];
if (emoteID in stats) {
if (msg.author.bot) stats[emoteID].bots++;
else {
stats[emoteID].users++;
totalUserEmoteUsage++;
}
}
}
for (const reaction of msg.reactions.cache.values()) {
const emoteID = reaction.emoji.id;
let continueReactionLoop = true;
let lastUserID: string | undefined;
let userReactions = 0;
let botReactions = 0;
// An emote's ID will be null if it's a unicode emote.
if (emoteID && emoteID in stats) {
// There is a simple count property on a reaction, but that doesn't separate users from bots.
// So instead, I'll use that property to check for inconsistencies.
while (continueReactionLoop) {
// After logging users, it seems like the order is strictly numerical. As long as that stays consistent, this should work fine.
const users = await reaction.users.fetch({
limit: 100,
after: lastUserID
});
if (users.size > 0) {
for (const user of users.values()) {
if (user.bot) {
stats[emoteID].bots++;
botReactions++;
} else {
stats[emoteID].users++;
totalUserEmoteUsage++;
userReactions++;
}
lastUserID = user.id;
}
} else {
// Then halt the loop and send warnings of any inconsistencies.
continueReactionLoop = false;
if (reaction.count !== userReactions + botReactions) {
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++;
}
}
}
}
}
selected = msg.id;
messagesSearched++;
}
} else {
continueLoop = false;
channelsSearched++;
}
}
}
// Mark the operation as ended.
const finishTime = Date.now();
clearInterval(interval);
statusMessage.edit(
`Finished operation in ${moment.duration(finishTime - startTime).humanize()} with ${pluralise(
warnings,
"inconsistenc",
"ies",
"y"
)}.`
);
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.
let sortedEmoteIDs = Object.keys(stats).sort((a, b) => stats[b].users - stats[a].users);
const lines: string[] = [];
let rank = 1;
// It's better to send all the lines at once rather than paginate the data because it's quite a memory-intensive task to search all the messages in a server for it, and I wouldn't want to activate the command again just to get to another page.
for (const emoteID of sortedEmoteIDs) {
const emote = stats[emoteID];
const botInfo = emote.bots > 0 ? ` (Bots: ${emote.bots})` : "";
lines.push(
`\`#${rank++}\` ${emote.formatted} x ${emote.users} - ${(
(emote.users / totalUserEmoteUsage) * 100 || 0
).toFixed(3)}%` + botInfo
);
}
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

@ -0,0 +1,20 @@
import {Command, NamedCommand} from "onion-lasers";
import * as https from "https";
export default new NamedCommand({
description: "Shortens a given URL.",
run: "Please provide a URL.",
any: new Command({
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 () {
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.");
}
})
}
});

47
src/defs/info.ts Normal file
View File

@ -0,0 +1,47 @@
// Flags a user can have.
// They're basically your profile badges.
export const flags: {[index: string]: string} = {
DISCORD_EMPLOYEE: "Discord Employee",
DISCORD_PARTNER: "Discord Partner",
BUGHUNTER_LEVEL_1: "Bug Hunter (Level 1)",
BUGHUNTER_LEVEL_2: "Bug Hunter (Level 2)",
HYPESQUAD_EVENTS: "HypeSquad Events",
HOUSE_BRAVERY: "House of Bravery",
HOUSE_BRILLIANCE: "House of Brilliance",
HOUSE_BALANCE: "House of Balance",
EARLY_SUPPORTER: "Early Supporter",
TEAM_USER: "Team User",
SYSTEM: "System",
VERIFIED_BOT: "Verified Bot",
VERIFIED_DEVELOPER: "Verified Bot Developer"
};
export const filterLevels: {[index: string]: string} = {
DISABLED: "Off",
MEMBERS_WITHOUT_ROLES: "No Role",
ALL_MEMBERS: "Everyone"
};
export const verificationLevels: {[index: string]: string} = {
NONE: "None",
LOW: "Low",
MEDIUM: "Medium",
HIGH: "(╯°□°)╯︵ ┻━┻",
VERY_HIGH: "┻━┻ ミヽ(ಠ益ಠ)ノ彡┻━┻"
};
export const regions: {[index: string]: string} = {
brazil: "Brazil",
europe: "Europe",
hongkong: "Hong Kong",
india: "India",
japan: "Japan",
russia: "Russia",
singapore: "Singapore",
southafrica: "South Africa",
sydney: "Sydney",
"us-central": "US Central",
"us-east": "US East",
"us-west": "US West",
"us-south": "US South"
};

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,5 +0,0 @@
const BotClient = require('./Structures/BotClient');
const config = require('../config.json');
const client = new BotClient(config);
client.start();

91
src/index.ts Normal file
View File

@ -0,0 +1,91 @@
import "./modules/globals";
import {Client, Permissions, Intents} from "discord.js";
import path from "path";
// 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({
intents: [
Intents.FLAGS.GUILDS,
Intents.FLAGS.GUILD_MEMBERS,
Intents.FLAGS.GUILD_EMOJIS_AND_STICKERS,
Intents.FLAGS.GUILD_VOICE_STATES,
Intents.FLAGS.GUILD_PRESENCES,
Intents.FLAGS.GUILD_MESSAGES,
Intents.FLAGS.GUILD_MESSAGE_REACTIONS,
Intents.FLAGS.DIRECT_MESSAGES
]
});
import {launch} from "onion-lasers";
import setup from "./modules/setup";
import {Config, getPrefix} from "./structures";
import {toTitleCase} from "./lib";
// Send the login request to Discord's API and then load modules while waiting for it.
setup.init().then(() => {
client.login(Config.token).catch(setup.again);
});
// Setup the command handler.
launch(client, path.join(__dirname, "commands"), {
getPrefix,
categoryTransformer: toTitleCase,
permissionLevels: [
{
// NONE //
name: "User",
check: () => true
},
{
// MOD //
name: "Moderator",
check: (_user, member) =>
!!member &&
(member.permissions.has(Permissions.FLAGS.MANAGE_ROLES) ||
member.permissions.has(Permissions.FLAGS.MANAGE_MESSAGES) ||
member.permissions.has(Permissions.FLAGS.KICK_MEMBERS) ||
member.permissions.has(Permissions.FLAGS.BAN_MEMBERS))
},
{
// ADMIN //
name: "Administrator",
check: (_user, member) => !!member && member.permissions.has(Permissions.FLAGS.ADMINISTRATOR)
},
{
// OWNER //
name: "Server Owner",
check: (_user, member) => !!member && member.guild.ownerId === member.id
},
{
// BOT_SUPPORT //
name: "Bot Support",
check: (user) => Config.support.includes(user.id)
},
{
// BOT_ADMIN //
name: "Bot Admin",
check: (user) => Config.admins.includes(user.id)
},
{
// BOT_OWNER //
name: "Bot Owner",
check: (user) => Config.owner === user.id
}
]
});
// Initialize Modules //
import "./modules/ready";
import "./modules/presence";
// TODO: Reimplement entire music system, contact Sink
// import "./modules/lavalink";
import "./modules/emoteRegistry";
import "./modules/systemInfo";
import "./modules/intercept";
// import "./modules/messageEmbed";
import "./modules/guildMemberAdd";
import "./modules/streamNotifications";
import "./modules/channelDefaults";
// This module must be loaded last for the dynamic event reading to work properly.
import "./modules/eventLogging";

92
src/lib.test.ts Normal file
View File

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

271
src/lib.ts Normal file
View File

@ -0,0 +1,271 @@
// Library for pure functions
import {get} from "https";
import FileManager from "./modules/storage";
/**
* Splits a command by spaces while accounting for quotes which capture string arguments.
* - `\"` = `"`
* - `\\` = `\`
*/
export function parseArgs(line: string): string[] {
let result = [];
let selection = "";
let inString = false;
let isEscaped = false;
for (let c of line) {
if (isEscaped) {
if (['"', "\\"].includes(c)) selection += c;
else selection += "\\" + c;
isEscaped = false;
} else if (c === "\\") isEscaped = true;
else if (c === '"') inString = !inString;
else if (c === " " && !inString) {
result.push(selection);
selection = "";
} else selection += c;
}
if (selection.length > 0) result.push(selection);
return result;
}
/**
* Allows you to store a template string with variable markers and parse it later.
* - Use `%name%` for variables
* - `%%` = `%`
* - If the invalid token is null/undefined, nothing is changed.
*/
export function parseVars(
line: string,
definitions: {[key: string]: string},
delimiter = "%",
invalid: string | null = ""
): string {
let result = "";
let inVariable = false;
let token = "";
for (const c of line) {
if (c === delimiter) {
if (inVariable) {
if (token === "") result += delimiter;
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 parseVarsCallback(line: string, callback: (variable: string) => string, delimiter = "%"): string {
let result = "";
let inVariable = false;
let token = "";
for (const c of line) {
if (c === delimiter) {
if (inVariable) {
if (token === "") result += delimiter;
else {
result += callback(token);
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: unknown) {
if (typeof text === "string")
return text.replace(/`/g, "`" + String.fromCharCode(8203)).replace(/@/g, "@" + String.fromCharCode(8203));
else return text;
}
export function trimArray(arr: any, maxLen = 10) {
if (arr.length > maxLen) {
const len = arr.length - maxLen;
arr = arr.slice(0, maxLen);
arr.push(`${len} more...`);
}
return arr;
}
export function formatBytes(bytes: any) {
if (bytes === 0) return "0 Bytes";
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${parseFloat((bytes / Math.pow(1024, i)).toFixed(2))} ${sizes[i]}`;
}
export function getContent(url: string): Promise<{url: string}> {
return new Promise((resolve, reject) => {
get(url, (res) => {
const {statusCode} = res;
if (statusCode !== 200) {
res.resume();
reject(`Request failed. Status code: ${statusCode}`);
}
res.setEncoding("utf8");
let rawData = "";
res.on("data", (chunk: string) => {
rawData += chunk;
});
res.on("end", () => {
try {
const parsedData = JSON.parse(rawData);
resolve(parsedData);
} catch (e) {
let errorMessage = "Something went wrong! We don't know what, though...";
if (e instanceof Error) {
errorMessage = e.message;
}
reject(`Error: ${errorMessage}`);
}
});
}).on("error", (err: {message: any}) => {
reject(`Error: ${err.message}`);
});
});
}
export interface GenericJSON {
[key: string]: any;
}
// In order to define a file to write to while also not:
// - Using the delete operator (which doesn't work on properties which cannot be undefined)
// - Assigning it first then using Object.defineProperty (which raises a flag on CodeQL)
// A non-null assertion is used on the class property to say that it'll definitely be assigned.
export abstract class GenericStructure {
private __meta__!: string;
constructor(tag?: string) {
Object.defineProperty(this, "__meta__", {
value: tag || "generic",
enumerable: false
});
}
public save(asynchronous = true) {
FileManager.write(this.__meta__, this, asynchronous);
}
}
// A 50% chance would be "Math.random() < 0.5" because Math.random() can be [0, 1), so to make two equal ranges, you'd need [0, 0.5)U[0.5, 1).
// Similar logic would follow for any other percentage. Math.random() < 1 is always true (100% chance) and Math.random() < 0 is always false (0% chance).
export const Random = {
num: (min: number, max: number) => Math.random() * (max - min) + min,
int: (min: number, max: number) => Math.floor(Random.num(min, max)),
chance: (decimal: number) => Math.random() < decimal,
sign: (number = 1) => number * (Random.chance(0.5) ? -1 : 1),
deviation: (base: number, deviation: number) => Random.num(base - deviation, base + deviation)
};
/**
* Pluralises a word and chooses a suffix attached to the root provided.
* - pluralise("credit", "s") = credit/credits
* - pluralise("part", "ies", "y") = party/parties
* - pluralise("sheep") = sheep
*/
export function pluralise(value: number, word: string, plural = "", singular = "", excludeNumber = false): string {
let result = excludeNumber ? "" : `${value} `;
if (value === 1) result += word + singular;
else result += word + plural;
return result;
}
/**
* Pluralises a word for changes.
* - (-1).pluraliseSigned() = '-1 credits'
* - (0).pluraliseSigned() = '+0 credits'
* - (1).pluraliseSigned() = '+1 credit'
*/
export function pluraliseSigned(
value: number,
word: string,
plural = "",
singular = "",
excludeNumber = false
): string {
const sign = value >= 0 ? "+" : "";
return `${sign}${pluralise(value, word, plural, singular, excludeNumber)}`;
}
export function replaceAll(text: string, before: string, after: string): string {
while (text.indexOf(before) !== -1) text = text.replace(before, after);
return text;
}
export function toTitleCase(text: string): string {
return text.replace(/([^\W_]+[^\s-]*) */g, (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase());
}
/** Returns a random element from this array. */
export function random<T>(array: T[]): T {
return array[Math.floor(Math.random() * array.length)];
}
/**
* Splits up this array into a specified length.
* `$([1,2,3,4,5,6,7,8,9,10]).split(3)` = `[[1,2,3],[4,5,6],[7,8,9],[10]]`
*/
export function split<T>(array: T[], lengthOfEachSection: number): T[][] {
const amountOfSections = Math.ceil(array.length / lengthOfEachSection);
const sections = new Array<T[]>(amountOfSections);
for (let index = 0; index < amountOfSections; index++)
sections[index] = array.slice(index * lengthOfEachSection, (index + 1) * lengthOfEachSection);
return sections;
}
/**
* Utility function to require all possible cases to be handled at compile time.
*
* To use this function, place it in the "default" case of a switch statement or the "else" statement of an if-else branch.
* If all cases are handled, the variable being tested for should be of type "never", and if it isn't, that means not all cases are handled yet.
*/
export function requireAllCasesHandledFor(variable: never): never {
throw new Error(`This function should never be called but got the value: ${variable}`);
}

View File

@ -0,0 +1,17 @@
import {client} from "../index";
import {Storage} from "../structures";
import {Permissions} from "discord.js";
client.on("voiceStateUpdate", async (before, after) => {
const channel = before.channel;
const {channelNames} = Storage.getGuild(after.guild.id);
if (
channel &&
channel.members.size === 0 &&
channel.id in channelNames &&
before.guild.me?.permissions.has(Permissions.FLAGS.MANAGE_CHANNELS)
) {
channel.setName(channelNames[channel.id]);
}
});

View File

@ -0,0 +1,33 @@
import {client} from "../index";
import FileManager from "./storage";
import {EmoteRegistryDump} from "../structures";
async function updateGlobalEmoteRegistry(): Promise<void> {
const data: EmoteRegistryDump = {version: 1, list: []};
for (const guild of client.guilds.cache.values()) {
let g = await guild.fetch();
for (const emote of g.emojis.cache.values()) {
data.list.push({
ref: emote.name,
id: emote.id,
name: emote.name,
requires_colons: emote.requiresColons ?? false,
animated: emote.animated ?? false,
url: emote.url,
guild_id: emote.guild.name,
guild_name: emote.guild.name
});
}
}
FileManager.open("data/public"); // generate folder if it doesn't exist
FileManager.write("public/emote-registry", data, false);
}
client.on("emojiCreate", updateGlobalEmoteRegistry);
client.on("emojiDelete", updateGlobalEmoteRegistry);
client.on("emojiUpdate", updateGlobalEmoteRegistry);
client.on("guildCreate", updateGlobalEmoteRegistry);
client.on("guildDelete", updateGlobalEmoteRegistry);
client.on("ready", updateGlobalEmoteRegistry);

View File

@ -0,0 +1,56 @@
// This will keep track of the last event that occurred to provide context to error messages.
// Like with logging each command invocation, it's not a good idea to pollute the logs with this kind of stuff when it works most of the time.
// However, it's also a pain to debug when no context is provided for an error message.
import {client} from "..";
import {setExecuteCommandListener} from "onion-lasers";
import {TextChannel, DMChannel, NewsChannel} from "discord.js";
let lastEvent = "N/A";
let lastCommandInfo: {
header: string;
args: string[];
channel: TextChannel | DMChannel | NewsChannel | null;
} = {
header: "N/A",
args: [],
channel: null
};
process.on("unhandledRejection", (reason: any) => {
const isLavalinkError = reason?.code === "ECONNREFUSED";
const isDiscordError = reason?.name === "DiscordAPIError";
if (!isLavalinkError) {
// If it's a DiscordAPIError on a message event, I'll make the assumption that it comes from the command handler.
// That's not always the case though, especially if you add your own message events. Just be wary of that.
if (isDiscordError && lastEvent === "message") {
console.error(
`Command Error: ${lastCommandInfo.header} (${lastCommandInfo.args.join(", ")})\n${reason.stack}`
);
lastCommandInfo.channel?.send(
`There was an error while trying to execute that command!\`\`\`${reason.stack}\`\`\``
);
} else {
console.error(
`@${lastEvent} : /${lastCommandInfo.header} (${lastCommandInfo.args.join(", ")})\n${reason.stack}`
);
}
}
});
// Store info on which command was executed last.
setExecuteCommandListener(({header, args, channel}) => {
lastCommandInfo = {
header,
args,
channel
};
});
// This will dynamically attach all known events instead of doing it manually.
// As such, it needs to be placed after all other events are attached or the tracking won't be done properly.
for (const event of client.eventNames()) {
client.on(event, () => {
lastEvent = event.toString();
});
}

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

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

View File

@ -0,0 +1,78 @@
import {createCanvas, loadImage, Canvas} from "canvas";
import {TextChannel, MessageAttachment} from "discord.js";
import {parseVars} from "../lib";
import {Storage} from "../structures";
import {client} from "../index";
function applyText(canvas: Canvas, text: string) {
const ctx = canvas.getContext("2d");
let fontSize = 70;
do {
ctx.font = `${(fontSize -= 10)}px sans-serif`;
} while (ctx.measureText(text).width > canvas.width - 300);
return ctx.font;
}
client.on("guildMemberAdd", async (member) => {
const {welcomeType, welcomeChannel, welcomeMessage, autoRoles} = Storage.getGuild(member.guild.id);
if (autoRoles) {
member.roles.add(autoRoles);
}
if (welcomeChannel) {
const channel = member.guild.channels.cache.get(welcomeChannel);
if (channel && channel instanceof TextChannel) {
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.send({content: `Welcome \`${member.user.tag}\`!`, attachments: [attachment]});
} else if (welcomeType === "text") {
channel.send(
parseVars(
welcomeMessage || "Say hello to `%user%`, everyone! We all need a warm welcome sometimes :D",
{
user: member.user.tag
}
)
);
}
} else {
console.error("[modules/guildMemberAdd]", `"${welcomeChannel}" is not a valid text channel ID!`);
}
}
});

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

@ -0,0 +1,12 @@
import {client} from "../index";
// Potentially port CE's intercept module to here?
// - ` ${text} `.test(/[ \.,\?!]hon[ \.,\?!]/)
// - "oil" will remain the same though, it's better that way (anything even remotely "oil"-related calls the image)
// - Also uwu and owo penalties
client.on("message", (message) => {
if (message.content.toLowerCase().includes("remember to drink water")) {
message.react("🚱");
}
});

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

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

View File

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

View File

@ -0,0 +1,58 @@
import {client} from "../index";
import {MessageEmbed} from "discord.js";
import {getPrefix} from "../structures";
import {getMessageByID} from "onion-lasers";
client.on("message", (message) => {
(async () => {
// Only execute if the message is from a user and isn't a command.
if (message.content.startsWith(getPrefix(message.guild)) || message.author.bot) return;
const messageLink = extractFirstMessageLink(message.content);
if (!messageLink) return;
const [guildID, channelID, messageID] = messageLink;
const linkMessage = await getMessageByID(channelID, messageID);
// If it's an invalid link (or the bot doesn't have access to it).
if (typeof linkMessage === "string") {
return message.channel.send("I don't have access to that channel!");
}
const embeds = [
...linkMessage.embeds.filter((embed) => embed.type === "rich"),
...linkMessage.attachments.values()
];
if (!linkMessage.cleanContent && embeds.length === 0) {
return message.channel.send({embeds: [new MessageEmbed().setDescription("🚫 The message is empty.")]});
}
const infoEmbed = new MessageEmbed()
.setAuthor(
linkMessage.author.username,
linkMessage.author.displayAvatarURL({format: "png", dynamic: true, size: 4096})
)
.setTimestamp(linkMessage.createdTimestamp)
.setDescription(
`${linkMessage.cleanContent}\n\nSent in **${linkMessage.guild?.name}** | <#${linkMessage.channel.id}> ([link](https://discord.com/channels/${guildID}/${channelID}/${messageID}))`
);
if (linkMessage.attachments.size !== 0) {
const image = linkMessage.attachments.first();
infoEmbed.setImage(image!.url);
}
return await message.channel.send({embeds: [infoEmbed]});
})();
});
export function extractFirstMessageLink(message: string): [string, string, string] | null {
const messageLinkMatch = message.match(
/([!<])?https?:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/channels\/(\d{17,})\/(\d{17,})\/(\d{17,})(>)?/
);
if (messageLinkMatch === null) return null;
const [, leftToken, guildID, channelID, messageID, rightToken] = messageLinkMatch;
// "!link" and "<link>" will cancel the embed request.
if (leftToken === "!" || (leftToken === "<" && rightToken === ">")) return null;
else return [guildID, channelID, messageID];
}

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

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

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

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

73
src/modules/setup.ts Normal file
View File

@ -0,0 +1,73 @@
import {existsSync as exists} from "fs";
import inquirer from "inquirer";
import Storage from "./storage";
import {Config} from "../structures";
// 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(false);
}
},
/** Prompt the user to set their token again. */
async again() {
console.error("It seems that the token you provided is invalid.");
// Deactivate the console //
const oldConsole = console;
console = {
...oldConsole,
log() {},
warn() {},
error() {},
debug() {},
ready() {}
};
const answers = await inquirer.prompt(prompts.slice(0, 1));
Config.token = answers.token as string;
Config.save(false);
process.exit();
}
};

68
src/modules/storage.ts Normal file
View File

@ -0,0 +1,68 @@
// Handles most of the file system operations, all of the ones related to `data` at least.
import fs from "fs";
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") {
console.warn("[storage.read]", `Malformed JSON data (header: ${header}), backing it up.`, file);
fs.writeFile(`${path}.backup`, file, (error) => {
if (error) console.error("[storage.read]", error);
console.log("[storage.read]", `Backup file of "${header}" successfully written as ${file}.`);
});
}
}
}
return data;
},
// There is no need to log successfully written operations as it pollutes the log with useless info for debugging.
write(header: string, data: object, asynchronous = true) {
this.open("data");
const path = `data/${header}.json`;
if (IS_DEV_MODE || header === "config") {
const result = JSON.stringify(data, null, "\t");
if (asynchronous)
fs.writeFile(path, result, (error) => {
if (error) console.error("[storage.write]", error);
});
else fs.writeFileSync(path, result);
} else {
const result = JSON.stringify(data);
if (asynchronous)
fs.writeFile(path, result, (error) => {
if (error) console.error("[storage.write]", error);
});
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, (error) => {
if (error) console.error("[storage.close]", error);
});
}
};
export default Storage;

View File

@ -0,0 +1,137 @@
import {GuildMember, VoiceChannel, MessageEmbed, TextChannel, Message, Collection, StageChannel} from "discord.js";
import {client} from "../index";
import {Storage} from "../structures";
type Stream = {
streamer: GuildMember;
channel: VoiceChannel | StageChannel;
category: string;
description?: string;
thumbnail?: string;
message: Message;
streamStart: number;
update: () => void;
};
// A list of user IDs and message embeds.
export const streamList = new Collection<string, Stream>();
// Probably find a better, DRY way of doing this.
function getStreamEmbed(
streamer: GuildMember,
channel: VoiceChannel | StageChannel,
streamStart: number,
category: string,
description?: string,
thumbnail?: string
): MessageEmbed {
const user = streamer.user;
const embed = new MessageEmbed()
.setTitle(channel.name)
.setAuthor(
streamer.nickname ?? user.username,
user.avatarURL({
dynamic: true,
format: "png"
}) ?? user.defaultAvatarURL
)
// I decided to not include certain fields:
// .addField("Activity", "CrossCode", true) - Probably too much presence data involved, increasing memory usage.
// .addField("Viewers", 5, true) - There doesn't seem to currently be a way to track how many viewers there are. Presence data for "WATCHING" doesn't seem to affect it, and listening to raw client events doesn't seem to make it appear either.
.addField("Voice Channel", channel.toString(), true)
.addField("Category", category, true)
.setColor(streamer.displayColor)
.setFooter(
"Stream Started",
streamer.guild.iconURL({
dynamic: true
}) || undefined
)
.setTimestamp(streamStart);
if (description) embed.setDescription(description);
if (thumbnail) embed.setThumbnail(thumbnail);
return embed;
}
client.on("voiceStateUpdate", async (before, after) => {
const isStartStreamEvent = !before.streaming && after.streaming;
const isStopStreamEvent = before.streaming && (!after.streaming || !after.channel); // If you were streaming before but now are either not streaming or have left the channel.
// Note: isStopStreamEvent can be called twice in a row - If Discord crashes/quits while you're streaming, it'll call once with a null channel and a second time with a channel.
if (isStartStreamEvent || isStopStreamEvent) {
const {streamingChannel, streamingRoles, members} = Storage.getGuild(after.guild.id);
if (streamingChannel) {
const member = after.member!;
const voiceChannel = after.channel!;
const textChannel = client.channels.cache.get(streamingChannel);
// Although checking the bot's permission to send might seem like a good idea, having the error be thrown will cause it to show up in the last channel rather than just show up in the console.
if (textChannel instanceof TextChannel) {
if (isStartStreamEvent) {
const streamStart = Date.now();
let streamNotificationPing = "";
let category = "None";
// Check the category if there's one set then ping that role.
if (member.id in members) {
const roleID = members[member.id].streamCategory;
// Only continue if they set a valid category.
if (roleID && roleID in streamingRoles) {
streamNotificationPing = `<@&${roleID}>`;
category = streamingRoles[roleID];
}
}
streamList.set(member.id, {
streamer: member,
channel: voiceChannel,
category,
message: await textChannel.send({
content: streamNotificationPing,
embeds: [getStreamEmbed(member, voiceChannel, streamStart, category)]
}),
update(this: Stream) {
this.message.edit({
embeds: [
getStreamEmbed(
this.streamer,
this.channel,
streamStart,
this.category,
this.description,
this.thumbnail
)
]
});
},
streamStart
});
} else if (isStopStreamEvent) {
if (streamList.has(member.id)) {
const {message} = streamList.get(member.id)!;
message.delete();
streamList.delete(member.id);
}
}
} else {
console.error(
`The streaming notifications channel ${streamingChannel} for guild ${after.guild.id} either doesn't exist or isn't a text channel.`
);
}
}
}
});
client.on("channelUpdate", (before, after) => {
if (before instanceof VoiceChannel && after instanceof VoiceChannel) {
for (const stream of streamList.values()) {
if (after.id === stream.channel.id) {
stream.update();
}
}
}
});

42
src/modules/systemInfo.ts Normal file
View File

@ -0,0 +1,42 @@
import {client} from "../index";
import {TextChannel} from "discord.js";
import {Config} from "../structures";
// Logging which guilds the bot is added to and removed from makes sense.
// However, logging the specific channels that are added/removed is a tad bit privacy-invading.
client.on("guildCreate", async (guild) => {
const owner = await guild.fetchOwner();
console.log(`[GUILD JOIN] ${guild.name} (${guild.id}) added the bot. Owner: ${owner.user.tag} (${owner.user.id}).`);
if (Config.systemLogsChannel) {
const channel = client.channels.cache.get(Config.systemLogsChannel);
if (channel instanceof TextChannel) {
channel.send(
`TravBot joined: \`${guild.name}\`. The owner of this guild is: \`${owner.user.tag}\` (\`${owner.user.id}\`)`
);
} else {
console.warn(`${Config.systemLogsChannel} is not a valid text channel for system logs!`);
}
}
});
client.on("guildDelete", (guild) => {
console.log(`[GUILD LEAVE] ${guild.name} (${guild.id}) removed the bot.`);
if (Config.systemLogsChannel) {
const channel = client.channels.cache.get(Config.systemLogsChannel);
if (channel instanceof TextChannel) {
channel.send(`\`${guild.name}\` (\`${guild.id}\`) removed the bot.`);
} else {
console.warn(
`${Config.systemLogsChannel} is not a valid text channel for system logs! Removing it from storage.`
);
Config.systemLogsChannel = null;
Config.save();
}
}
});

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