commit c0da4083da11328a85e271a120bf5a923cabb37d Author: artie Date: Sat Feb 8 16:11:43 2025 +0100 initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..44d6cdb --- /dev/null +++ b/.gitignore @@ -0,0 +1,177 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +data/temp/* \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a243041 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# artemis + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.2.2. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..7f0ba19 --- /dev/null +++ b/bun.lock @@ -0,0 +1,198 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "artemis", + "dependencies": { + "@discordjs/core": "^2.0.1", + "@sapphire/discord.js-utilities": "^7.3.2", + "cheerio": "^1.0.0", + "discord.js": "^14.17.3", + "ky": "^1.7.4", + "winston": "^3.17.0", + "zod": "^3.24.1", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5.0.0", + }, + }, + }, + "packages": { + "@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="], + + "@dabh/diagnostics": ["@dabh/diagnostics@2.0.3", "", { "dependencies": { "colorspace": "1.1.x", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA=="], + + "@discordjs/builders": ["@discordjs/builders@1.10.0", "", { "dependencies": { "@discordjs/formatters": "^0.6.0", "@discordjs/util": "^1.1.1", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.37.114", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-ikVZsZP+3shmVJ5S1oM+7SveUCK3L9fTyfA8aJ7uD9cNQlTqF+3Irbk2Y22KXTb3C3RNUahRkSInClJMkHrINg=="], + + "@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="], + + "@discordjs/core": ["@discordjs/core@2.0.1", "", { "dependencies": { "@discordjs/rest": "^2.4.1", "@discordjs/util": "^1.1.1", "@discordjs/ws": "^2.0.1", "@sapphire/snowflake": "^3.5.3", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.37.114" } }, "sha512-tj//rnPhdniq+Clxq0tq5wI7QTMH4IgyXkUFI32+ashIRpFJ1+J1btNG3FA74oATFu2sYy/zT9CKLWW4jp2RGQ=="], + + "@discordjs/formatters": ["@discordjs/formatters@0.6.0", "", { "dependencies": { "discord-api-types": "^0.37.114" } }, "sha512-YIruKw4UILt/ivO4uISmrGq2GdMY6EkoTtD0oS0GvkJFRZbTSdPhzYiUILbJ/QslsvC9H9nTgGgnarnIl4jMfw=="], + + "@discordjs/rest": ["@discordjs/rest@2.4.2", "", { "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.37.114", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.19.8" } }, "sha512-9bOvXYLQd5IBg/kKGuEFq3cstVxAMJ6wMxO2U3wjrgO+lHv8oNCT+BBRpuzVQh7BoXKvk/gpajceGvQUiRoJ8g=="], + + "@discordjs/util": ["@discordjs/util@1.1.1", "", {}, "sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g=="], + + "@discordjs/ws": ["@discordjs/ws@2.0.1", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/rest": "^2.4.1", "@discordjs/util": "^1.1.1", "@sapphire/async-queue": "^1.5.3", "@types/ws": "^8.5.12", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.37.114", "tslib": "^2.6.3", "ws": "^8.18.0" } }, "sha512-5etVbXdwThIT5+KfU+uiBh358Ql58f2vf1W9B5ZXxaZuIlpiX/VC/G0Lr/xuEcs6cL+/HZtEIUf0p9dQfEDang=="], + + "@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="], + + "@sapphire/discord-utilities": ["@sapphire/discord-utilities@3.4.4", "", { "dependencies": { "discord-api-types": "^0.37.114" } }, "sha512-P7bCL5U2s+U5oy9OFpC4lr4Y0bGoNrSCPu2bgxP9AaqK90hcwpoLWWKouvGgaY9tCbUZmaf8E98/SlZZoakhAQ=="], + + "@sapphire/discord.js-utilities": ["@sapphire/discord.js-utilities@7.3.2", "", { "dependencies": { "@sapphire/discord-utilities": "^3.4.4", "@sapphire/duration": "^1.1.4", "@sapphire/utilities": "^3.18.1", "tslib": "^2.8.1" } }, "sha512-eEs6SjZghc7SlSRfirXzfJNy7sT9VxBtX32Zz33u8sxVq2X2/W6++klmDuIi6LSLrcsAlo9xWxcR+lL0pdaIEQ=="], + + "@sapphire/duration": ["@sapphire/duration@1.1.4", "", {}, "sha512-hxtuE8HvmWcRok2A10lJ+ic8qY0oYGTTn44XmESUYJYYSVJWmqlCH1LnNYi6Ul+LRjxNUfFQEL/TJS0GJ+8kew=="], + + "@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="], + + "@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="], + + "@sapphire/utilities": ["@sapphire/utilities@3.18.2", "", {}, "sha512-QGLdC9+pT74Zd7aaObqn0EUfq40c4dyTL65pFnkM6WO1QYN7Yg/s4CdH+CXmx0Zcu6wcfCWILSftXPMosJHP5A=="], + + "@types/bun": ["@types/bun@1.2.2", "", { "dependencies": { "bun-types": "1.2.2" } }, "sha512-tr74gdku+AEDN5ergNiBnplr7hpDp3V1h7fqI2GcR/rsUaM39jpSeKH0TFibRvU0KwniRx5POgaYnaXbk0hU+w=="], + + "@types/node": ["@types/node@22.13.1", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew=="], + + "@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="], + + "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], + + "@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.6", "", {}, "sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA=="], + + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + + "bun-types": ["bun-types@1.2.2", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-RCbMH5elr9gjgDGDhkTTugA21XtJAy/9jkKe/G3WR2q17VPGhcquf9Sir6uay9iW+7P/BV0CAHA1XlHXMAVKHg=="], + + "cheerio": ["cheerio@1.0.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "encoding-sniffer": "^0.2.0", "htmlparser2": "^9.1.0", "parse5": "^7.1.2", "parse5-htmlparser2-tree-adapter": "^7.0.0", "parse5-parser-stream": "^7.1.2", "undici": "^6.19.5", "whatwg-mimetype": "^4.0.0" } }, "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww=="], + + "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], + + "color": ["color@3.2.1", "", { "dependencies": { "color-convert": "^1.9.3", "color-string": "^1.6.0" } }, "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA=="], + + "color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], + + "color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], + + "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + + "colorspace": ["colorspace@1.1.4", "", { "dependencies": { "color": "^3.1.3", "text-hex": "1.0.x" } }, "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w=="], + + "css-select": ["css-select@5.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg=="], + + "css-what": ["css-what@6.1.0", "", {}, "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw=="], + + "discord-api-types": ["discord-api-types@0.37.119", "", {}, "sha512-WasbGFXEB+VQWXlo6IpW3oUv73Yuau1Ig4AZF/m13tXcTKnMpc/mHjpztIlz4+BM9FG9BHQkEXiPto3bKduQUg=="], + + "discord.js": ["discord.js@14.17.3", "", { "dependencies": { "@discordjs/builders": "^1.10.0", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.0", "@discordjs/rest": "^2.4.2", "@discordjs/util": "^1.1.1", "@discordjs/ws": "^1.2.0", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.37.114", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "tslib": "^2.6.3", "undici": "6.19.8" } }, "sha512-8/j8udc3CU7dz3Eqch64UaSHoJtUT6IXK4da5ixjbav4NAXJicloWswD/iwn1ImZEMoAV3LscsdO0zhBh6H+0Q=="], + + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + + "enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="], + + "encoding-sniffer": ["encoding-sniffer@0.2.0", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg=="], + + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fecha": ["fecha@4.2.3", "", {}, "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="], + + "fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="], + + "htmlparser2": ["htmlparser2@9.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="], + + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "kuler": ["kuler@2.0.0", "", {}, "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="], + + "ky": ["ky@1.7.4", "", {}, "sha512-zYEr/gh7uLW2l4su11bmQ2M9xLgQLjyvx58UyNM/6nuqyWFHPX5ktMjvpev3F8QWdjSsHUpnWew4PBCswBNuMQ=="], + + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + + "lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="], + + "logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="], + + "magic-bytes.js": ["magic-bytes.js@1.10.0", "", {}, "sha512-/k20Lg2q8LE5xiaaSkMXk4sfvI+9EGEykFS4b0CHHGWqDYU0bGUFSwchNOMA56D7TCs9GwVTkqe9als1/ns8UQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + + "one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="], + + "parse5": ["parse5@7.2.1", "", { "dependencies": { "entities": "^4.5.0" } }, "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ=="], + + "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="], + + "parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="], + + "stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="], + + "triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="], + + "ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + + "undici": ["undici@6.19.8", "", {}, "sha512-U8uCCl2x9TK3WANvmBavymRzxbfFYG+tAu+fgx3zxQy3qdagQqBLwJVrdyO1TBfUXvfKveMKJZhpvUYoOjM+4g=="], + + "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "winston": ["winston@3.17.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.2", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw=="], + + "winston-transport": ["winston-transport@4.9.0", "", { "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" } }, "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A=="], + + "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + + "zod": ["zod@3.24.1", "", {}, "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="], + + "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], + + "@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], + + "discord.js/@discordjs/ws": ["@discordjs/ws@1.2.0", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.4.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.37.114", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-QH5CAFe3wHDiedbO+EI3OOiyipwWd+Q6BdoFZUw/Wf2fw5Cv2fgU/9UEtJRmJa9RecI+TAhdGPadMaEIur5yJg=="], + + "discord.js/@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7ddef81 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "artemis", + "module": "index.ts", + "type": "module", + "main": "src/index.ts", + "scripts": { + "dev": "bun --watch src/index.ts", + "start": "bun run src/index.ts", + "sync": "bun run src/scripts/sync.ts", + "test": "bun run src/scripts/test.ts" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "@discordjs/core": "^2.0.1", + "@sapphire/discord.js-utilities": "^7.3.2", + "cheerio": "^1.0.0", + "discord.js": "^14.17.3", + "ky": "^1.7.4", + "winston": "^3.17.0", + "zod": "^3.24.1" + } +} diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..1c46604 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,6 @@ +import { env } from "./env"; +import { API } from "@discordjs/core"; +import { REST } from "discord.js"; + +const rest = new REST().setToken(env.DISCORD_TOKEN); +export const api = new API(rest); diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..a599b31 --- /dev/null +++ b/src/client.ts @@ -0,0 +1,130 @@ +import { Client, Collection, GatewayIntentBits } from "discord.js"; +import { env } from "./env"; +import { ActivityType, API, InteractionContextType } from "@discordjs/core"; +import { api } from "./api"; +import type { Command } from "./types/command"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { log } from "./utils/logger"; +import { pluralize } from "./utils/functions"; +import { DEV } from "./utils/constants"; + +export class ArtemisClient extends Client { + public api: API; + public ownerId = env.OWNER_ID; + public commands = new Collection(); + + constructor() { + super({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.DirectMessages, + ], + allowedMentions: { + parse: [], + }, + presence: { + activities: [{ name: "🩷", type: ActivityType.Custom }], + }, + }); + + this.api = api; + } + + async setup() { + Promise.all([this.loadCommands(), this.registerEvents()]); + } + + async loadCommands() { + const commandsDir = path.join(import.meta.dir, "commands"); + const categories = await fs + .readdir(commandsDir) + .then((categories) => + categories.filter((category) => !category.includes(".")) + ); + const promises: Promise[] = []; + + for (const category of categories) { + const files = await fs + .readdir(path.join(commandsDir, category)) + .then((files) => files.filter((file) => file.endsWith(".ts"))); + + for (const file of files) { + promises.push( + import(path.join(commandsDir, category, file)).then( + ({ default: command }: { default: Command }) => { + this.commands.set(command.data.name, { + ...command, + category, + }); + } + ) + ); + } + } + + await Promise.all(promises); + log.info(`Loaded ${pluralize(this.commands.size, "application command")}`); + } + + async registerEvents() { + const eventsDir = path.join(import.meta.dir, "events"); + const files = await fs + .readdir(eventsDir) + .then((files) => files.filter((file) => file !== "index.ts")); + + for (const file of files) { + const { default: event } = await import(path.join(eventsDir, file)); + this[event.once ? "once" : "on"](event.name, event.execute); + } + + log.info(`Registered ${pluralize(files.length, "event")}`); + } + + async syncCommands() { + if (!this.commands.size) { + log.warn("No commands were loaded, skipping registration"); + return; + } + + const publicCommands = this.commands + .filter((command) => !command.isOwnerOnly) + .map((command) => command.data.toJSON()); + const ownerCommands = this.commands + .filter((command) => command.isOwnerOnly) + .map((command) => command.data.toJSON()); + + let guildCount = 0; + let globalCount = 0; + + if (DEV) { + await this.api.applicationCommands + .bulkOverwriteGuildCommands(env.APPLICATION_ID, env.DEV_GUILD_ID, [ + ...publicCommands, + ...ownerCommands, + ]) + .then((res) => (guildCount += res.length)); + } else { + await this.api.applicationCommands + .bulkOverwriteGuildCommands( + env.APPLICATION_ID, + env.DEV_GUILD_ID, + ownerCommands + ) + .then((res) => (guildCount += res.length)); + await this.api.applicationCommands + .bulkOverwriteGlobalCommands(env.APPLICATION_ID, publicCommands) + .then((res) => (globalCount += res.length)); + } + + log.info( + `Successfully synced ${guildCount} guild and ${globalCount} global application commands` + ); + return { guildCount, globalCount }; + } +} + +export const client = new ArtemisClient(); diff --git a/src/commands/index.ts b/src/commands/index.ts new file mode 100644 index 0000000..f912ffa --- /dev/null +++ b/src/commands/index.ts @@ -0,0 +1,5 @@ +import type { Command } from "../types/command"; + +export function defineCommand(command: Command) { + return command; +} diff --git a/src/commands/language/wiktionary.ts b/src/commands/language/wiktionary.ts new file mode 100644 index 0000000..3cb4010 --- /dev/null +++ b/src/commands/language/wiktionary.ts @@ -0,0 +1,131 @@ +import { + bold, + EmbedBuilder, + inlineCode, + SlashCommandBuilder, +} from "discord.js"; +import { defineCommand } from ".."; +import { getDefinitions } from "../../utils/wiktionary"; +import { stripHtml } from "../../utils/functions"; + +export default defineCommand({ + data: new SlashCommandBuilder() + .setName("wiktionary") + .setDescription("Looks up a term on Wiktionary") + .addStringOption((option) => + option + .setName("term") + .setDescription("The term to look up") + .setRequired(true) + .setAutocomplete(true) + ), + + async autocomplete(interaction) { + let language: string | undefined; + let term = interaction.options.getFocused().trim(); + if (term.length < 3) { + await interaction.respond([]); + return; + } + + const parsed = term.split(":"); + if (parsed.length === 2) { + term = parsed[0].trim(); + language = parsed[1].trim(); + if (!language) { + await interaction.respond([]); + return; + } + } + + const definitions = await getDefinitions(term); + if (!definitions) { + await interaction.respond([]); + return; + } + + if (language) { + const choices = definitions + .filter((definition) => + definition.language.toLowerCase().startsWith(language.toLowerCase()) + ) + .map((definition) => ({ + name: `${term} (${definition.language})`, + value: `:${term}:${definition.languageCode}:`, + })) + .slice(0, 25); + + await interaction.respond(choices); + return; + } + + const choices = definitions + .map((definition) => ({ + name: `${term} (${definition.language})`, + value: `:${term}:${definition.languageCode}:`, + })) + .slice(0, 25); + + await interaction.respond(choices); + }, + + async execute(interaction) { + let term = interaction.options.getString("term", true); + let languageCode: string | undefined; + + const parsed = term.match(/^:(?.+):(?.+):$/); + if (parsed?.groups) { + term = parsed.groups.term; + languageCode = parsed.groups.languageCode; + } + + const definitions = await getDefinitions(term); + if (!definitions) { + await interaction.reply({ + content: "No definitions found", + }); + return; + } + + const definition = languageCode + ? definitions.find((def) => def.languageCode === languageCode) + : definitions[0]; + if (!definition) { + await interaction.reply({ + content: "No definitions found", + }); + return; + } + + let description = definition.entries + .map((entry) => { + const name = entry.partOfSpeech; + const definitions = entry.definitions + .filter((def) => def.definition) + .map((def, i) => { + const prefix = inlineCode(`${i + 1}.`); + const definition = stripHtml(def.definition); + return `${prefix} ${definition.trim()}`; + }) + .join("\n"); + + return `${bold(name)}\n${definitions}`; + }) + .join("\n\n"); + + const embed = new EmbedBuilder() + .setAuthor({ + name: `Wiktionary - ${definition.language}`, + iconURL: + "https://en.wiktionary.org/static/apple-touch/wiktionary/en.png", + url: `https://${ + definition.languageCode + }.wiktionary.org/wiki/${encodeURIComponent(term)}`, + }) + .setTitle(term) + .setColor(0xfefefe) + .setDescription(description); + + await interaction.reply({ embeds: [embed] }); + }, +}); diff --git a/src/commands/owner/restart.ts b/src/commands/owner/restart.ts new file mode 100644 index 0000000..4671386 --- /dev/null +++ b/src/commands/owner/restart.ts @@ -0,0 +1,19 @@ +import { MessageFlags, SlashCommandBuilder } from "discord.js"; +import { defineCommand } from ".."; +import { restart } from "../../utils/restart"; + +export default defineCommand({ + data: new SlashCommandBuilder() + .setName("restart") + .setDescription("Restarts the bot"), + isOwnerOnly: true, + + async execute(interaction) { + await interaction.reply({ + content: "Restarting...", + flags: MessageFlags.Ephemeral, + }); + + await restart(interaction.token); + }, +}); diff --git a/src/commands/owner/sync.ts b/src/commands/owner/sync.ts new file mode 100644 index 0000000..3a0c6a1 --- /dev/null +++ b/src/commands/owner/sync.ts @@ -0,0 +1,28 @@ +import { MessageFlags, SlashCommandBuilder } from "discord.js"; +import { client } from "../../client"; +import { defineCommand } from ".."; + +export default defineCommand({ + data: new SlashCommandBuilder() + .setName("sync") + .setDescription("Sync application commands"), + isOwnerOnly: true, + + async execute(interaction) { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + const counts = await client.syncCommands(); + + if (!counts) { + await interaction.followUp({ + content: "No commands to sync", + }); + return; + } + + const { guildCount, globalCount } = counts; + + await interaction.followUp({ + content: `Successfully synced ${guildCount} guild and ${globalCount} global application commands`, + }); + }, +}); diff --git a/src/commands/utility/ping.ts b/src/commands/utility/ping.ts new file mode 100644 index 0000000..e883b52 --- /dev/null +++ b/src/commands/utility/ping.ts @@ -0,0 +1,33 @@ +import { inlineCode, SlashCommandBuilder } from "discord.js"; +import { client } from "../../client"; +import { defineCommand } from ".."; + +export default defineCommand({ + data: new SlashCommandBuilder() + .setName("ping") + .setDescription("Useful latency data"), + + async execute(interaction) { + if (client.ws.ping < 1) { + await interaction.reply( + ":ping_pong: Pong!\nThe bot is still starting up, accurate latency will be available shortly." + ); + return; + } + + const msg = ( + await interaction.reply({ + content: `:ping_pong: Pong!\nWebSocket latency is ${inlineCode( + Math.round(client.ws.ping).toString() + )} ms.`, + withResponse: true, + }) + ).resource?.message!; + + await msg.edit( + `${msg.content}\nAPI roundtrip latency is ${inlineCode( + (msg.createdTimestamp - interaction.createdTimestamp).toString() + )}ms.` + ); + }, +}); diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..4a939de --- /dev/null +++ b/src/env.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +const envSchema = z.object({ + DISCORD_TOKEN: z.string(), + OWNER_ID: z.string(), + APPLICATION_ID: z.string(), + NODE_ENV: z + .enum(["development", "production"]) + .optional() + .default("development"), + DEV_GUILD_ID: z.string(), +}); + +export const env = envSchema.parse(process.env); diff --git a/src/events/clientReady.ts b/src/events/clientReady.ts new file mode 100644 index 0000000..6dc727d --- /dev/null +++ b/src/events/clientReady.ts @@ -0,0 +1,20 @@ +import { env } from "../env"; +import { Events } from "discord.js"; +import { log } from "../utils/logger"; +import { defineEvent } from "."; +import { maybeSendRestarted } from "../utils/restart"; + +export default defineEvent({ + name: Events.ClientReady, + once: true, + + async execute(client) { + log.info("Logged in", { + tag: client.user.tag, + id: client.user.id, + env: env.NODE_ENV, + }); + + await maybeSendRestarted(); + }, +}); diff --git a/src/events/index.ts b/src/events/index.ts new file mode 100644 index 0000000..0eaad4d --- /dev/null +++ b/src/events/index.ts @@ -0,0 +1,6 @@ +import type { ClientEvents } from "discord.js"; +import type { Event } from "../types/event"; + +export function defineEvent(event: Event) { + return event; +} diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts new file mode 100644 index 0000000..6c81a28 --- /dev/null +++ b/src/events/interactionCreate.ts @@ -0,0 +1,81 @@ +import { + AutocompleteInteraction, + ChatInputCommandInteraction, + Events, + MessageFlags, +} from "discord.js"; +import { client } from "../client"; +import { log } from "../utils/logger"; +import { defineEvent } from "."; + +const running = new Map(); +const getRunning = (command: string) => running.get(command) ?? 0; + +export default defineEvent({ + name: Events.InteractionCreate, + + async execute(interaction) { + if (interaction.isChatInputCommand()) { + await handleChatInputCommand(interaction); + } else if (interaction.isAutocomplete()) { + await handleAutocomplete(interaction); + } + }, +}); + +async function handleChatInputCommand( + interaction: ChatInputCommandInteraction +) { + const command = client.commands.get(interaction.commandName); + if (!command) return; + + if (command.isOwnerOnly && interaction.user.id !== client.ownerId) { + await interaction.reply({ + content: "You do not have permission to use this command!", + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (command.maxConcurrency) { + if (getRunning(command.data.name) >= command.maxConcurrency) { + await interaction.reply({ + content: `This command can only be run ${command.maxConcurrency} times at a time.`, + flags: MessageFlags.Ephemeral, + }); + return; + } + running.set(command.data.name, getRunning(command.data.name) + 1); + } + + try { + await command.execute(interaction); + } catch (error) { + log.error(error); + await interaction[ + interaction.replied || interaction.deferred ? "followUp" : "reply" + ]({ + content: "There was an error while executing this command!", + }); + } finally { + if (command.maxConcurrency) { + running.set( + command.data.name, + Math.max(0, getRunning(command.data.name) - 1) + ); + } + } +} + +async function handleAutocomplete(interaction: AutocompleteInteraction) { + const command = client.commands.get(interaction.commandName); + if (!command) return; + + if (command.autocomplete) { + try { + await command.autocomplete(interaction); + } catch (error) { + log.error(error); + } + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..88e28e9 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,5 @@ +import { env } from "./env"; +import { client } from "./client"; + +await client.setup(); +client.login(env.DISCORD_TOKEN); diff --git a/src/scripts/sync.ts b/src/scripts/sync.ts new file mode 100644 index 0000000..1b73fea --- /dev/null +++ b/src/scripts/sync.ts @@ -0,0 +1,9 @@ +import { client } from "../client"; +import { log } from "../utils/logger"; + +async function main() { + await client.loadCommands(); + await client.syncCommands(); +} + +main().catch(log.error); diff --git a/src/scripts/test.ts b/src/scripts/test.ts new file mode 100644 index 0000000..69abd8e --- /dev/null +++ b/src/scripts/test.ts @@ -0,0 +1,7 @@ +import { getDefinitions } from "../utils/wiktionary"; + +const definitions = await getDefinitions("appl"); + +if (!definitions) process.exit(1); + +console.dir(definitions, { depth: null }); diff --git a/src/types/command.ts b/src/types/command.ts new file mode 100644 index 0000000..be5650c --- /dev/null +++ b/src/types/command.ts @@ -0,0 +1,15 @@ +import type { + AutocompleteInteraction, + ChatInputCommandInteraction, + SlashCommandBuilder, + SlashCommandOptionsOnlyBuilder, +} from "discord.js"; + +export interface Command { + data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder; + execute(interaction: ChatInputCommandInteraction): Promise; + autocomplete?(interaction: AutocompleteInteraction): Promise; + category?: string; + maxConcurrency?: number; + isOwnerOnly?: boolean; +} diff --git a/src/types/event.ts b/src/types/event.ts new file mode 100644 index 0000000..c0d454e --- /dev/null +++ b/src/types/event.ts @@ -0,0 +1,7 @@ +import type { ClientEvents } from "discord.js"; + +export interface Event { + name: Event; + once?: boolean; + execute(...args: ClientEvents[Event]): Promise; +} diff --git a/src/utils/components.ts b/src/utils/components.ts new file mode 100644 index 0000000..2888845 --- /dev/null +++ b/src/utils/components.ts @@ -0,0 +1,48 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ChatInputCommandInteraction, + ComponentType, +} from "discord.js"; + +export async function confirmPrompt( + interaction: ChatInputCommandInteraction, + message: string +) { + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("confirm") + .setLabel("Confirm") + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId("cancel") + .setLabel("Cancel") + .setStyle(ButtonStyle.Danger) + ); + + const msg = ( + await interaction.reply({ + content: message, + components: [row], + withResponse: true, + }) + ).resource?.message!; + + const confirmation = await msg + .awaitMessageComponent({ + componentType: ComponentType.Button, + time: 60000, + filter: (i) => i.user.id === interaction.user.id, + dispose: true, + }) + .catch(() => { + interaction.editReply({ + content: "You took too long to respond.", + components: [], + }); + }); + + msg.delete(); + return confirmation?.customId === "confirm"; +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts new file mode 100644 index 0000000..56ca338 --- /dev/null +++ b/src/utils/constants.ts @@ -0,0 +1,4 @@ +import { env } from "../env"; + +export const DEV = env.NODE_ENV === "development"; +export const PROD = env.NODE_ENV === "production"; diff --git a/src/utils/functions.ts b/src/utils/functions.ts new file mode 100644 index 0000000..ec70628 --- /dev/null +++ b/src/utils/functions.ts @@ -0,0 +1,41 @@ +import * as cheerio from "cheerio"; + +export function noop() {} + +export function pickRandom(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} + +export function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function chunk(arr: T[], size: number): T[][]; +export function chunk(arr: string, size: number): string[]; +export function chunk(arr: any, size: number): any[] { + return Array.from({ length: Math.ceil(arr.length / size) }, (_, i) => + arr.slice(i * size, i * size + size) + ); +} + +export function stripHtml(html: string) { + return cheerio.load(html).text(); +} + +export function pluralize( + value: number, + singular: string, + plural = singular + "s" +) { + return value === 1 ? `${value} ${singular}` : `${value} ${plural}`; +} + +export function run(fn: () => T): T { + return fn(); +} + +export async function silently>(p?: T) { + try { + return await p; + } catch {} +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..3bac750 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,11 @@ +import { createLogger, format, transports } from "winston"; +import { DEV } from "./constants"; +const { combine, simple, errors, prettyPrint } = format; + +export const log = createLogger({ + level: "info", + format: DEV + ? combine(errors({ stack: true }), prettyPrint({ colorize: true })) + : combine(errors({ stack: true }), simple()), + transports: [new transports.Console()], +}); diff --git a/src/utils/restart.ts b/src/utils/restart.ts new file mode 100644 index 0000000..bf1d0e5 --- /dev/null +++ b/src/utils/restart.ts @@ -0,0 +1,34 @@ +import { Client, InteractionWebhook, MessageFlags } from "discord.js"; +import { silently } from "./functions"; +import { client } from "../client"; +import { env } from "../env"; +import { readFile, rm, writeFile } from "node:fs/promises"; +import { log } from "./logger"; + +export async function restart(state: string) { + log.info("Shutting down..."); + + await writeFile("./data/temp/restart", state); + + await client.destroy(); + process.exit(0); +} + +export async function maybeSendRestarted() { + const restartToken = await silently(readFile("./data/temp/restart", "utf-8")); + + if (restartToken) { + const webhook = new InteractionWebhook( + client as Client, + env.APPLICATION_ID, + restartToken + ); + + await webhook.send({ + content: "Successfully restarted!", + flags: MessageFlags.Ephemeral, + }); + + await silently(rm("./data/temp/restart")); + } +} diff --git a/src/utils/wiktionary.ts b/src/utils/wiktionary.ts new file mode 100644 index 0000000..5920469 --- /dev/null +++ b/src/utils/wiktionary.ts @@ -0,0 +1,37 @@ +import ky from "ky"; + +type ParsedExample = { + example: string; +}; + +type Definition = { + definition: string; + parsedExamples?: ParsedExample[]; +}; + +type Entry = { + partOfSpeech: string; + language: string; + definitions: Definition[]; +}; + +type DefinitionsResponse = { + [key: string]: Entry[]; +}; + +const client = ky.create({ + prefixUrl: "https://en.wiktionary.org/api/rest_v1", + throwHttpErrors: false, +}); + +export async function getDefinitions(word: string) { + const res = await client.get("page/definition/" + encodeURIComponent(word)); + const data = await res.json(); + if (!res.ok || !data) return null; + + return Object.entries(data).map(([languageCode, entries]) => ({ + language: entries[0].language, + languageCode, + entries, + })); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..39736b6 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "allowJs": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true + } +}