A powerful and extendable Discord bot, with it's own module system :3 thevoid.cafe/projects/voidy

Compare changes

Choose any two refs to compare.

+5 -3
.env.example
··· 1 - BOT_TOKEN=#Get this from https://discord.com/developers - REQUIRED 2 - BOT_CLIENT_ID=#Get this from https://discord.com/developers - REQUIRED 3 - DB_URI=#Your MongoDB connection URI - REQUIRED 1 + BOT_TOKEN: #Get this from https://discord.com/developers - REQUIRED 2 + BOT_CLIENT_ID: #Get this from https://discord.com/developers - REQUIRED 3 + BOT_ADMINS: #Discord Ids of bot administrators, comma separated 4 + DB_URI: #Your MongoDB connection URI - REQUIRED 5 + ACCESS_TOKEN: # Your API Bearer token
.github/assets/cyn-hi-chat-cyn-murder-drones.gif

This is a binary file and will not be displayed.

+30 -8
.gitignore
··· 1 - ### Jetbrains 2 - .idea 3 - .vscode 1 + # dependencies (bun install) 2 + node_modules 3 + 4 + # output 5 + out 6 + dist 7 + *.tgz 4 8 5 - ### Node/Deno/Bun 6 - node_modules 9 + # code coverage 10 + coverage 11 + *.lcov 7 12 8 - ### Secrets 13 + # logs 14 + logs 15 + _.log 16 + report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 17 + 18 + # dotenv environment variable files 9 19 .env 20 + .env.development.local 21 + .env.test.local 22 + .env.production.local 23 + .env.local 10 24 11 - ### Docker 12 - docker-compose.yml 25 + # caches 26 + .eslintcache 27 + .cache 28 + *.tsbuildinfo 29 + 30 + # IntelliJ based IDEs 31 + .idea 32 + 33 + # Finder (MacOS) folder config 34 + .DS_Store
+3
.oxfmtrc.jsonc
··· 1 + { 2 + "$schema": "./node_modules/oxfmt/configuration_schema.json", 3 + }
-31
.zed/settings.json
··· 1 - { 2 - "lsp": { 3 - "deno": { 4 - "settings": { 5 - "deno": { 6 - "enable": true 7 - } 8 - } 9 - } 10 - }, 11 - "languages": { 12 - "TypeScript": { 13 - "language_servers": [ 14 - "deno", 15 - "!typescript-language-server", 16 - "!vtsls", 17 - "!eslint" 18 - ], 19 - "formatter": "language_server" 20 - }, 21 - "TSX": { 22 - "language_servers": [ 23 - "deno", 24 - "!typescript-language-server", 25 - "!vtsls", 26 - "!eslint" 27 - ], 28 - "formatter": "language_server" 29 - } 30 - } 31 - }
+18 -9
README.md
··· 1 1 <br> 2 - <h1 align="center">๏ธโœจ Voidy โœจ<br></h1> 3 - <div align="center">My powerful discord bot :3</div> 2 + <h1 align="center">๏ธVoidy<br></h1> 3 + <div align="center">A powerful and extendable Discord bot, with it's own module system :3</div> 4 4 <br> 5 5 6 6 ## ๐Ÿš€ Deployment 7 - To deploy this project, in development mode, run the following command in your terminal of choice. 7 + 8 + Deploying this project is extremely simple. First, clone the repository: 8 9 9 10 ```sh 10 - deno task dev 11 + git clone git@tangled.org:thevoid.cafe/voidy 12 + ``` 13 + 14 + Then, navigate to the project directory and install all dependencies: 15 + 16 + ```sh 17 + cd voidy 18 + bun install 11 19 ``` 12 20 13 - ## ๐Ÿ“ Goals 14 - - Implement a robust combined event and command handling system, with proper namespacing, titled "feature manager" 15 - - Built-in statistics system, which records monthly channel/user activity, command executions per guild/user, and more (all recorded data is viewable by the user or guild admins) 16 - - Built-in relationship management system, which allows sharing secret messages and giving other users access to your statistics data 17 - - Integration of Snowfall API, with configurable instance URL and token, for synchronizing statistics, in a last.fm like fashion, but more open and cool 21 + Finally, run the development server: 22 + 23 + ```sh 24 + bun dev 25 + ``` 18 26 19 27 ## ๐ŸŽจ Credits 28 + 20 29 Certain features of this bot were the result 21 30 of conversations with: 22 31
+182
bun.lock
··· 1 + { 2 + "lockfileVersion": 1, 3 + "configVersion": 0, 4 + "workspaces": { 5 + "": { 6 + "name": "voidydiscord", 7 + "devDependencies": { 8 + "oxfmt": "^0.17.0", 9 + }, 10 + }, 11 + "packages/api": { 12 + "name": "@voidy/api", 13 + "version": "0.1.0", 14 + "dependencies": { 15 + "@voidy/bot": "workspace:*", 16 + "hono": "^4.11.1", 17 + "mongoose": "^9.0.1", 18 + }, 19 + "devDependencies": { 20 + "@types/bun": "latest", 21 + }, 22 + "peerDependencies": { 23 + "typescript": "^5.9.2", 24 + }, 25 + }, 26 + "packages/bot": { 27 + "name": "@voidy/bot", 28 + "version": "0.1.0", 29 + "dependencies": { 30 + "@voidy/api": "workspace:*", 31 + "@voidy/framework": "workspace:*", 32 + "discord.js": "^14.25.1", 33 + "hono": "^4.11.1", 34 + "mongoose": "^9.0.1", 35 + }, 36 + "devDependencies": { 37 + "@types/bun": "latest", 38 + }, 39 + "peerDependencies": { 40 + "typescript": "^5.9.2", 41 + }, 42 + }, 43 + "packages/framework": { 44 + "name": "@voidy/framework", 45 + "version": "0.1.0", 46 + "dependencies": { 47 + "discord.js": "^14.25.1", 48 + }, 49 + "devDependencies": { 50 + "@types/bun": "latest", 51 + }, 52 + "peerDependencies": { 53 + "typescript": "^5.9.2", 54 + }, 55 + }, 56 + }, 57 + "packages": { 58 + "@discordjs/builders": ["@discordjs/builders@1.13.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.33", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w=="], 59 + 60 + "@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="], 61 + 62 + "@discordjs/formatters": ["@discordjs/formatters@0.6.2", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ=="], 63 + 64 + "@discordjs/rest": ["@discordjs/rest@2.6.0", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.1.1", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.3", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.16", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w=="], 65 + 66 + "@discordjs/util": ["@discordjs/util@1.2.0", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg=="], 67 + 68 + "@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="], 69 + 70 + "@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.4.0", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-ZHzx7Z3rdlWL1mECydvpryWN/ETXJiCxdgQKTAH+djzIPe77HdnSizKBDi1TVDXZjXyOj2IqEG/vPw71ULF06w=="], 71 + 72 + "@oxfmt/darwin-arm64": ["@oxfmt/darwin-arm64@0.17.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-OMv0tOb+xiwSZKjYbM6TwMSP5QwFJlBGQmEsk98QJ30sHhdyC//0UvGKuR0KZuzZW4E0+k0rHDmos1Z5DmBEkA=="], 73 + 74 + "@oxfmt/darwin-x64": ["@oxfmt/darwin-x64@0.17.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-trzidyzryKIdL/cLCYU9IwprgJegVBUrz1rqzOMe5is+qdgH/RxTCvhYUNFzxRHpil3g4QUYd2Ja831tc5Nehg=="], 75 + 76 + "@oxfmt/linux-arm64-gnu": ["@oxfmt/linux-arm64-gnu@0.17.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-KlwzidgvHznbUaaglZT1goTS30osTV553pfbKve9B1PyTDkluNDfm/polOaf3SVLN7wL/NNLFZRMupvJ1eJXAw=="], 77 + 78 + "@oxfmt/linux-arm64-musl": ["@oxfmt/linux-arm64-musl@0.17.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-+tbYJTocF4BNLaQQbc/xrBWTNgiU6zmYeF4NvRDxuuQjDOnmUZPn0EED3PZBRJyg4/YllhplHDo8x+gfcb9G3A=="], 79 + 80 + "@oxfmt/linux-x64-gnu": ["@oxfmt/linux-x64-gnu@0.17.0", "", { "os": "linux", "cpu": "x64" }, "sha512-pEmv7zJIw2HpnA4Tn1xrfJNGi2wOH2+usT14Pkvf/c5DdB+pOir6k/5jzfe70+V3nEtmtV9Lm+spndN/y6+X7A=="], 81 + 82 + "@oxfmt/linux-x64-musl": ["@oxfmt/linux-x64-musl@0.17.0", "", { "os": "linux", "cpu": "x64" }, "sha512-+DrFSCZWyFdtEAWR5xIBTV8GX0RA9iB+y7ZlJPRAXrNG8TdBY9vc7/MIGolIgrkMPK4mGMn07YG/qEyPY+iKaw=="], 83 + 84 + "@oxfmt/win32-arm64": ["@oxfmt/win32-arm64@0.17.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-FoUZRR7mVpTYIaY/qz2BYwzqMnL+HsUxmMWAIy6nl29UEkDgxNygULJ4rIGY4/Axne41fhtldLrSGBOpwNm3jA=="], 85 + 86 + "@oxfmt/win32-x64": ["@oxfmt/win32-x64@0.17.0", "", { "os": "win32", "cpu": "x64" }, "sha512-fBIcUpHmCwf3leWlo0cYwLb9Pd2mzxQlZYJX9dD9nylPvsxOnsy9fmsaflpj34O0JbQJN3Y0SRkoaCcHHlxFww=="], 87 + 88 + "@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="], 89 + 90 + "@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="], 91 + 92 + "@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="], 93 + 94 + "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], 95 + 96 + "@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="], 97 + 98 + "@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="], 99 + 100 + "@types/whatwg-url": ["@types/whatwg-url@13.0.0", "", { "dependencies": { "@types/webidl-conversions": "*" } }, "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q=="], 101 + 102 + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], 103 + 104 + "@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.6", "", {}, "sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA=="], 105 + 106 + "@voidy/api": ["@voidy/api@workspace:packages/api"], 107 + 108 + "@voidy/bot": ["@voidy/bot@workspace:packages/bot"], 109 + 110 + "@voidy/framework": ["@voidy/framework@workspace:packages/framework"], 111 + 112 + "bson": ["bson@7.0.0", "", {}, "sha512-Kwc6Wh4lQ5OmkqqKhYGKIuELXl+EPYSCObVE6bWsp1T/cGkOCBN0I8wF/T44BiuhHyNi1mmKVPXk60d41xZ7kw=="], 113 + 114 + "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], 115 + 116 + "discord-api-types": ["discord-api-types@0.38.37", "", {}, "sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w=="], 117 + 118 + "discord.js": ["discord.js@14.25.1", "", { "dependencies": { "@discordjs/builders": "^1.13.0", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.0", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.33", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g=="], 119 + 120 + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], 121 + 122 + "hono": ["hono@4.11.1", "", {}, "sha512-KsFcH0xxHes0J4zaQgWbYwmz3UPOOskdqZmItstUG93+Wk1ePBLkLGwbP9zlmh1BFUiL8Qp+Xfu9P7feJWpGNg=="], 123 + 124 + "kareem": ["kareem@3.0.0", "", {}, "sha512-RKhaOBSPN8L7y4yAgNhDT2602G5FD6QbOIISbjN9D6mjHPeqeg7K+EB5IGSU5o81/X2Gzm3ICnAvQW3x3OP8HA=="], 125 + 126 + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], 127 + 128 + "lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="], 129 + 130 + "magic-bytes.js": ["magic-bytes.js@1.12.1", "", {}, "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA=="], 131 + 132 + "memory-pager": ["memory-pager@1.5.0", "", {}, "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="], 133 + 134 + "mongodb": ["mongodb@7.0.0", "", { "dependencies": { "@mongodb-js/saslprep": "^1.3.0", "bson": "^7.0.0", "mongodb-connection-string-url": "^7.0.0" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.806.0", "@mongodb-js/zstd": "^7.0.0", "gcp-metadata": "^7.0.1", "kerberos": "^7.0.0", "mongodb-client-encryption": ">=7.0.0 <7.1.0", "snappy": "^7.3.2", "socks": "^2.8.6" }, "optionalPeers": ["@aws-sdk/credential-providers", "@mongodb-js/zstd", "gcp-metadata", "kerberos", "mongodb-client-encryption", "snappy", "socks"] }, "sha512-vG/A5cQrvGGvZm2mTnCSz1LUcbOPl83hfB6bxULKQ8oFZauyox/2xbZOoGNl+64m8VBrETkdGCDBdOsCr3F3jg=="], 135 + 136 + "mongodb-connection-string-url": ["mongodb-connection-string-url@7.0.0", "", { "dependencies": { "@types/whatwg-url": "^13.0.0", "whatwg-url": "^14.1.0" } }, "sha512-irhhjRVLE20hbkRl4zpAYLnDMM+zIZnp0IDB9akAFFUZp/3XdOfwwddc7y6cNvF2WCEtfTYRwYbIfYa2kVY0og=="], 137 + 138 + "mongoose": ["mongoose@9.0.1", "", { "dependencies": { "kareem": "3.0.0", "mongodb": "~7.0", "mpath": "0.9.0", "mquery": "6.0.0", "ms": "2.1.3", "sift": "17.1.3" } }, "sha512-aHPfQx2YX5UwAmMVud7OD4lIz9AEO4jI+oDnRh3lPZq9lrKTiHmOzszVffDMyQHXvrf4NXsJ34kpmAhyYAZGbw=="], 139 + 140 + "mpath": ["mpath@0.9.0", "", {}, "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew=="], 141 + 142 + "mquery": ["mquery@6.0.0", "", {}, "sha512-b2KQNsmgtkscfeDgkYMcWGn9vZI9YoXh802VDEwE6qc50zxBFQ0Oo8ROkawbPAsXCY1/Z1yp0MagqsZStPWJjw=="], 143 + 144 + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 145 + 146 + "oxfmt": ["oxfmt@0.17.0", "", { "optionalDependencies": { "@oxfmt/darwin-arm64": "0.17.0", "@oxfmt/darwin-x64": "0.17.0", "@oxfmt/linux-arm64-gnu": "0.17.0", "@oxfmt/linux-arm64-musl": "0.17.0", "@oxfmt/linux-x64-gnu": "0.17.0", "@oxfmt/linux-x64-musl": "0.17.0", "@oxfmt/win32-arm64": "0.17.0", "@oxfmt/win32-x64": "0.17.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-12Rmq2ub61rUZ3Pqnsvmo99rRQ6hQJwQsjnFnbvXYLMrlIsWT6SFVsrjAkBBrkXXSHv8ePIpKQ0nZph5KDrOqw=="], 147 + 148 + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], 149 + 150 + "sift": ["sift@17.1.3", "", {}, "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ=="], 151 + 152 + "sparse-bitfield": ["sparse-bitfield@3.0.3", "", { "dependencies": { "memory-pager": "^1.0.2" } }, "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ=="], 153 + 154 + "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], 155 + 156 + "ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="], 157 + 158 + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 159 + 160 + "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], 161 + 162 + "undici": ["undici@6.21.3", "", {}, "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw=="], 163 + 164 + "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], 165 + 166 + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], 167 + 168 + "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], 169 + 170 + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], 171 + 172 + "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], 173 + 174 + "@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], 175 + 176 + "@discordjs/ws/@discordjs/rest": ["@discordjs/rest@2.5.1", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.1.1", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.3", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-Tg9840IneBcbrAjcGaQzHUJWFNq1MMWZjTdjJ0WS/89IffaNKc++iOvffucPxQTF/gviO9+9r8kEPea1X5J2Dw=="], 177 + 178 + "@discordjs/ws/@discordjs/util": ["@discordjs/util@1.1.1", "", {}, "sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g=="], 179 + 180 + "@discordjs/ws/discord-api-types": ["discord-api-types@0.38.18", "", {}, "sha512-ygenySjZKUaBf5JT8BNhZSxLzwpwdp41O0wVroOTu/N2DxFH7dxYTZUSnFJ6v+/2F3BMcnD47PC47u4aLOLxrQ=="], 181 + } 182 + }
-10
deno.json
··· 1 - { 2 - "tasks": { 3 - "dev": "deno run --watch --env-file --allow-env --allow-read --allow-net --unstable-cron src/main.ts" 4 - }, 5 - "imports": { 6 - "@std/fs": "jsr:@std/fs", 7 - "@std/path": "jsr:@std/path", 8 - "discord.js": "npm:discord.js" 9 - } 10 - }
-160
deno.lock
··· 1 - { 2 - "version": "4", 3 - "specifiers": { 4 - "jsr:@std/fs@*": "1.0.17", 5 - "jsr:@std/path@*": "1.0.9", 6 - "jsr:@std/path@^1.0.9": "1.0.9", 7 - "npm:discord.js@*": "14.19.2" 8 - }, 9 - "jsr": { 10 - "@std/fs@1.0.17": { 11 - "integrity": "1c00c632677c1158988ef7a004cb16137f870aafdb8163b9dce86ec652f3952b", 12 - "dependencies": [ 13 - "jsr:@std/path@^1.0.9" 14 - ] 15 - }, 16 - "@std/path@1.0.9": { 17 - "integrity": "260a49f11edd3db93dd38350bf9cd1b4d1366afa98e81b86167b4e3dd750129e" 18 - } 19 - }, 20 - "npm": { 21 - "@discordjs/builders@1.11.1": { 22 - "integrity": "sha512-2zDAVuoeAkdv0YQzYKO8vZfaDfB+1KZ60ymBKtD7QDpsh6lzAnQSUBLqeRkhlons6BT9+yRctOh9fPy94w6kDA==", 23 - "dependencies": [ 24 - "@discordjs/formatters", 25 - "@discordjs/util", 26 - "@sapphire/shapeshift", 27 - "discord-api-types", 28 - "fast-deep-equal", 29 - "ts-mixer", 30 - "tslib" 31 - ] 32 - }, 33 - "@discordjs/collection@1.5.3": { 34 - "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==" 35 - }, 36 - "@discordjs/collection@2.1.1": { 37 - "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==" 38 - }, 39 - "@discordjs/formatters@0.6.1": { 40 - "integrity": "sha512-5cnX+tASiPCqCWtFcFslxBVUaCetB0thvM/JyavhbXInP1HJIEU+Qv/zMrnuwSsX3yWH2lVXNJZeDK3EiP4HHg==", 41 - "dependencies": [ 42 - "discord-api-types" 43 - ] 44 - }, 45 - "@discordjs/rest@2.5.0": { 46 - "integrity": "sha512-PWhchxTzpn9EV3vvPRpwS0EE2rNYB9pvzDU/eLLW3mByJl0ZHZjHI2/wA8EbH2gRMQV7nu+0FoDF84oiPl8VAQ==", 47 - "dependencies": [ 48 - "@discordjs/collection@2.1.1", 49 - "@discordjs/util", 50 - "@sapphire/async-queue", 51 - "@sapphire/snowflake", 52 - "@vladfrangu/async_event_emitter", 53 - "discord-api-types", 54 - "magic-bytes.js", 55 - "tslib", 56 - "undici" 57 - ] 58 - }, 59 - "@discordjs/util@1.1.1": { 60 - "integrity": "sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g==" 61 - }, 62 - "@discordjs/ws@1.2.2": { 63 - "integrity": "sha512-dyfq7yn0wO0IYeYOs3z79I6/HumhmKISzFL0Z+007zQJMtAFGtt3AEoq1nuLXtcunUE5YYYQqgKvybXukAK8/w==", 64 - "dependencies": [ 65 - "@discordjs/collection@2.1.1", 66 - "@discordjs/rest", 67 - "@discordjs/util", 68 - "@sapphire/async-queue", 69 - "@types/ws", 70 - "@vladfrangu/async_event_emitter", 71 - "discord-api-types", 72 - "tslib", 73 - "ws" 74 - ] 75 - }, 76 - "@sapphire/async-queue@1.5.5": { 77 - "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==" 78 - }, 79 - "@sapphire/shapeshift@4.0.0": { 80 - "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", 81 - "dependencies": [ 82 - "fast-deep-equal", 83 - "lodash" 84 - ] 85 - }, 86 - "@sapphire/snowflake@3.5.3": { 87 - "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==" 88 - }, 89 - "@types/node@22.12.0": { 90 - "integrity": "sha512-Fll2FZ1riMjNmlmJOdAyY5pUbkftXslB5DgEzlIuNaiWhXd00FhWxVC/r4yV/4wBb9JfImTu+jiSvXTkJ7F/gA==", 91 - "dependencies": [ 92 - "undici-types" 93 - ] 94 - }, 95 - "@types/ws@8.18.1": { 96 - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", 97 - "dependencies": [ 98 - "@types/node" 99 - ] 100 - }, 101 - "@vladfrangu/async_event_emitter@2.4.6": { 102 - "integrity": "sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA==" 103 - }, 104 - "discord-api-types@0.38.1": { 105 - "integrity": "sha512-vsjsqjAuxsPhiwbPjTBeGQaDPlizFmSkU0mTzFGMgRxqCDIRBR7iTY74HacpzrDV0QtERHRKQEk1tq7drZUtHg==" 106 - }, 107 - "discord.js@14.19.2": { 108 - "integrity": "sha512-L/ivhVefzzRcChHJSaGYsgA4Uqx6or2sst5JZ/ft9OBwrj8OJIzrrcutlkHnm/hlI0Hrm3es62TRVksU8VUqrg==", 109 - "dependencies": [ 110 - "@discordjs/builders", 111 - "@discordjs/collection@1.5.3", 112 - "@discordjs/formatters", 113 - "@discordjs/rest", 114 - "@discordjs/util", 115 - "@discordjs/ws", 116 - "@sapphire/snowflake", 117 - "discord-api-types", 118 - "fast-deep-equal", 119 - "lodash.snakecase", 120 - "magic-bytes.js", 121 - "tslib", 122 - "undici" 123 - ] 124 - }, 125 - "fast-deep-equal@3.1.3": { 126 - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" 127 - }, 128 - "lodash.snakecase@4.1.1": { 129 - "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==" 130 - }, 131 - "lodash@4.17.21": { 132 - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" 133 - }, 134 - "magic-bytes.js@1.12.1": { 135 - "integrity": "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==" 136 - }, 137 - "ts-mixer@6.0.4": { 138 - "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==" 139 - }, 140 - "tslib@2.8.1": { 141 - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" 142 - }, 143 - "undici-types@6.20.0": { 144 - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==" 145 - }, 146 - "undici@6.21.1": { 147 - "integrity": "sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==" 148 - }, 149 - "ws@8.18.1": { 150 - "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==" 151 - } 152 - }, 153 - "workspace": { 154 - "dependencies": [ 155 - "jsr:@std/fs@*", 156 - "jsr:@std/path@*", 157 - "npm:discord.js@*" 158 - ] 159 - } 160 - }
+44
docker-compose.yml
··· 1 + # Don't change these development defaults, use a docker-compose.override.yml file instead :) 2 + services: 3 + api: 4 + build: 5 + context: . 6 + dockerfile: packages/api/Dockerfile 7 + env_file: .env 8 + ports: 9 + - "4300:4300" 10 + labels: 11 + - "traefik.enable=true" 12 + - "traefik.http.routers.voidy-api.entrypoints=websecure" 13 + - "traefik.http.routers.voidy-api.rule=Host(`voidy.thevoid.cafe`)" 14 + - "traefik.http.services.voidy-api.loadbalancer.server.port=4300" 15 + networks: 16 + - default 17 + - proxy 18 + restart: unless-stopped 19 + 20 + bot: 21 + build: 22 + context: . 23 + dockerfile: packages/bot/Dockerfile 24 + env_file: .env 25 + depends_on: 26 + - api 27 + networks: 28 + - default 29 + restart: unless-stopped 30 + 31 + mongo: 32 + image: mongo:latest 33 + container_name: voidy_db 34 + ports: 35 + - "27017:27017" 36 + environment: 37 + MONGO_INITDB_ROOT_USERNAME: voidy 38 + MONGO_INITDB_ROOT_PASSWORD: voidy 39 + networks: 40 + - default 41 + 42 + networks: 43 + proxy: 44 + external: true
+12
package.json
··· 1 + { 2 + "name": "voidy", 3 + "workspaces": [ 4 + "packages/*" 5 + ], 6 + "scripts": { 7 + "dev": "cd packages/bot && bun dev" 8 + }, 9 + "devDependencies": { 10 + "oxfmt": "^0.17.0" 11 + } 12 + }
+2
packages/api/.env.example
··· 1 + DB_URI: #Your MongoDB connection URI - REQUIRED 2 + ACCESS_TOKEN: # Your Bearer token
+12
packages/api/Dockerfile
··· 1 + FROM oven/bun:1.1 2 + 3 + WORKDIR /app 4 + 5 + COPY . . 6 + 7 + RUN bun install 8 + 9 + WORKDIR /app/packages/api 10 + 11 + EXPOSE 3000 12 + CMD ["bun", "run", "dev"]
+25
packages/api/index.ts
··· 1 + import { Hono } from "hono"; 2 + import { v1 } from "./routes/api/v1"; 3 + import { connect } from "mongoose"; 4 + 5 + // Instantiate Hono 6 + const app = new Hono(); 7 + 8 + // Database URI validation and connection check 9 + if (!Bun.env.DB_URI) throw new Error("[Voidy] Missing database URI"); 10 + await connect(Bun.env.DB_URI) 11 + .then(() => { 12 + console.log("Connected to database"); 13 + }) 14 + .catch((error) => { 15 + console.error("Failed to connect to database:", error); 16 + }); 17 + 18 + // Define routes 19 + app.route("/api/v1", v1); 20 + 21 + // Export app configuration 22 + export default { 23 + fetch: app.fetch, 24 + port: Bun.env.PORT || 4300, 25 + }
+22
packages/api/middlewares/isAuthenticated.ts
··· 1 + import type { MiddlewareHandler } from "hono"; 2 + 3 + export const isAuthenticated: MiddlewareHandler = async (c, next) => { 4 + const auth = c.req.header("authorization"); 5 + 6 + if (!auth || !auth.startsWith("Bearer ")) { 7 + return c.json({ error: "Unauthorized" }, 401); 8 + } 9 + 10 + const token = auth.slice("Bearer ".length); 11 + 12 + if (!Bun.env.ACCESS_TOKEN) { 13 + console.error("ACCESS_TOKEN environment variable is not set"); 14 + return c.json({ error: "Internal server error" }, 500); 15 + } 16 + 17 + if (token !== Bun.env.ACCESS_TOKEN) { 18 + return c.json({ error: "Invalid token" }, 401); 19 + } 20 + 21 + await next(); 22 + };
+21
packages/api/package.json
··· 1 + { 2 + "name": "@voidy/api", 3 + "version": "0.1.0", 4 + "module": "src/index.ts", 5 + "type": "module", 6 + "private": true, 7 + "scripts": { 8 + "dev": "bun --watch ." 9 + }, 10 + "devDependencies": { 11 + "@types/bun": "latest" 12 + }, 13 + "peerDependencies": { 14 + "typescript": "^5.9.2" 15 + }, 16 + "dependencies": { 17 + "@voidy/bot": "workspace:*", 18 + "hono": "^4.11.1", 19 + "mongoose": "^9.0.1" 20 + } 21 + }
+71
packages/api/routes/api/v1/currency.ts
··· 1 + import { Hono } from "hono"; 2 + import { UserCurrency, UserCurrencyType, UserIntegration } from "@voidy/bot/db"; 3 + import { isAuthenticated } from "../../../middlewares/isAuthenticated"; 4 + 5 + export const currency = new Hono(); 6 + 7 + async function findUserId(serviceType: string, serviceId: string) { 8 + const integration = await UserIntegration.findOne({ 9 + service: { 10 + type: serviceType, 11 + id: serviceId, 12 + } 13 + }); 14 + 15 + if (!integration) return null; 16 + 17 + return integration.userId; 18 + } 19 + 20 + currency.get("/", isAuthenticated, async (c) => { 21 + const serviceType = c.req.query("serviceType"); 22 + const serviceId = c.req.query("serviceId"); 23 + 24 + if (!serviceId || !serviceType) return c.json({ error: "Missing serviceId or serviceType" }, 400); 25 + 26 + const userId = await findUserId(serviceType, serviceId); 27 + if (!userId) return c.json({ error: "User not found" }, 404); 28 + 29 + const userCurrency = await UserCurrency.findOne({ userId, type: UserCurrencyType.BITS }); 30 + if (!userCurrency) return c.json({ error: "User currency not found" }, 404); 31 + 32 + return c.json(userCurrency); 33 + }); 34 + 35 + currency.post("/deposit", isAuthenticated, async (c) => { 36 + const { serviceType, serviceId, amount } = await c.req.json(); 37 + 38 + if (!serviceId || !serviceType) return c.json({ error: "Missing serviceId or serviceType" }, 400); 39 + if (!amount) return c.json({ error: "Missing amount" }, 400); 40 + 41 + const userId = await findUserId(serviceType, serviceId); 42 + if (!userId) return c.json({ error: "User not found" }, 404); 43 + 44 + const userCurrency = await UserCurrency.findOne({ userId, type: UserCurrencyType.BITS }); 45 + if (!userCurrency) return c.json({ error: "User currency not found" }, 404); 46 + 47 + userCurrency.amount += parseInt(amount); 48 + await userCurrency.save(); 49 + 50 + return c.json(userCurrency); 51 + }); 52 + 53 + currency.post("/withdraw", isAuthenticated, async (c) => { 54 + const { serviceType, serviceId, amount } = await c.req.json(); 55 + 56 + if (!serviceId || !serviceType) return c.json({ error: "Missing serviceId or serviceType" }, 400); 57 + if (!amount) return c.json({ error: "Missing amount" }, 400); 58 + 59 + const userId = await findUserId(serviceType, serviceId); 60 + if (!userId) return c.json({ error: "User not found" }, 404); 61 + 62 + const userCurrency = await UserCurrency.findOne({ userId, type: UserCurrencyType.BITS }); 63 + if (!userCurrency) return c.json({ error: "User currency not found" }, 404); 64 + 65 + if (userCurrency.amount < parseInt(amount)) return c.json({ error: "Insufficient balance" }, 400); 66 + 67 + userCurrency.amount -= parseInt(amount); 68 + await userCurrency.save(); 69 + 70 + return c.json(userCurrency); 71 + });
+8
packages/api/routes/api/v1/index.ts
··· 1 + import { Hono } from "hono"; 2 + import { currency } from "./currency"; 3 + import { shop } from "./shop"; 4 + 5 + export const v1 = new Hono(); 6 + 7 + v1.route("/currency", currency); 8 + v1.route("/shop", shop);
+98
packages/api/routes/api/v1/shop.ts
··· 1 + import { Hono } from "hono"; 2 + import { MinecraftShopItem, MinecraftShop } from "@voidy/bot/db"; 3 + import { isAuthenticated } from "../../../middlewares/isAuthenticated"; 4 + 5 + export const shop = new Hono(); 6 + 7 + shop.get("/", async (c) => { 8 + const latestShop = await MinecraftShop.findOne().sort({ createdAt: -1 }); 9 + 10 + if (!latestShop) { 11 + return c.json({ error: "No shop found" }, 404); 12 + } 13 + 14 + return c.json(latestShop); 15 + }); 16 + 17 + shop.get("/items", async (c) => { 18 + const item = c.req.query("item"); 19 + 20 + // Return all items if no item is specified 21 + if (!item) { 22 + const locatedItems = await MinecraftShopItem.find(); 23 + return c.json(locatedItems); 24 + } 25 + 26 + const locatedItem = await MinecraftShopItem.findOne({ item }); 27 + 28 + if (!locatedItem) { 29 + return c.json({ error: "No item with that ID found" }, 404); 30 + } 31 + 32 + return c.json(locatedItem); 33 + }); 34 + 35 + shop.post("/generate", isAuthenticated, async (c) => { 36 + const body = await c.req.json().catch(() => ({})); 37 + const { highlight } = body as { highlight?: string }; 38 + 39 + const allItems = await MinecraftShopItem.find(); 40 + 41 + if (!allItems.length) { 42 + return c.json( 43 + { error: "No shop items available to generate from" }, 44 + 400, 45 + ); 46 + } 47 + 48 + // Config 49 + const MIN_ITEMS = 4; 50 + const MAX_ITEMS = 8; 51 + 52 + const selected: typeof allItems = []; 53 + 54 + // --- Force highlighted item --- 55 + if (highlight) { 56 + const highlightedItem = allItems.find( 57 + (item) => item.item === highlight, 58 + ); 59 + 60 + if (!highlightedItem) { 61 + return c.json( 62 + { error: `Highlighted item '${highlight}' not found` }, 63 + 400, 64 + ); 65 + } 66 + 67 + selected.push(highlightedItem); 68 + } 69 + 70 + // --- Random fill --- 71 + const pool = allItems.filter( 72 + (item) => !selected.some((s) => s.id === item.id), 73 + ); 74 + 75 + const targetCount = 76 + Math.floor(Math.random() * (MAX_ITEMS - MIN_ITEMS + 1)) + MIN_ITEMS; 77 + 78 + while (selected.length < targetCount && pool.length > 0) { 79 + const index = Math.floor(Math.random() * pool.length); 80 + selected.push(pool.splice(index, 1)[0]); 81 + } 82 + 83 + // --- Snapshot normalization --- 84 + const items = selected.map((item) => ({ 85 + label: item.label, 86 + icon: item.icon, 87 + item: item.item, 88 + price: item.price, 89 + defaultOptions: { 90 + quantity: item.defaultOptions.quantity, 91 + stackLimit: item.defaultOptions.stackLimit, 92 + }, 93 + })); 94 + 95 + const newShop = await MinecraftShop.create({ items }); 96 + 97 + return c.json(newShop, 201); 98 + });
+5
packages/bot/.env.example
··· 1 + BOT_TOKEN: #Get this from https://discord.com/developers - REQUIRED 2 + BOT_CLIENT_ID: #Get this from https://discord.com/developers - REQUIRED 3 + BOT_ADMINS: #Discord Ids of bot administrators, comma separated 4 + DB_URI: #Your MongoDB connection URI - REQUIRED 5 + VOIDY_API_TOKEN: # 123456789
+11
packages/bot/Dockerfile
··· 1 + FROM oven/bun:1.1 2 + 3 + WORKDIR /app 4 + 5 + COPY . . 6 + 7 + RUN bun install 8 + 9 + WORKDIR /app/packages/bot 10 + 11 + CMD ["bun", "run", "dev"]
+10
packages/bot/docker-compose.yml
··· 1 + # Don't change these development defaults, use a docker-compose.override.yml file instead :) 2 + services: 3 + mongo: 4 + image: mongo:latest 5 + container_name: voidy_db 6 + ports: 7 + - "27017:27017" 8 + environment: 9 + MONGO_INITDB_ROOT_USERNAME: voidy 10 + MONGO_INITDB_ROOT_PASSWORD: voidy
+29
packages/bot/package.json
··· 1 + { 2 + "name": "@voidy/bot", 3 + "version": "0.1.0", 4 + "module": "src/index.ts", 5 + "type": "module", 6 + "private": true, 7 + "exports": { 8 + "./db": { 9 + "import": "./src/modules/index.ts", 10 + "require": "./src/modules/index.cjs" 11 + } 12 + }, 13 + "scripts": { 14 + "dev": "bun --watch ." 15 + }, 16 + "devDependencies": { 17 + "@types/bun": "latest" 18 + }, 19 + "peerDependencies": { 20 + "typescript": "^5.9.2" 21 + }, 22 + "dependencies": { 23 + "@voidy/framework": "workspace:*", 24 + "@voidy/api": "workspace:*", 25 + "discord.js": "^14.25.1", 26 + "hono": "^4.11.1", 27 + "mongoose": "^9.0.1" 28 + } 29 + }
+28
packages/bot/src/index.ts
··· 1 + import { GatewayIntentBits } from "discord.js"; 2 + import { VoidyClient } from "@voidy/framework"; 3 + import { connect } from "mongoose"; 4 + 5 + //=============================================== 6 + // Discord client initialization 7 + //=============================================== 8 + 9 + // Client initialization with intents and stuff... 10 + const client = new VoidyClient({ 11 + intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages], 12 + developers: Bun.env.BOT_ADMINS?.split(",") ?? ["423520077246103563"], 13 + logChannelId: "1451025628206731459" 14 + }); 15 + 16 + // Database URI validation and connection check 17 + if (!Bun.env.DB_URI) throw new Error("[Voidy] Missing database URI"); 18 + await connect(Bun.env.DB_URI) 19 + .then(() => { 20 + console.log("Connected to database"); 21 + }) 22 + .catch((error) => { 23 + console.error("Failed to connect to database:", error); 24 + }); 25 + 26 + // Token validation and client start 27 + if (!Bun.env.BOT_TOKEN) throw new Error("[Voidy] Missing bot token"); 28 + await client.start(Bun.env.BOT_TOKEN, `${import.meta.dirname}/modules`);
+16
packages/bot/src/modules/core/commands/ping.ts
··· 1 + import { MessageFlags, SlashCommandBuilder } from "discord.js"; 2 + import type { Command } from "@voidy/framework"; 3 + 4 + export default { 5 + id: "ping", 6 + data: new SlashCommandBuilder() 7 + .setName("ping") 8 + .setDescription("View the websocket ping between Discord and the Bot."), 9 + 10 + execute: async (interaction, client) => { 11 + await interaction.reply({ 12 + content: `The current websocket ping is at ${client.ws.ping}`, 13 + flags: [MessageFlags.Ephemeral], 14 + }); 15 + }, 16 + } as Command;
+61
packages/bot/src/modules/core/events/interactionCreate.ts
··· 1 + import { Events, MessageFlags, type Interaction } from "discord.js"; 2 + import { 3 + ChatInputCommandHandler, 4 + ButtonHandler, 5 + type VoidyClient, 6 + type Event, 7 + } from "@voidy/framework"; 8 + 9 + export default { 10 + id: "interactionCreate", 11 + name: Events.InteractionCreate, 12 + execute: async (client: VoidyClient, interaction: Interaction) => { 13 + if (interaction.isChatInputCommand() && interaction.isCommand()) { 14 + // Set the top-level command name 15 + let commandId = interaction.commandName; 16 + 17 + // Try to get a subgroup first 18 + const subgroup = interaction.options.getSubcommandGroup(false); 19 + if (subgroup) commandId += `.${subgroup}`; 20 + 21 + // Then subcommand (or subcommand in a group) 22 + const subcommand = interaction.options.getSubcommand(false); 23 + if (subcommand) commandId += `.${subcommand}`; 24 + 25 + const command = client.commands.get(commandId); 26 + 27 + if (!command) 28 + return interaction.reply({ 29 + content: `Sorry, but the command ${interaction.commandName} could not be located in my command cache >:3`, 30 + flags: [MessageFlags.Ephemeral], 31 + }); 32 + 33 + ChatInputCommandHandler.invoke(interaction, command, client); 34 + } else if (interaction.isButton()) { 35 + // Filter the client button cache to locate the invoked button 36 + const button = client.buttons.get(interaction.customId); 37 + 38 + if (!button) 39 + return interaction.reply({ 40 + content: `Sorry, but the button ${interaction.customId} could not be located in my button cache >:3`, 41 + flags: [MessageFlags.Ephemeral], 42 + }); 43 + 44 + ButtonHandler.invoke(interaction, button, client); 45 + } else { 46 + let dmChannel = interaction.user.dmChannel; 47 + 48 + // Attempt DM channel creation, if not found. 49 + if (!dmChannel) { 50 + dmChannel = await interaction.user.createDM(); 51 + } 52 + 53 + // If the DM channel is still not available, give up. 54 + if (!dmChannel || !dmChannel.isSendable()) return; 55 + 56 + dmChannel.send({ 57 + content: `Sorry, but your last interaction wasn't successful and has been logged as an error case, for debugging purposes.\n\nIf you have any additional information to share with us, please communicate with the bot within this DM channel, and we will get in contact.\n\nThank you for understanding, and have a great day :3`, 58 + }); 59 + } 60 + }, 61 + } as Event;
+16
packages/bot/src/modules/core/events/ready.ts
··· 1 + import type { Event, VoidyClient } from "@voidy/framework"; 2 + import { ActivityType, Events } from "discord.js"; 3 + 4 + export default { 5 + id: "clientReady", 6 + name: Events.ClientReady, 7 + once: true, 8 + execute: async (client: VoidyClient) => { 9 + console.log("THE BOT IS READY >:3"); 10 + 11 + client.user?.setActivity({ 12 + name: `${client.guilds.cache.size} guilds :3`, 13 + type: ActivityType.Watching, 14 + }); 15 + }, 16 + } as Event;
+19
packages/bot/src/modules/core/module.ts
··· 1 + import { CommandLoader, EventLoader, type Module } from "@voidy/framework"; 2 + 3 + export default { 4 + id: "core", 5 + name: "Core", 6 + description: "Initializes the bot and registers all commands.", 7 + author: "thevoid.cafe", 8 + 9 + exports: [ 10 + { 11 + source: `${import.meta.dir}/events`, 12 + loader: EventLoader, 13 + }, 14 + { 15 + source: `${import.meta.dir}/commands`, 16 + loader: CommandLoader, 17 + }, 18 + ], 19 + } as Module;
+86
packages/bot/src/modules/economy/commands/currency/set.ts
··· 1 + import { MessageFlags, SlashCommandSubcommandBuilder } from "discord.js"; 2 + import { UserCurrencyType, UserCurrency } from "../../schemas/UserCurrency"; 3 + import type { Command } from "@voidy/framework"; 4 + 5 + export default { 6 + id: "currency.set", 7 + devOnly: true, 8 + data: new SlashCommandSubcommandBuilder() 9 + .setName("set") 10 + .setDescription("Override a users current balance.") 11 + .addUserOption((option) => 12 + option.setName("user").setDescription("The user whose balance to override.").setRequired(true) 13 + ) 14 + .addStringOption((option) => 15 + option 16 + .setName("currency") 17 + .setDescription("The currency to override.") 18 + .setRequired(true) 19 + .setChoices( 20 + { name: "BiTS", value: UserCurrencyType.BITS }, 21 + { name: "Gems", value: UserCurrencyType.GEMS } 22 + ) 23 + ) 24 + .addIntegerOption((option) => 25 + option 26 + .setName("new_balance") 27 + .setDescription("The new balance to set.") 28 + .setRequired(true) 29 + ), 30 + 31 + execute: async (interaction, _client) => { 32 + const { options } = interaction; 33 + 34 + // Retrieve the requested user from our interaction options 35 + const user = options.getUser("user"); 36 + if (!user) { 37 + await interaction.reply({ 38 + content: "Please provide a valid user.", 39 + flags: [MessageFlags.Ephemeral], 40 + }); 41 + 42 + return; 43 + }; 44 + 45 + // Retrieve the selected currency from our interaction options 46 + const selectedCurrency = options.getString("currency"); 47 + if (!selectedCurrency) { 48 + await interaction.reply({ 49 + content: "Please provide a valid currency.", 50 + flags: [MessageFlags.Ephemeral], 51 + }); 52 + 53 + return; 54 + } 55 + 56 + // Retrieve the new balance from our interaction options 57 + const newBalance = options.getInteger("new_balance"); 58 + if (!newBalance) { 59 + await interaction.reply({ 60 + content: "Please provide a valid new balance.", 61 + flags: [MessageFlags.Ephemeral], 62 + }); 63 + 64 + return; 65 + } 66 + 67 + // Retrieve the requested user's balance from our database 68 + let userBalance = await UserCurrency.findOne({ userId: user.id, type: selectedCurrency }); 69 + if (!userBalance) { 70 + userBalance = await UserCurrency.create({ 71 + userId: user.id, 72 + type: selectedCurrency, 73 + amount: newBalance, 74 + }); 75 + } else { 76 + userBalance.amount = newBalance; 77 + await userBalance.save(); 78 + } 79 + 80 + // Reply with the requested user's current balance 81 + await interaction.reply({ 82 + content: `Balance of user \`${user.username}\` has been set to \`${userBalance.amount} ${selectedCurrency.toUpperCase()}\`.`, 83 + flags: [MessageFlags.Ephemeral], 84 + }); 85 + }, 86 + } as Command;
+66
packages/bot/src/modules/economy/commands/currency/view.ts
··· 1 + import { MessageFlags, SlashCommandSubcommandBuilder } from "discord.js"; 2 + import { UserCurrencyType, UserCurrency } from "../../schemas/UserCurrency"; 3 + import type { Command } from "@voidy/framework"; 4 + 5 + export default { 6 + id: "currency.view", 7 + data: new SlashCommandSubcommandBuilder() 8 + .setName("view") 9 + .setDescription("Retrieve a users current balance.") 10 + .addUserOption((option) => 11 + option.setName("user").setDescription("The user whose balance to view.").setRequired(true) 12 + ) 13 + .addStringOption((option) => 14 + option 15 + .setName("currency") 16 + .setDescription("The currency to view.") 17 + .setRequired(true) 18 + .setChoices( 19 + { name: "BiTS", value: UserCurrencyType.BITS }, 20 + { name: "Gems", value: UserCurrencyType.GEMS } 21 + ) 22 + ), 23 + 24 + execute: async (interaction, _client) => { 25 + const { options } = interaction; 26 + 27 + // Retrieve the requested user from our interaction options 28 + const user = options.getUser("user"); 29 + if (!user) { 30 + await interaction.reply({ 31 + content: "Please provide a valid user.", 32 + flags: [MessageFlags.Ephemeral], 33 + }); 34 + 35 + return; 36 + }; 37 + 38 + // Retrieve the selected currency from our interaction options 39 + const selectedCurrency = options.getString("currency"); 40 + if (!selectedCurrency) { 41 + await interaction.reply({ 42 + content: "Please provide a valid currency.", 43 + flags: [MessageFlags.Ephemeral], 44 + }); 45 + 46 + return; 47 + } 48 + 49 + // Retrieve the requested user's balance from our database 50 + const userBalance = await UserCurrency.findOne({ userId: user.id, type: selectedCurrency }); 51 + if (!userBalance || UserCurrencyType === undefined || userBalance.amount === 0) { 52 + await interaction.reply({ 53 + content: `The requested user hasn't earned any \`${selectedCurrency.toUpperCase()}\` yet.`, 54 + flags: [MessageFlags.Ephemeral], 55 + }); 56 + 57 + return; 58 + } 59 + 60 + // Reply with the requested user's current balance 61 + await interaction.reply({ 62 + content: `User \`${user.username}\` has \`${userBalance.amount} ${selectedCurrency.toUpperCase()}\`.`, 63 + flags: [MessageFlags.Ephemeral], 64 + }); 65 + }, 66 + } as Command;
+91
packages/bot/src/modules/economy/commands/minecraft/shop/add-item.ts
··· 1 + import { MessageFlags, SlashCommandSubcommandBuilder } from "discord.js"; 2 + import type { Command } from "@voidy/framework"; 3 + import { MinecraftShopItem } from "../../../schemas"; 4 + 5 + export default { 6 + id: "minecraft.shop.add-item", 7 + devOnly: true, 8 + data: new SlashCommandSubcommandBuilder() 9 + .setName("add-item") 10 + .setDescription("Add a new Minecraft shop item to the database.") 11 + .addStringOption((option) => 12 + option 13 + .setName("item") 14 + .setDescription("Minecraft item id (e.g. minecraft:diamond)") 15 + .setRequired(true), 16 + ) 17 + .addStringOption((option) => 18 + option 19 + .setName("label") 20 + .setDescription("Display label for the shop item") 21 + .setRequired(true), 22 + ) 23 + .addStringOption((option) => 24 + option 25 + .setName("icon") 26 + .setDescription("Texture path for the item icon") 27 + .setRequired(true), 28 + ) 29 + .addIntegerOption((option) => 30 + option 31 + .setName("price") 32 + .setDescription("Base price of the item") 33 + .setRequired(true), 34 + ) 35 + .addIntegerOption((option) => 36 + option 37 + .setName("quantity") 38 + .setDescription("Default quantity per purchase (default: 16)"), 39 + ) 40 + .addIntegerOption((option) => 41 + option 42 + .setName("stack-limit") 43 + .setDescription("Maximum stack size (default: 64)"), 44 + ), 45 + 46 + execute: async (interaction, client) => { 47 + await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); 48 + 49 + const item = interaction.options.getString("item", true); 50 + const label = interaction.options.getString("label", true); 51 + const icon = interaction.options.getString("icon", true); 52 + const price = interaction.options.getInteger("price", true); 53 + const quantity = interaction.options.getInteger("quantity") ?? 16; 54 + const stackLimit = interaction.options.getInteger("stack-limit") ?? 64; 55 + 56 + try { 57 + // Prevent duplicates 58 + const existing = await MinecraftShopItem.findOne({ item }); 59 + if (existing) { 60 + await interaction.followUp({ 61 + content: `โš ๏ธ Item \`${item}\` already exists in the shop database.`, 62 + flags: [MessageFlags.Ephemeral], 63 + }); 64 + return; 65 + } 66 + 67 + await MinecraftShopItem.create({ 68 + item, 69 + label, 70 + icon, 71 + price, 72 + defaultOptions: { 73 + quantity, 74 + stackLimit, 75 + }, 76 + }); 77 + 78 + await interaction.followUp({ 79 + content: `โœ… Added shop item **${label}** (\`${item}\`) for ${price} coins.`, 80 + flags: [MessageFlags.Ephemeral], 81 + }); 82 + } catch (err) { 83 + client.logger.send(`[minecraft.shop.add-item] Failed: \`\`\`${err}\`\`\``); 84 + 85 + await interaction.followUp({ 86 + content: "โŒ Failed to add shop item. Check logs for details.", 87 + flags: [MessageFlags.Ephemeral], 88 + }); 89 + } 90 + }, 91 + } as Command;
+63
packages/bot/src/modules/economy/commands/minecraft/shop/generate.ts
··· 1 + import { MessageFlags, SlashCommandSubcommandBuilder } from "discord.js"; 2 + import type { Command } from "@voidy/framework"; 3 + 4 + export default { 5 + id: "minecraft.shop.generate", 6 + devOnly: true, 7 + data: new SlashCommandSubcommandBuilder() 8 + .setName("generate") 9 + .setDescription("Generate a new shop for the minecraft currency integration.") 10 + .addStringOption((option) => 11 + option 12 + .setName("highlight") 13 + .setDescription("Force pick an item to highlight.") 14 + ), 15 + 16 + execute: async (interaction, client) => { 17 + await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); 18 + 19 + const highlight = interaction.options.getString("highlight"); 20 + 21 + // Todo: Replace hardcoded URL with environment variable 22 + try { 23 + const res = await fetch( 24 + "https://voidy.thevoid.cafe/api/v1/shop/generate", 25 + { 26 + method: "POST", 27 + headers: { 28 + "Content-Type": "application/json", 29 + Authorization: `Bearer ${process.env.ACCESS_TOKEN}`, 30 + }, 31 + body: highlight 32 + ? JSON.stringify({ highlight }) 33 + : undefined, 34 + }, 35 + ); 36 + 37 + if (!res.ok) { 38 + const text = await res.text(); 39 + throw new Error(`API error (${res.status}): ${text}`); 40 + } 41 + 42 + if (!highlight) { 43 + await interaction.followUp({ 44 + content: "Generated new shop for the Minecraft currency integration.", 45 + flags: [MessageFlags.Ephemeral], 46 + }); 47 + return; 48 + } 49 + 50 + await interaction.followUp({ 51 + content: `Generated new shop for the Minecraft currency integration, with highlighted item: \`${highlight}\``, 52 + flags: [MessageFlags.Ephemeral], 53 + }); 54 + } catch (err) { 55 + client.logger.send(`[minecraft.shop.generate] Failed: \`\`\`${err}\`\`\``); 56 + 57 + await interaction.followUp({ 58 + content: "โŒ Failed to generate a new shop. Check logs for details.", 59 + flags: [MessageFlags.Ephemeral], 60 + }); 61 + } 62 + }, 63 + } as Command;
+48
packages/bot/src/modules/economy/commands/minecraft/shop/rm-item.ts
··· 1 + import { MessageFlags, SlashCommandSubcommandBuilder } from "discord.js"; 2 + import type { Command } from "@voidy/framework"; 3 + import { MinecraftShopItem } from "../../../schemas"; 4 + 5 + export default { 6 + id: "minecraft.shop.rm-item", 7 + devOnly: true, 8 + data: new SlashCommandSubcommandBuilder() 9 + .setName("rm-item") 10 + .setDescription("Remove a Minecraft shop item from the database.") 11 + .addStringOption((option) => 12 + option 13 + .setName("item") 14 + .setDescription("Minecraft item id (e.g. minecraft:diamond)") 15 + .setRequired(true), 16 + ), 17 + 18 + execute: async (interaction, client) => { 19 + await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); 20 + 21 + const item = interaction.options.getString("item", true); 22 + 23 + try { 24 + // Remove item from database, if it exists 25 + const deleted = await MinecraftShopItem.findOneAndDelete({ item }); 26 + 27 + if (!deleted) { 28 + await interaction.followUp({ 29 + content: `โš ๏ธ Item \`${item}\` does not exist in the shop database.`, 30 + flags: [MessageFlags.Ephemeral], 31 + }); 32 + return; 33 + } 34 + 35 + await interaction.followUp({ 36 + content: `โœ… Removed shop item \`${item}\` from the database.`, 37 + flags: [MessageFlags.Ephemeral], 38 + }); 39 + } catch (err) { 40 + client.logger.send(`[minecraft.shop.rm-item] Failed: \`\`\`${err}\`\`\``); 41 + 42 + await interaction.followUp({ 43 + content: "โŒ Failed to remove shop item. Check logs for details.", 44 + flags: [MessageFlags.Ephemeral], 45 + }); 46 + } 47 + }, 48 + } as Command;
+31
packages/bot/src/modules/economy/events/messageCreate.ts
··· 1 + import type { Event, VoidyClient } from "@voidy/framework"; 2 + import { UserCurrency, UserCurrencyType } from "../schemas/UserCurrency"; 3 + import { Events, Message } from "discord.js"; 4 + 5 + export default { 6 + id: "messageCreate", 7 + name: Events.MessageCreate, 8 + execute: async (client: VoidyClient, message: Message) => { 9 + // Don't collect currencies for bots 10 + if (message.author.bot) return; 11 + 12 + // Retrieve user balance of BITS 13 + let userCurrency = await UserCurrency.findOne({ userId: message.author.id, type: UserCurrencyType.BITS }); 14 + if (!userCurrency) { 15 + userCurrency = await UserCurrency.create({ userId: message.author.id, type: UserCurrencyType.BITS }); 16 + } 17 + 18 + // Increase user balance of BITS at random 19 + if (Math.random() < 0.5) { // 50% chance 20 + // Calculate amount of BITS to give, between 1 and 10 21 + const amount = Math.floor(Math.random() * 10) + 1; 22 + 23 + // Update user balance of BITS and commit changes 24 + userCurrency.amount += amount; 25 + await userCurrency.save(); 26 + 27 + // Log to logging channel 28 + await client.logger.send(`User \`${message.author.tag}\` received \`${amount} BITS\``); 29 + } 30 + } 31 + } as Event;
+20
packages/bot/src/modules/economy/module.ts
··· 1 + import { CommandLoader, EventLoader } from "@voidy/framework"; 2 + 3 + export default { 4 + id: "economy", 5 + name: "Economy Management Services", 6 + description: 7 + "Provides various resources for working with users economies and currencies, including storage, transactions, and economy-related commands.", 8 + author: "thevoid.cafe", 9 + 10 + exports: [ 11 + { 12 + source: `${import.meta.dir}/commands`, 13 + loader: CommandLoader, 14 + }, 15 + { 16 + source: `${import.meta.dir}/events`, 17 + loader: EventLoader, 18 + }, 19 + ], 20 + };
+28
packages/bot/src/modules/economy/schemas/MinecraftShop.ts
··· 1 + import { Schema, model } from "mongoose"; 2 + 3 + export const MinecraftShop = model( 4 + "MinecraftShop", 5 + new Schema({ 6 + createdAt: { 7 + type: Date, 8 + required: true, 9 + default: Date.now, 10 + }, 11 + items: { 12 + type: Array, 13 + required: true, 14 + default: [ 15 + { 16 + label: "Bread", 17 + icon: "textures/items/bread", 18 + item: "minecraft:bread", 19 + price: 12, 20 + defaultOptions: { 21 + quantity: 16, 22 + stackLimit: 64, 23 + }, 24 + }, 25 + ] 26 + } 27 + }), 28 + );
+18
packages/bot/src/modules/economy/schemas/MinecraftShopItem.ts
··· 1 + import { Schema, model } from "mongoose"; 2 + 3 + export const MinecraftShopItem = model( 4 + "MinecraftShopItem", 5 + new Schema({ 6 + label: { type: String, required: true }, 7 + icon: { type: String, required: true }, 8 + item: { type: String, required: true, unique: true }, 9 + price: { type: Number, required: true }, 10 + defaultOptions: { 11 + type: { 12 + quantity: { type: Number, required: true, default: 16 }, 13 + stackLimit: { type: Number, required: true, default: 64 }, 14 + }, 15 + required: true 16 + }, 17 + }), 18 + );
+17
packages/bot/src/modules/economy/schemas/UserCurrency.ts
··· 1 + import { Schema, model } from "mongoose"; 2 + 3 + // Define valid currency types, 4 + // this should be the only source of truth for currency types. 5 + export enum UserCurrencyType { 6 + BITS = "bits", 7 + GEMS = "gems" 8 + } 9 + 10 + export const UserCurrency = model( 11 + "UserCurrency", 12 + new Schema({ 13 + userId: { type: String, required: true }, 14 + type: { type: String, enum: UserCurrencyType, required: true }, 15 + amount: { type: Number, required: true, default: 0 }, 16 + }), 17 + );
+3
packages/bot/src/modules/economy/schemas/index.ts
··· 1 + export * from "./UserCurrency"; 2 + export * from "./MinecraftShop"; 3 + export * from "./MinecraftShopItem";
+2
packages/bot/src/modules/index.ts
··· 1 + export * from "./economy/schemas"; 2 + export * from "./user/schemas";
+29
packages/bot/src/modules/toys/commands/api/httpcat.ts
··· 1 + import { MessageFlags, SlashCommandSubcommandBuilder } from "discord.js"; 2 + import type { Command } from "@voidy/framework"; 3 + 4 + export default { 5 + id: "api.httpcat", 6 + data: new SlashCommandSubcommandBuilder() 7 + .setName("httpcat") 8 + .setDescription("Display a cat from the https://http.cat API.") 9 + .addStringOption((option) => 10 + option.setName("code").setDescription("The desired HTTP status code.").setRequired(true), 11 + ) 12 + .addBooleanOption((option) => 13 + option 14 + .setName("ephemeral") 15 + .setDescription("Whether or not to publicly share the bot response"), 16 + ), 17 + 18 + execute: async (interaction, _client) => { 19 + const { options } = interaction; 20 + 21 + const httpCode = options.getString("code"); 22 + const ephemeral = options.getBoolean("ephemeral") ?? true; 23 + 24 + await interaction.reply({ 25 + files: [`https://http.cat/${httpCode}.jpg`], 26 + flags: ephemeral ? [MessageFlags.Ephemeral] : [], 27 + }); 28 + }, 29 + } as Command;
+15
packages/bot/src/modules/toys/module.ts
··· 1 + import { CommandLoader } from "@voidy/framework"; 2 + 3 + export default { 4 + id: "toys", 5 + name: "Toys", 6 + description: "Provides various fun commands, e.g. coinflip, dice or simple API interactions", 7 + author: "thevoid.cafe", 8 + 9 + exports: [ 10 + { 11 + source: `${import.meta.dir}/commands`, 12 + loader: CommandLoader, 13 + }, 14 + ], 15 + };
+54
packages/bot/src/modules/user/commands/consent/update.ts
··· 1 + import { MessageFlags, SlashCommandSubcommandBuilder } from "discord.js"; 2 + import type { Command } from "@voidy/framework"; 3 + import { UserConfig } from "../../schemas/UserConfig"; 4 + 5 + export default { 6 + id: "user.consent.update", 7 + data: new SlashCommandSubcommandBuilder() 8 + .setName("update") 9 + .setDescription("Update your user consent choices.") 10 + .addStringOption((option) => 11 + option 12 + .setName("category") 13 + .setDescription("Choose which category of consent to update.") 14 + .setRequired(true) 15 + .addChoices( 16 + { name: "Storage", value: "storage" }, 17 + { name: "Statistics", value: "statistics" }, 18 + ), 19 + ) 20 + .addBooleanOption((option) => 21 + option 22 + .setName("consent") 23 + .setDescription("Choose whether to grant or revoke consent.") 24 + .setRequired(true), 25 + ), 26 + 27 + execute: async (interaction, _client) => { 28 + const { options } = interaction; 29 + const category = options.getString("category", true); 30 + const consent = options.getBoolean("consent", true); 31 + 32 + let userConfig = await UserConfig.findById(interaction.user.id); 33 + if (!userConfig) { 34 + userConfig = new UserConfig({ id: interaction.user.id }); 35 + } 36 + 37 + switch (category) { 38 + case "storage": 39 + userConfig.consent.storage = consent; 40 + break; 41 + case "statistics": 42 + userConfig.consent.statistics = consent; 43 + break; 44 + default: 45 + console.error(`Invalid category: ${category}`); 46 + } 47 + 48 + await userConfig.save(); 49 + await interaction.reply({ 50 + content: `Updated consent for \`${category}\` to \`${consent ? "granted" : "revoked"}\`.`, 51 + flags: [MessageFlags.Ephemeral], 52 + }); 53 + }, 54 + } as Command;
+65
packages/bot/src/modules/user/commands/integration/update.ts
··· 1 + import { MessageFlags, SlashCommandSubcommandBuilder } from "discord.js"; 2 + import type { Command } from "@voidy/framework"; 3 + import { UserIntegration } from "../../schemas/UserIntegration"; 4 + 5 + export default { 6 + id: "user.integration.update", 7 + data: new SlashCommandSubcommandBuilder() 8 + .setName("update") 9 + .setDescription("Update one of your external service integrations.") 10 + .addStringOption((option) => 11 + option 12 + .setName("service") 13 + .setDescription("Choose which service to update/link.") 14 + .setRequired(true) 15 + .addChoices( 16 + { name: "Minecraft", value: "minecraft" }, 17 + ), 18 + ) 19 + .addStringOption((option) => 20 + option 21 + .setName("id") 22 + .setDescription("Provide your external service user ID.") 23 + .setRequired(true), 24 + ), 25 + 26 + execute: async (interaction, client) => { 27 + const { options } = interaction; 28 + const service = options.getString("service", true); 29 + const id = options.getString("id", true); 30 + 31 + let userIntegration = await UserIntegration.findOne({ 32 + userId: interaction.user.id, 33 + service: { 34 + type: service, 35 + id: id, 36 + } }); 37 + 38 + if (!userIntegration) { 39 + userIntegration = new UserIntegration({ 40 + userId: interaction.user.id, 41 + service: { 42 + type: service, 43 + id: id, 44 + }, 45 + }); 46 + } 47 + 48 + switch (service) { 49 + case "minecraft": 50 + userIntegration.service.type = service; 51 + userIntegration.service.id = id; 52 + break; 53 + default: 54 + console.error(`Invalid service: ${service}`); 55 + } 56 + 57 + await userIntegration.save(); 58 + await interaction.reply({ 59 + content: `Updated integration \`${service}\` to ID \`${id}\`.`, 60 + flags: [MessageFlags.Ephemeral], 61 + }); 62 + 63 + await client.logger.send(`Updated integration \`${service}\` to ID \`${id}\` for user \`${interaction.user.tag}\``); 64 + }, 65 + } as Command;
+16
packages/bot/src/modules/user/module.ts
··· 1 + import { CommandLoader } from "@voidy/framework"; 2 + 3 + export default { 4 + id: "user", 5 + name: "User Management Services", 6 + description: 7 + "Provides various resources for working with users, like global preferences and consent statements.", 8 + author: "thevoid.cafe", 9 + 10 + exports: [ 11 + { 12 + source: `${import.meta.dir}/commands`, 13 + loader: CommandLoader, 14 + }, 15 + ], 16 + };
+16
packages/bot/src/modules/user/schemas/UserConfig.ts
··· 1 + import { Schema, model } from "mongoose"; 2 + 3 + export const UserConfig = model( 4 + "UserConfig", 5 + new Schema({ 6 + userId: { type: String, required: true }, 7 + consent: { 8 + type: Object, 9 + required: true, 10 + default: { 11 + storage: { type: Boolean, required: true, default: true }, 12 + statistics: { type: Boolean, required: true, default: true }, 13 + }, 14 + }, 15 + }), 16 + );
+16
packages/bot/src/modules/user/schemas/UserIntegration.ts
··· 1 + import { Schema, model } from "mongoose"; 2 + 3 + export const UserIntegration = model( 4 + "UserIntegration", 5 + new Schema({ 6 + userId: { type: String, required: true }, 7 + service: { 8 + type: Object, 9 + required: true, 10 + default: { 11 + type: { type: String, required: true }, 12 + id: { type: String, required: true }, 13 + } 14 + } 15 + }), 16 + );
+2
packages/bot/src/modules/user/schemas/index.ts
··· 1 + export * from "./UserConfig"; 2 + export * from "./UserIntegration";
+4
packages/bot/tsconfig.json
··· 1 + { 2 + "extends": "../../tsconfig.json", 3 + "include": ["./src"], 4 + }
+22
packages/framework/package.json
··· 1 + { 2 + "name": "@voidy/framework", 3 + "version": "0.1.0", 4 + "module": "src/index.ts", 5 + "type": "module", 6 + "private": true, 7 + "exports": { 8 + ".": { 9 + "import": "./src/index.ts", 10 + "require": "./src/index.cjs" 11 + } 12 + }, 13 + "devDependencies": { 14 + "@types/bun": "latest" 15 + }, 16 + "peerDependencies": { 17 + "typescript": "^5.9.2" 18 + }, 19 + "dependencies": { 20 + "discord.js": "^14.25.1" 21 + } 22 + }
+80
packages/framework/src/core/Loader.ts
··· 1 + //=============================================== 2 + // Imports 3 + //=============================================== 4 + import { Glob } from "bun"; 5 + 6 + //=============================================== 7 + // Loader Definition 8 + //=============================================== 9 + interface ILoader<T extends object> { 10 + id: string; 11 + cache: T[]; 12 + source: string; 13 + 14 + collect: () => Promise<ThisType<this>>; 15 + validate: (data: Partial<T>) => Promise<T | null>; 16 + getJSON: () => T[]; 17 + } 18 + 19 + //=============================================== 20 + // Loader Implementation 21 + //=============================================== 22 + export abstract class Loader<T extends object> implements ILoader<T> { 23 + public abstract id: string; 24 + public cache: T[] = []; 25 + public source: string; 26 + 27 + public constructor(source: string) { 28 + if (!source) 29 + throw new Error( 30 + "Class of type Loader was initialized without the *required* source parameter.", 31 + ); 32 + 33 + this.source = source; 34 + } 35 + 36 + /** 37 + * Recursively collects data from a directory based on the path specificed in dataSource property. 38 + */ 39 + public async collect() { 40 + const glob = new Glob(`**/**.ts`); 41 + const iterator = glob.scan(this.source); 42 + 43 + try { 44 + for await (const path of iterator) { 45 + let moduleDefault: T | null; 46 + 47 + try { 48 + const module = await import(`${this.source}/${path}`); 49 + moduleDefault = module.default; 50 + 51 + if (!moduleDefault) continue; 52 + } catch { 53 + continue; 54 + } 55 + 56 + const final = await this.validate(moduleDefault); 57 + if (!final) continue; 58 + 59 + this.cache.push(final); 60 + } 61 + } catch { 62 + console.error(`[Voidy] Specified loader target ${this.source} doesn't exist. Skipping...`); 63 + return this; 64 + } 65 + 66 + return this; 67 + } 68 + 69 + /** 70 + * Validates a singular element during data collection, and returns whatever should be written to the cache. 71 + */ 72 + public abstract validate(data: Partial<T>): Promise<T | null>; 73 + 74 + /** 75 + * Returns the JSON-ified contents of the loader cache 76 + */ 77 + public getJSON() { 78 + return this.cache; 79 + } 80 + }
+24
packages/framework/src/core/Logger.ts
··· 1 + import type { VoidyClient } from "./VoidyClient"; 2 + 3 + export class Logger { 4 + private logChannelId?: string; 5 + 6 + constructor(private client: VoidyClient) {} 7 + 8 + public async send(message: string) { 9 + if (!this.logChannelId) return; 10 + 11 + try { 12 + const loggingChannel = this.client.channels.cache.get(this.logChannelId); 13 + if (loggingChannel && loggingChannel.isSendable()) { 14 + await loggingChannel.send(message); 15 + } 16 + } catch {} 17 + 18 + console.log(message); 19 + } 20 + 21 + public setChannelId(id: string) { 22 + this.logChannelId = id; 23 + } 24 + }
+72
packages/framework/src/core/ModuleManager.ts
··· 1 + //=============================================== 2 + // Imports 3 + //=============================================== 4 + import { ModuleLoader } from "../loaders/ModuleLoader"; 5 + import type { Command } from "./types/Command"; 6 + import type { Button } from "./types/Button"; 7 + import type { Module } from "./types/Module"; 8 + import type { Event } from "./types/Event"; 9 + 10 + export type CacheMap<T = unknown> = Map<string, T>; 11 + 12 + //=============================================== 13 + // ModuleManager Implementation 14 + //=============================================== 15 + export class ModuleManager { 16 + private cache = new Map<string, Map<string, unknown>>(); 17 + 18 + // Module Loading 19 + //============================== 20 + async loadModules(path: string) { 21 + const moduleLoader = new ModuleLoader(path); 22 + const modules = (await moduleLoader.collect()).getJSON(); 23 + 24 + for (const module of modules) { 25 + await this.prepareModule(module); 26 + } 27 + } 28 + 29 + async prepareModule(module: Module) { 30 + for (const exp of module.exports) { 31 + const loader = new exp.loader(exp.source); 32 + const data = (await loader.collect()).getJSON(); 33 + 34 + for (const item of data) { 35 + this.set(loader.id, (item as any).id, item); 36 + } 37 + } 38 + } 39 + 40 + // Core API 41 + //============================== 42 + set<T>(type: string, id: string, value: T) { 43 + if (!this.cache.has(type)) this.cache.set(type, new Map()); 44 + (this.cache.get(type) as CacheMap<T>).set(id, value); 45 + } 46 + 47 + get<T>(type: string, id: string): T | undefined { 48 + return (this.cache.get(type) as CacheMap<T>)?.get(id); 49 + } 50 + 51 + getAll<T>(type: string): CacheMap<T> { 52 + return (this.cache.get(type) as CacheMap<T>) ?? new Map(); 53 + } 54 + 55 + // Typed Accessors 56 + //============================== 57 + get modules(): CacheMap<Module> { 58 + return this.getAll<Module>("module"); 59 + } 60 + 61 + get commands(): CacheMap<Command> { 62 + return this.getAll<Command>("command"); 63 + } 64 + 65 + get buttons(): CacheMap<Button> { 66 + return this.getAll<Button>("button"); 67 + } 68 + 69 + get events(): CacheMap<Event> { 70 + return this.getAll<Event>("event"); 71 + } 72 + }
+151
packages/framework/src/core/VoidyClient.ts
··· 1 + //=============================================== 2 + // Imports 3 + //=============================================== 4 + import { 5 + type ClientOptions, 6 + SlashCommandSubcommandGroupBuilder, 7 + SlashCommandSubcommandBuilder, 8 + SlashCommandBuilder, 9 + Client, 10 + Events, 11 + } from "discord.js"; 12 + import { ModuleManager, type CacheMap } from "./ModuleManager"; 13 + import { Logger } from "./Logger"; 14 + import type { Command } from "./types/Command"; 15 + import type { Button } from "./types/Button"; 16 + import type { Event } from "./types/Event"; 17 + 18 + //=============================================== 19 + // ClientOptions Override 20 + //=============================================== 21 + export interface VoidyClientOptions extends ClientOptions { 22 + developers?: string[]; // List of developer user ids 23 + logChannelId?: string; // ID of the channel to log events to 24 + } 25 + 26 + //=============================================== 27 + // VoidyClient Implementation 28 + //=============================================== 29 + export class VoidyClient extends Client { 30 + public moduleManager = new ModuleManager(); 31 + public developers: string[] = []; 32 + public logger: Logger = new Logger(this); 33 + 34 + public constructor(options: VoidyClientOptions) { 35 + super(options); 36 + 37 + // Set developers, if provided. 38 + if (options.developers) { 39 + this.developers = options.developers; 40 + } 41 + 42 + // Inject channel ID into logger, if provided. 43 + if (options.logChannelId) { 44 + this.logger.setChannelId(options.logChannelId); 45 + } 46 + } 47 + 48 + /** 49 + * Launches the bot 50 + * @param token - The Discord application bot token. 51 + * @param modulesPath - Where the bot should search for modules. 52 + */ 53 + public async start(token: string, modulesPath: string) { 54 + // Load modules and register events 55 + await this.moduleManager.loadModules(modulesPath); 56 + await this.registerEvents(); 57 + 58 + // Register commands on ready event 59 + this.on(Events.ClientReady, this.registerCommands); 60 + 61 + // Login using the bot token 62 + await this.login(token); 63 + } 64 + 65 + /** 66 + * Registers all cached events 67 + * @param events 68 + */ 69 + private async registerEvents() { 70 + const events = this.moduleManager.events; 71 + 72 + for (const [_id, event] of events) { 73 + const execute = (...args: unknown[]) => event.execute(this, ...args); 74 + 75 + if (event.once) this.once(event.name, execute); 76 + else this.on(event.name, execute); 77 + } 78 + } 79 + 80 + /** 81 + * Registers all provided commands globally 82 + * @param commands 83 + */ 84 + private async registerCommands(): Promise<void> { 85 + const topLevelCommands = new Map<string, SlashCommandBuilder>(); 86 + 87 + for (const cmd of this.moduleManager.commands.values()) { 88 + const parts = cmd.id.split("."); // ["music", "set", "channel"] 89 + const command = parts[0]; 90 + const subcommand = parts[1]; 91 + const subgroupcommand = parts[2]; 92 + 93 + if (!command) continue; 94 + 95 + // Ensure top-level builder exists 96 + if (!topLevelCommands.has(command)) { 97 + const topCommand = 98 + (this.moduleManager.commands.get(command)?.data as SlashCommandBuilder) ?? 99 + new SlashCommandBuilder().setName(command).setDescription("..."); 100 + 101 + topLevelCommands.set(command, topCommand); 102 + } 103 + 104 + const parent = topLevelCommands.get(command)!; 105 + 106 + if (subcommand && !subgroupcommand) { 107 + // It's a subcommand 108 + parent.addSubcommand(cmd.data as SlashCommandSubcommandBuilder); 109 + } else if (subcommand && subgroupcommand) { 110 + // It's a subgroup command 111 + let group = parent.options.find( 112 + (o): o is SlashCommandSubcommandGroupBuilder => 113 + o instanceof SlashCommandSubcommandGroupBuilder && o.name === subcommand, 114 + ); 115 + 116 + if (!group) { 117 + group = new SlashCommandSubcommandGroupBuilder() 118 + .setName(subcommand) 119 + .setDescription("..."); 120 + parent.addSubcommandGroup(group); 121 + } 122 + 123 + group.addSubcommand(cmd.data as SlashCommandSubcommandBuilder); 124 + } 125 + } 126 + 127 + // Finally convert assembled top-level commands to JSON and register them 128 + await this.application?.commands.set([...topLevelCommands.values()].map((c) => c.toJSON())); 129 + } 130 + 131 + /** 132 + * Returns all cached commands 133 + */ 134 + get commands(): CacheMap<Command> { 135 + return this.moduleManager.commands; 136 + } 137 + 138 + /** 139 + * Returns all cached events 140 + */ 141 + get events(): CacheMap<Event> { 142 + return this.moduleManager.events; 143 + } 144 + 145 + /** 146 + * Returns all cached buttons 147 + */ 148 + get buttons(): CacheMap<Button> { 149 + return this.moduleManager.buttons; 150 + } 151 + }
+13
packages/framework/src/core/types/Button.ts
··· 1 + //=============================================== 2 + // Imports 3 + //=============================================== 4 + import type { ButtonInteraction } from "discord.js"; 5 + import type { VoidyClient } from "../VoidyClient"; 6 + import type { Resource } from "./Resource"; 7 + 8 + //=============================================== 9 + // Button Definition 10 + //=============================================== 11 + export interface Button extends Resource { 12 + execute: (interaction: ButtonInteraction, client: VoidyClient) => Promise<void>; 13 + }
+20
packages/framework/src/core/types/Command.ts
··· 1 + //=============================================== 2 + // Imports 3 + //=============================================== 4 + import type { 5 + SlashCommandSubcommandGroupBuilder, 6 + SlashCommandSubcommandBuilder, 7 + ChatInputCommandInteraction, 8 + SlashCommandBuilder, 9 + } from "discord.js"; 10 + import type { VoidyClient } from "../VoidyClient"; 11 + import type { Resource } from "./Resource"; 12 + 13 + //=============================================== 14 + // Command Definition 15 + //=============================================== 16 + export interface Command extends Resource { 17 + data: SlashCommandBuilder | SlashCommandSubcommandBuilder | SlashCommandSubcommandGroupBuilder; 18 + devOnly: boolean | null; 19 + execute: (interaction: ChatInputCommandInteraction, client: VoidyClient) => Promise<void>; 20 + }
+15
packages/framework/src/core/types/Event.ts
··· 1 + //=============================================== 2 + // Imports 3 + //=============================================== 4 + import type { VoidyClient } from "../VoidyClient"; 5 + import type { ClientEvents } from "discord.js"; 6 + import type { Resource } from "./Resource"; 7 + 8 + //=============================================== 9 + // Event Definition 10 + //=============================================== 11 + export interface Event extends Resource { 12 + name: keyof ClientEvents; 13 + once?: boolean; 14 + execute: (client: VoidyClient, ...args: unknown[]) => void; 15 + }
+23
packages/framework/src/core/types/Module.ts
··· 1 + //=============================================== 2 + // Imports 3 + //=============================================== 4 + import type { Resource } from "./Resource"; 5 + import type { Loader } from "../Loader"; 6 + 7 + //=============================================== 8 + // ModuleExportsItem Definition 9 + //=============================================== 10 + export interface ModuleExportsItem<T extends object> { 11 + source: string; 12 + loader: new (...args: ConstructorParameters<typeof Loader<T>>) => Loader<T>; 13 + } 14 + 15 + //=============================================== 16 + // Module Definition 17 + //=============================================== 18 + export interface Module extends Resource { 19 + name: string; 20 + description: string; 21 + author: string; 22 + exports: ModuleExportsItem<object>[]; 23 + }
+6
packages/framework/src/core/types/Resource.ts
··· 1 + //=============================================== 2 + // Resource Definition 3 + //=============================================== 4 + export interface Resource { 5 + id: string; 6 + }
+9
packages/framework/src/handlers/ButtonHandler.ts
··· 1 + import type { ButtonInteraction } from "discord.js"; 2 + import type { Button } from "../core/types/Button"; 3 + import type { VoidyClient } from "../core/VoidyClient"; 4 + 5 + export class ButtonHandler { 6 + public static invoke(interaction: ButtonInteraction, payload: Button, client: VoidyClient): void { 7 + payload.execute(interaction, client); 8 + } 9 + }
+24
packages/framework/src/handlers/CommandHandler.ts
··· 1 + import { MessageFlags, type ChatInputCommandInteraction } from "discord.js"; 2 + import type { Command } from "../core/types/Command"; 3 + import type { VoidyClient } from "../core/VoidyClient"; 4 + 5 + export class ChatInputCommandHandler { 6 + public static async invoke( 7 + interaction: ChatInputCommandInteraction, 8 + payload: Command, 9 + client: VoidyClient, 10 + ): Promise<void> { 11 + if (payload.devOnly && !client.developers.includes(interaction.user.id)) { 12 + await interaction.reply({ 13 + content: "You are not authorized to use this command.", 14 + flags: [MessageFlags.Ephemeral] 15 + }); 16 + 17 + return; 18 + } 19 + 20 + client.logger.send(`[ChatInputCommandHandler] \`${payload.id}\` invoked by \`${interaction.user.tag}\``); 21 + 22 + payload.execute(interaction, client); 23 + } 24 + }
+21
packages/framework/src/index.ts
··· 1 + // Core 2 + export * from "./core/Loader"; 3 + export * from "./core/ModuleManager"; 4 + export * from "./core/VoidyClient"; 5 + 6 + // Types 7 + export * from "./core/types/Button"; 8 + export * from "./core/types/Command"; 9 + export * from "./core/types/Event"; 10 + export * from "./core/types/Module"; 11 + export * from "./core/types/Resource"; 12 + 13 + // Handlers 14 + export * from "./handlers/ButtonHandler"; 15 + export * from "./handlers/CommandHandler"; 16 + 17 + // Loaders 18 + export * from "./loaders/ButtonLoader"; 19 + export * from "./loaders/CommandLoader"; 20 + export * from "./loaders/EventLoader"; 21 + export * from "./loaders/ModuleLoader";
+16
packages/framework/src/loaders/ButtonLoader.ts
··· 1 + //=============================================== 2 + // Imports 3 + //=============================================== 4 + import type { Button } from "../core/types/Button"; 5 + import { Loader } from "../core/Loader"; 6 + 7 + //=============================================== 8 + // ButtonLoader Implementation 9 + //=============================================== 10 + export class ButtonLoader extends Loader<Button> { 11 + public id = "button"; 12 + public async validate(data: Partial<Button>) { 13 + if (!data.id || !data.execute) return null; 14 + return data as Button; 15 + } 16 + }
+16
packages/framework/src/loaders/CommandLoader.ts
··· 1 + //=============================================== 2 + // Imports 3 + //=============================================== 4 + import type { Command } from "../core/types/Command"; 5 + import { Loader } from "../core/Loader"; 6 + 7 + //=============================================== 8 + // CommandLoader Implementation 9 + //=============================================== 10 + export class CommandLoader extends Loader<Command> { 11 + public id = "command"; 12 + public async validate(data: Partial<Command>) { 13 + if (!data.id || !data.data || !data.execute) return null; 14 + return data as Command; 15 + } 16 + }
+16
packages/framework/src/loaders/EventLoader.ts
··· 1 + //=============================================== 2 + // Imports 3 + //=============================================== 4 + import type { Event } from "../core/types/Event"; 5 + import { Loader } from "../core/Loader"; 6 + 7 + //=============================================== 8 + // EventLoader Implemenation 9 + //=============================================== 10 + export class EventLoader extends Loader<Event> { 11 + public id = "event"; 12 + public async validate(data: Partial<Event>) { 13 + if (!data.id || !data.name || !data.execute) return null; 14 + return data as Event; 15 + } 16 + }
+17
packages/framework/src/loaders/ModuleLoader.ts
··· 1 + //=============================================== 2 + // Imports 3 + //=============================================== 4 + import type { Module } from "../core/types/Module"; 5 + import { Loader } from "../core/Loader"; 6 + 7 + //=============================================== 8 + // ModuleLoader Implementation 9 + //=============================================== 10 + export class ModuleLoader extends Loader<Module> { 11 + public id = "module"; 12 + public async validate(data: Partial<Module>) { 13 + if (!data.id || !data.name || !data.description || !data.author || !data.exports) return null; 14 + 15 + return data as Module; 16 + } 17 + }
+12
packages/framework/tsconfig.json
··· 1 + { 2 + "extends": "../../tsconfig.json", 3 + "compilerOptions": { 4 + "declaration": true, 5 + "declarationDir": "dist/types", 6 + "outDir": "dist", 7 + "strict": true, 8 + "esModuleInterop": true, 9 + "composite": true, 10 + }, 11 + "include": ["src"], 12 + }
-25
src/core/client.ts
··· 1 - import { Client, ClientOptions } from "discord.js"; 2 - import { FeatureRegistry } from "./registry.ts"; 3 - 4 - export class VoidyClient extends Client { 5 - public registry: FeatureRegistry; 6 - 7 - constructor(options: ClientOptions) { 8 - super(options); 9 - 10 - this.registry = new FeatureRegistry(this); 11 - } 12 - 13 - async start(token: string) { 14 - await this.registry.loadFeaturesFromDirectory("src/features"); 15 - 16 - this.once("ready", async () => { 17 - console.log(`โœ… Logged in as ${this.user?.tag}`); 18 - 19 - await this.registry.deployCommands(); 20 - await this.registry.notifyReady(); 21 - }); 22 - 23 - await this.login(token); 24 - } 25 - }
-132
src/core/registry.ts
··· 1 - import { walk } from "@std/fs"; 2 - import { resolve } from "@std/path"; 3 - import { Interaction } from "discord.js"; 4 - import { Feature } from "./types.ts"; 5 - import { VoidyClient } from "./client.ts"; 6 - 7 - export class FeatureRegistry { 8 - private client: VoidyClient; 9 - private features = new Map<string, Feature>(); 10 - 11 - constructor(client: VoidyClient) { 12 - this.client = client; 13 - 14 - // Global interaction handler 15 - this.client.on("interactionCreate", async (interaction: Interaction) => { 16 - if (interaction.isChatInputCommand()) { 17 - for (const feature of this.features.values()) { 18 - for (const cmd of feature.commands ?? []) { 19 - if (cmd.data.name === interaction.commandName) { 20 - const context = { 21 - client: this.client, 22 - createCustomId: (id: string) => `${feature.id}:${id}`, 23 - }; 24 - 25 - return await cmd.execute(interaction, context); 26 - } 27 - } 28 - } 29 - } 30 - 31 - if (interaction.isButton()) { 32 - const [featureId, buttonId] = interaction.customId.split(":"); 33 - const feature = this.features.get(featureId); 34 - const buttonHandler = feature?.buttonHandlers?.get(buttonId); 35 - 36 - if (feature && buttonHandler) { 37 - const context = { 38 - client: this.client, 39 - createCustomId: (id: string) => `${feature.id}:${id}`, 40 - }; 41 - 42 - return await buttonHandler(interaction, context); 43 - } 44 - } 45 - }); 46 - 47 - // Global event handler 48 - for (const feature of this.features.values()) { 49 - for (const event of feature.events ?? []) { 50 - const context = { 51 - client: this.client, 52 - createCustomId: (id: string) => `${feature.id}:${id}`, 53 - }; 54 - 55 - if (event.once) { 56 - this.client.once(event.name, async (data) => { 57 - await event.execute(data, context); 58 - }); 59 - } else { 60 - this.client.on(event.name, async (data) => { 61 - await event.execute(data, context); 62 - }); 63 - } 64 - } 65 - } 66 - } 67 - 68 - async loadFeaturesFromDirectory(directory: string) { 69 - const root = resolve(Deno.cwd(), directory); 70 - 71 - for await (const entry of walk(root, { includeDirs: false })) { 72 - if (entry.name === "index.ts") { 73 - const module = await import("file://" + entry.path); 74 - const feature: Feature = module.default; 75 - 76 - if (!feature || !feature.id) { 77 - console.warn(`โŒ Invalid feature at ${entry.path}`); 78 - continue; 79 - } 80 - 81 - if (this.features.has(feature.id)) { 82 - console.warn(`โš  Feature ID conflict: ${feature.id}`); 83 - continue; 84 - } 85 - 86 - this.features.set(feature.id, feature); 87 - await feature.setup?.(); 88 - console.log(`๐Ÿ”น Loaded feature: ${feature.name}`); 89 - } 90 - } 91 - 92 - // Log some statistics 93 - const featureCount = this.features.size; 94 - const eventsCount = Array.from(this.features.values()) 95 - .flatMap((f) => f.events ?? []) 96 - .length; 97 - const commandsCount = Array.from(this.features.values()) 98 - .flatMap((f) => f.commands ?? []) 99 - .length; 100 - 101 - console.log( 102 - `โœ… Loaded ${featureCount} features, with ${eventsCount} events and ${commandsCount} commands.`, 103 - ); 104 - } 105 - 106 - async deployCommands() { 107 - const commands = Array.from(this.features.values()) 108 - .flatMap((f) => f.commands ?? []) 109 - .map((c) => c.data.toJSON()); 110 - 111 - await this.client.application?.commands.set(commands); 112 - console.log(`๐Ÿš€ Deployed ${commands.length} slash commands.`); 113 - } 114 - 115 - async notifyReady() { 116 - for (const feature of this.features.values()) { 117 - await feature.onReady?.(); 118 - } 119 - } 120 - 121 - async cleanupAll() { 122 - for (const feature of this.features.values()) { 123 - try { 124 - await feature.cleanup?.(); 125 - } catch (err) { 126 - console.warn(`โš  Error during cleanup of ${feature.id}:`, err); 127 - } 128 - } 129 - 130 - this.features.clear(); 131 - } 132 - }
-49
src/core/types.ts
··· 1 - import { 2 - ButtonInteraction, 3 - ChatInputCommandInteraction, 4 - SlashCommandBuilder, 5 - SlashCommandOptionsOnlyBuilder, 6 - } from "discord.js"; 7 - import { VoidyClient } from "./client.ts"; 8 - 9 - export interface Command { 10 - data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder; 11 - execute: ( 12 - interaction: ChatInputCommandInteraction, 13 - context: FeatureContext, 14 - ) => Promise<void>; 15 - } 16 - 17 - export interface Event { 18 - name: string; 19 - once?: boolean; 20 - execute: ( 21 - data: object, 22 - context: FeatureContext, 23 - ) => Promise<void> | void; 24 - } 25 - 26 - export type ButtonHandler = ( 27 - interaction: ButtonInteraction, 28 - context: FeatureContext, 29 - ) => Promise<void>; 30 - 31 - export interface Feature { 32 - id: string; 33 - name: string; 34 - description?: string; 35 - 36 - commands?: Command[]; 37 - events?: Event[]; 38 - buttonHandlers?: Map<string, ButtonHandler>; 39 - 40 - // Optional lifecycle hooks 41 - setup?: () => Promise<void>; 42 - onReady?: () => Promise<void>; 43 - cleanup?: () => Promise<void>; 44 - } 45 - 46 - export interface FeatureContext { 47 - client: VoidyClient; 48 - createCustomId: (id: string) => string; 49 - }
-125
src/features/maintenance/commands.ts
··· 1 - import { 2 - CommandInteraction, 3 - ContainerBuilder, 4 - MediaGalleryBuilder, 5 - MediaGalleryItemBuilder, 6 - MessageFlags, 7 - PermissionFlagsBits, 8 - SlashCommandBuilder, 9 - TextDisplayBuilder, 10 - } from "discord.js"; 11 - import type { Command, FeatureContext } from "../../core/types.ts"; 12 - import { inspect } from "node:util"; 13 - 14 - export const reloadCommand: Command = { 15 - data: new SlashCommandBuilder() 16 - .setName("reload") 17 - .setDescription("Reload all features (admin only)") 18 - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), 19 - 20 - execute: async (interaction: CommandInteraction, context: FeatureContext) => { 21 - await interaction.deferReply({ ephemeral: true }); 22 - 23 - try { 24 - const registry = context.client.registry; 25 - 26 - await interaction.editReply("๐Ÿงน Cleaning up features..."); 27 - await registry.cleanupAll(); 28 - 29 - console.log("๐Ÿ” Reloading features..."); 30 - await interaction.editReply("๐Ÿ” Reloading features..."); 31 - await registry.loadFeaturesFromDirectory("src/features"); 32 - 33 - await interaction.editReply("๐Ÿ“ก Re-deploying commands..."); 34 - await registry.deployCommands(); 35 - 36 - await interaction.editReply("โœ… Notifying features..."); 37 - await registry.notifyReady(); 38 - 39 - await interaction.editReply("โœ… Features reloaded successfully."); 40 - } catch (err) { 41 - console.error("โŒ Reload failed:", err); 42 - await interaction.editReply("โŒ Reload failed. Check logs."); 43 - } 44 - }, 45 - }; 46 - 47 - const OWNER_IDS = 48 - Deno.env.get("BOT_ADMINS")?.split(",").map((id) => id.trim()) ?? []; 49 - 50 - export const evalCommand: Command = { 51 - data: new SlashCommandBuilder() 52 - .setName("eval") 53 - .setDescription("Execute JavaScript code (owner only)") 54 - .addStringOption((opt) => 55 - opt.setName("code").setDescription("JS code to execute").setRequired(true) 56 - ) 57 - .addBooleanOption((opt) => 58 - opt.setName("is_image").setDescription( 59 - "Whether to expect an image", 60 - ) 61 - ) as SlashCommandBuilder, 62 - 63 - execute: async ( 64 - interaction: CommandInteraction, 65 - _context: FeatureContext, 66 - ) => { 67 - if (!interaction.isChatInputCommand()) return; 68 - const userId = interaction.user.id; 69 - 70 - if (!OWNER_IDS.includes(userId)) { 71 - await interaction.reply({ 72 - content: "โŒ You are not authorized to use this.", 73 - flags: [MessageFlags.Ephemeral], 74 - }); 75 - 76 - return; 77 - } 78 - 79 - const code = interaction.options.getString("code", true); 80 - 81 - try { 82 - const result = await eval(`(async () => { ${code} })()`); 83 - const output = inspect(result, { depth: 1 }); 84 - 85 - const headerText = new TextDisplayBuilder() 86 - .setContent("โœ… Eval Success"); 87 - 88 - const contentText = new TextDisplayBuilder() 89 - .setContent("```js\n" + output + "\n```"); 90 - 91 - const container = new ContainerBuilder() 92 - .addTextDisplayComponents([headerText, contentText]); 93 - 94 - if (interaction.options.getBoolean("is_image", false)) { 95 - const galleryItemComponent = new MediaGalleryItemBuilder().setURL( 96 - output.split("'")[1], 97 - ); 98 - const galleryComponent = new MediaGalleryBuilder().addItems( 99 - galleryItemComponent, 100 - ); 101 - 102 - container.addMediaGalleryComponents(galleryComponent); 103 - } 104 - 105 - await interaction.reply({ 106 - components: [container], 107 - flags: [MessageFlags.IsComponentsV2, MessageFlags.Ephemeral], 108 - }); 109 - } catch (err) { 110 - const headerText = new TextDisplayBuilder() 111 - .setContent("โŒ Eval Error"); 112 - 113 - const contentText = new TextDisplayBuilder() 114 - .setContent("```js\n" + err?.toString() + "\n```"); 115 - 116 - const container = new ContainerBuilder() 117 - .addTextDisplayComponents([headerText, contentText]); 118 - 119 - await interaction.reply({ 120 - components: [container], 121 - flags: [MessageFlags.IsComponentsV2, MessageFlags.Ephemeral], 122 - }); 123 - } 124 - }, 125 - };
-11
src/features/maintenance/index.ts
··· 1 - import type { Feature } from "../../core/types.ts"; 2 - import { evalCommand, reloadCommand } from "./commands.ts"; 3 - 4 - const MaintenanceFeature: Feature = { 5 - id: "maintenance", 6 - name: "Maintenance Tools", 7 - 8 - commands: [reloadCommand, evalCommand], 9 - }; 10 - 11 - export default MaintenanceFeature;
-21
src/features/statistics/index.ts
··· 1 - import type { Feature, FeatureContext } from "../../core/types.ts"; 2 - 3 - const messageCreateEvent = { 4 - name: "messageCreate", 5 - 6 - execute(_data: object, context: FeatureContext) { 7 - console.log( 8 - `messageCreate event executed in statistics feature, by ${context.client?.user?.tag}`, 9 - ); 10 - }, 11 - }; 12 - 13 - const StatisticsFeature: Feature = { 14 - id: "statistics", 15 - name: "Statistics", 16 - 17 - commands: [], 18 - events: [messageCreateEvent], 19 - }; 20 - 21 - export default StatisticsFeature;
-129
src/features/utility/commands.ts
··· 1 - import { 2 - ButtonBuilder, 3 - ButtonStyle, 4 - ChatInputCommandInteraction, 5 - CommandInteraction, 6 - ContainerBuilder, 7 - MessageFlags, 8 - SectionBuilder, 9 - SlashCommandBuilder, 10 - TextDisplayBuilder, 11 - } from "discord.js"; 12 - import type { Command, FeatureContext } from "../../core/types.ts"; 13 - 14 - export const pingCommand: Command = { 15 - data: new SlashCommandBuilder() 16 - .setName("ping") 17 - .setDescription("Replies with Pong and a Refresh button"), 18 - 19 - execute: async (interaction: CommandInteraction, context: FeatureContext) => { 20 - const button = new ButtonBuilder() 21 - .setCustomId(context.createCustomId("refresh")) 22 - .setLabel("๐Ÿ” Refresh") 23 - .setStyle(ButtonStyle.Primary); 24 - 25 - const headerTitle = new TextDisplayBuilder() 26 - .setContent(`๐Ÿ“ Pong! ${context.client.ws.ping}ms`); 27 - 28 - const headerSection = new SectionBuilder() 29 - .addTextDisplayComponents([headerTitle]) 30 - .setButtonAccessory(button); 31 - 32 - const container = new ContainerBuilder() 33 - .addSectionComponents([headerSection]); 34 - 35 - await interaction.reply({ 36 - components: [container], 37 - flags: [MessageFlags.IsComponentsV2], 38 - }); 39 - }, 40 - }; 41 - 42 - export const uploadCommand: Command = { 43 - data: new SlashCommandBuilder() 44 - .setName("upload") 45 - .setDescription("Uploads an image to the contest API") 46 - .addAttachmentOption((option) => 47 - option 48 - .setName("image") 49 - .setDescription("The image to upload") 50 - .setRequired(true) 51 - ) 52 - .addIntegerOption((option) => 53 - option 54 - .setName("contest_id") 55 - .setDescription("The contest ID") 56 - .setRequired(true) 57 - ) 58 - .addStringOption((option) => 59 - option 60 - .setName("participant_name") 61 - .setDescription("The name of the participant") 62 - .setRequired(true) 63 - ) 64 - .addStringOption((option) => 65 - option 66 - .setName("participant_email") 67 - .setDescription("The email of the participant") 68 - .setRequired(true) 69 - ), 70 - 71 - execute: async ( 72 - interaction: ChatInputCommandInteraction, 73 - _context: FeatureContext, 74 - ) => { 75 - const attachment = interaction.options.getAttachment("image", true); 76 - const contestId = interaction.options.getInteger("contest_id", true); 77 - const participantName = interaction.options.getString( 78 - "participant_name", 79 - true, 80 - ); 81 - const participantEmail = interaction.options.getString( 82 - "participant_email", 83 - true, 84 - ); 85 - 86 - await interaction.deferReply({ ephemeral: true }); 87 - 88 - try { 89 - const fileResponse = await fetch(attachment.url); 90 - const fileBuffer = await fileResponse.arrayBuffer(); 91 - 92 - const form = new FormData(); 93 - form.append("image", new Blob([fileBuffer])); 94 - form.append("contest_id", contestId.toString()); 95 - form.append("participant_name", participantName); 96 - form.append("participant_email", participantEmail); 97 - 98 - const res = await fetch("http://localhost:8000/api/upload-drawing", { 99 - method: "POST", 100 - body: form, 101 - }); 102 - 103 - if (!res.ok) { 104 - const error = await res.text(); 105 - console.log(error); 106 - 107 - await interaction.editReply({ 108 - content: `โŒ Upload failed, check console for details.`, 109 - }); 110 - 111 - return; 112 - } 113 - 114 - const result = await res.json(); 115 - await interaction.editReply({ 116 - content: `โœ… Image uploaded successfully! 117 - Participant ID: ${result.participant_id ?? "(not returned)"} 118 - Submission ID: ${result.submission_id ?? "(not returned)"}`, 119 - }); 120 - } catch (err) { 121 - console.error(err); 122 - await interaction.editReply({ 123 - content: `โŒ An error occurred during upload.`, 124 - }); 125 - 126 - return; 127 - } 128 - }, 129 - };
-15
src/features/utility/index.ts
··· 1 - import type { Feature } from "../../core/types.ts"; 2 - import { pingCommand, uploadCommand } from "./commands.ts"; 3 - import { refreshButton } from "./interactions.ts"; 4 - 5 - const UtilityFeature: Feature = { 6 - id: "utility", 7 - name: "Utility Commands", 8 - 9 - commands: [pingCommand, uploadCommand], 10 - buttonHandlers: new Map([ 11 - ["refresh", refreshButton], 12 - ]), 13 - }; 14 - 15 - export default UtilityFeature;
-35
src/features/utility/interactions.ts
··· 1 - import { 2 - ButtonBuilder, 3 - ButtonInteraction, 4 - ButtonStyle, 5 - ContainerBuilder, 6 - MessageFlags, 7 - SectionBuilder, 8 - TextDisplayBuilder, 9 - } from "discord.js"; 10 - import { FeatureContext } from "../../core/types.ts"; 11 - 12 - export const refreshButton = async ( 13 - interaction: ButtonInteraction, 14 - context: FeatureContext, 15 - ): Promise<void> => { 16 - const button = new ButtonBuilder() 17 - .setCustomId(context.createCustomId("refresh")) 18 - .setLabel("๐Ÿ” Refresh") 19 - .setStyle(ButtonStyle.Primary); 20 - 21 - const headerTitle = new TextDisplayBuilder() 22 - .setContent(`๐Ÿ“ Pong! ${context.client.ws.ping}ms`); 23 - 24 - const headerSection = new SectionBuilder() 25 - .addTextDisplayComponents([headerTitle]) 26 - .setButtonAccessory(button); 27 - 28 - const container = new ContainerBuilder() 29 - .addSectionComponents([headerSection]); 30 - 31 - await interaction.update({ 32 - components: [container], 33 - flags: [MessageFlags.IsComponentsV2], 34 - }); 35 - };
-8
src/main.ts
··· 1 - import { GatewayIntentBits } from "discord.js"; 2 - import { VoidyClient } from "./core/client.ts"; 3 - 4 - const client = new VoidyClient({ 5 - intents: [GatewayIntentBits.Guilds], 6 - }); 7 - 8 - await client.start(Deno.env.get("BOT_TOKEN")!);
+21
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "lib": ["ESNext"], 4 + "target": "ESNext", 5 + "module": "Preserve", 6 + "moduleDetection": "force", 7 + "allowJs": true, 8 + "moduleResolution": "bundler", 9 + "allowImportingTsExtensions": true, 10 + "verbatimModuleSyntax": true, 11 + "noEmit": true, 12 + "strict": true, 13 + "skipLibCheck": true, 14 + "noFallthroughCasesInSwitch": true, 15 + "noUncheckedIndexedAccess": true, 16 + "noImplicitOverride": true, 17 + "noUnusedLocals": false, 18 + "noUnusedParameters": false, 19 + "noPropertyAccessFromIndexSignature": false, 20 + }, 21 + }