diff --git a/.gitignore b/.gitignore index e5cb576d..bd161a0d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ dist/ +configs/ -overrideConfig.json +.env diff --git a/README.md b/README.md index c40d9357..c1889707 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,43 @@ -# Needle -Needle is a [Discord](https://discord.com/) bot that helps you manage your [Discord threads](https://support.discord.com/hc/en-us/articles/4403205878423-Threads-FAQ). -## Features -- Automatically create new threads for every message in certain channels -- Let thread owners close the automatically created threads with `/close` -- Let thread owners change the title of the automatically created thread with `/title` -- More to come :wink: Check out open [issues](https://github.com/MarcusOtter/discord-needle/issues)! -## Running the bot +
+

+ Needle + + + +

+ + Needle is a Discord bot that helps you manage your Discord threads 🪡 +

+ + + + + + +
Website ✨Invite Needle 🪡Get support 💬
+
+ +## Self-hosting 1. Clone the repository -2. Edit the `src/config.json` with API token and the IDs of the channels you want to thread every message in: - ```json - { - "discordApiToken": "INSERT TOKEN", - "threadChannels": [ - "CHANNEL ID 1", - "CHANNEL ID 2", - ] - } - ``` +2. Create a file named `.env` in the root directory and insert your Discord API token: + ```bash + DISCORD_API_TOKEN=abcd1234... + ``` 3. Run `npm install` -4. Make sure the bot has the required permissions in Discord. They are: - - `USE_PUBLIC_THREADS` - - `SEND_MESSAGES_IN_THREADS` - - `READ_MESSAGE_HISTORY` +4. Make sure the bot has the required permissions in Discord: + - [x] View channels + - [x] Send messages + - [x] Send messages in threads + - [x] Create public threads + - [x] Read message history 5. Run `npm start` -6. Done! :tada: +6. Deploy! :tada: + +## Contributing +Coming soon :tm: + +[Join the Discord](https://needle.gg/chat) if interested! diff --git a/branding/logo-128x128.png b/branding/logo-128x128.png new file mode 100644 index 00000000..1c192207 Binary files /dev/null and b/branding/logo-128x128.png differ diff --git a/branding/logo-64x64.png b/branding/logo-64x64.png new file mode 100644 index 00000000..e82e4c8b Binary files /dev/null and b/branding/logo-64x64.png differ diff --git a/branding/logo-full.PNG b/branding/logo-full.PNG new file mode 100644 index 00000000..c19d634d Binary files /dev/null and b/branding/logo-full.PNG differ diff --git a/package-lock.json b/package-lock.json index 9afef35a..7cc7b512 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,11 @@ "version": "1.0.0", "license": "GPL-3.0", "dependencies": { - "@discordjs/builders": "^0.6.0", + "@discordjs/builders": "^0.8.2", "@discordjs/rest": "^0.1.0-canary.0", - "discord-api-types": "^0.23.1", - "discord.js": "^13.1.0" + "discord-api-types": "^0.24.0", + "discord.js": "^13.3.1", + "dotenv": "^10.0.0" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^4.29.2", @@ -129,35 +130,28 @@ } }, "node_modules/@discordjs/builders": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-0.6.0.tgz", - "integrity": "sha512-mH3Gx61LKk2CD05laCI9K5wp+a3NyASHDUGx83DGJFkqJlRlSV5WMJNY6RS37A5SjqDtGMF4wVR9jzFaqShe6Q==", + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-0.8.2.tgz", + "integrity": "sha512-/YRd11SrcluqXkKppq/FAVzLIPRVlIVmc6X8ZklspzMIHDtJ+A4W37D43SHvLdH//+NnK+SHW/WeOF4Ts54PeQ==", "dependencies": { - "@sindresorhus/is": "^4.0.1", - "discord-api-types": "^0.22.0", + "@sindresorhus/is": "^4.2.0", + "discord-api-types": "^0.24.0", "ow": "^0.27.0", "ts-mixer": "^6.0.0", "tslib": "^2.3.1" }, "engines": { - "node": ">=14.0.0", + "node": ">=16.0.0", "npm": ">=7.0.0" } }, - "node_modules/@discordjs/builders/node_modules/discord-api-types": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.22.0.tgz", - "integrity": "sha512-l8yD/2zRbZItUQpy7ZxBJwaLX/Bs2TGaCthRppk8Sw24LOIWg12t9JEreezPoYD0SQcC2htNNo27kYEpYW/Srg==", - "engines": { - "node": ">=12" - } - }, "node_modules/@discordjs/collection": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-0.2.1.tgz", - "integrity": "sha512-vhxqzzM8gkomw0TYRF3tgx7SwElzUlXT/Aa41O7mOcyN6wIJfj5JmDWaO5XGKsGSsNx7F3i5oIlrucCCWV1Nog==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-0.3.2.tgz", + "integrity": "sha512-dMjLl60b2DMqObbH1MQZKePgWhsNe49XkKBZ0W5Acl5uVV43SN414i2QfZwRI7dXAqIn8pEWD2+XXQFn9KWxqg==", "engines": { - "node": ">=14.0.0" + "node": ">=16.0.0", + "npm": ">=7.0.0" } }, "node_modules/@discordjs/form-data": { @@ -281,12 +275,12 @@ } }, "node_modules/@sapphire/async-queue": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.1.4.tgz", - "integrity": "sha512-fFrlF/uWpGOX5djw5Mu2Hnnrunao75WGey0sP0J3jnhmrJ5TAPzHYOmytD5iN/+pMxS+f+u/gezqHa9tPhRHEA==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.1.9.tgz", + "integrity": "sha512-CbXaGwwlEMq+l1TRu01FJCvySJ1CEFKFclHT48nIfNeZXaAAmmwwy7scUKmYHPUa3GhoMp6Qr1B3eAJux6XgOQ==", "engines": { - "node": ">=14", - "npm": ">=6" + "node": ">=v14.0.0", + "npm": ">=7.0.0" } }, "node_modules/@sapphire/snowflake": { @@ -300,9 +294,9 @@ } }, "node_modules/@sindresorhus/is": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.0.1.tgz", - "integrity": "sha512-Qm9hBEBu18wt1PO2flE7LPb30BHMQt1eQgbV76YntdNk73XZGpn3izvGTYxbGgzXKgbCjiia0uxTd3aTNQrY/g==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.2.0.tgz", + "integrity": "sha512-VkE3KLBmJwcCaVARtQpfuKcKv8gcBmUubrfHGF84dXuuW6jgsRYxPtzcIhPyK9WAPpRt2/xY6zkD9MnRaJzSyw==", "engines": { "node": ">=10" }, @@ -317,14 +311,36 @@ "dev": true }, "node_modules/@types/node": { - "version": "16.7.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.7.0.tgz", - "integrity": "sha512-e66BrnjWQ3BRBZ2+iA5e85fcH9GLNe4S0n1H0T3OalK2sXg5XWEFTO4xvmGrYQ3edy+q6fdOh5t0/HOY8OAqBg==" + "version": "16.11.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.6.tgz", + "integrity": "sha512-ua7PgUoeQFjmWPcoo9khiPum3Pd60k4/2ZGXt18sm2Slk0W0xZTqt5Y0Ny1NyBiN1EVQ/+FaF9NcY4Qe6rwk5w==" + }, + "node_modules/@types/node-fetch": { + "version": "2.5.12", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.12.tgz", + "integrity": "sha512-MKgC4dlq4kKNa/mYrwpKfzQMB5X3ee5U6fSprkKpToBqBmX4nFZL9cW5jl6sWn+xpRJ7ypWh2yyqqr8UUCstSw==", + "dependencies": { + "@types/node": "*", + "form-data": "^3.0.0" + } + }, + "node_modules/@types/node-fetch/node_modules/form-data": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", + "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } }, "node_modules/@types/ws": { - "version": "7.4.7", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz", - "integrity": "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.2.0.tgz", + "integrity": "sha512-cyeefcUCgJlEk+hk2h3N+MqKKsPViQgF5boi9TTHSK+PoR9KWBb/C5ccPcDyAqgsbAYHTwulch725DV84+pSpg==", "dependencies": { "@types/node": "*" } @@ -561,9 +577,9 @@ } }, "node_modules/ansi-regex": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "engines": { "node": ">=8" @@ -761,56 +777,33 @@ } }, "node_modules/discord-api-types": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.23.1.tgz", - "integrity": "sha512-igWmn+45mzXRWNEPU25I/pr8MwxHb767wAr51oy3VRLRcTlp5ADBbrBR0lq3SA1Rfw3MtM4TQu1xo3kxscfVdQ==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.24.0.tgz", + "integrity": "sha512-X0uA2a92cRjowUEXpLZIHWl4jiX1NsUpDhcEOpa1/hpO1vkaokgZ8kkPtPih9hHth5UVQ3mHBu/PpB4qjyfJ4A==", "engines": { "node": ">=12" } }, "node_modules/discord.js": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-13.1.0.tgz", - "integrity": "sha512-gxO4CXKdHpqA+WKG+f5RNnd3srTDj5uFJHgOathksDE90YNq/Qijkd2WlMgTTMS6AJoEnHxI7G9eDQHCuZ+xDA==", + "version": "13.3.1", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-13.3.1.tgz", + "integrity": "sha512-zn4G8tL5+tMV00+0aSsVYNYcIfMSdT2g0nudKny+Ikd+XKv7m6bqI7n3Vji0GIRqXDr5ArPaw+iYFM2I1Iw3vg==", "dependencies": { - "@discordjs/builders": "^0.5.0", - "@discordjs/collection": "^0.2.1", + "@discordjs/builders": "^0.8.1", + "@discordjs/collection": "^0.3.2", "@discordjs/form-data": "^3.0.1", - "@sapphire/async-queue": "^1.1.4", - "@types/ws": "^7.4.7", - "discord-api-types": "^0.22.0", + "@sapphire/async-queue": "^1.1.8", + "@types/node-fetch": "^2.5.12", + "@types/ws": "^8.2.0", + "discord-api-types": "^0.24.0", "node-fetch": "^2.6.1", - "ws": "^7.5.1" + "ws": "^8.2.3" }, "engines": { "node": ">=16.6.0", "npm": ">=7.0.0" } }, - "node_modules/discord.js/node_modules/@discordjs/builders": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-0.5.0.tgz", - "integrity": "sha512-HP5y4Rqw68o61Qv4qM5tVmDbWi4mdTFftqIOGRo33SNPpLJ1Ga3KEIR2ibKofkmsoQhEpLmopD1AZDs3cKpHuw==", - "dependencies": { - "@sindresorhus/is": "^4.0.1", - "discord-api-types": "^0.22.0", - "ow": "^0.27.0", - "ts-mixer": "^6.0.0", - "tslib": "^2.3.0" - }, - "engines": { - "node": ">=14.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/discord.js/node_modules/discord-api-types": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.22.0.tgz", - "integrity": "sha512-l8yD/2zRbZItUQpy7ZxBJwaLX/Bs2TGaCthRppk8Sw24LOIWg12t9JEreezPoYD0SQcC2htNNo27kYEpYW/Srg==", - "engines": { - "node": ">=12" - } - }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -837,6 +830,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/dotenv": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", + "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", + "engines": { + "node": ">=10" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -2054,11 +2055,11 @@ "dev": true }, "node_modules/ws": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.3.tgz", - "integrity": "sha512-kQ/dHIzuLrS6Je9+uv81ueZomEwH0qVYstcAQ4/Z93K8zeko9gtAbttJWzoC5ukqXY1PpoouV3+VSOqEAFt5wg==", + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", + "integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==", "engines": { - "node": ">=8.3.0" + "node": ">=10.0.0" }, "peerDependencies": { "bufferutil": "^4.0.1", @@ -2166,28 +2167,21 @@ } }, "@discordjs/builders": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-0.6.0.tgz", - "integrity": "sha512-mH3Gx61LKk2CD05laCI9K5wp+a3NyASHDUGx83DGJFkqJlRlSV5WMJNY6RS37A5SjqDtGMF4wVR9jzFaqShe6Q==", + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-0.8.2.tgz", + "integrity": "sha512-/YRd11SrcluqXkKppq/FAVzLIPRVlIVmc6X8ZklspzMIHDtJ+A4W37D43SHvLdH//+NnK+SHW/WeOF4Ts54PeQ==", "requires": { - "@sindresorhus/is": "^4.0.1", - "discord-api-types": "^0.22.0", + "@sindresorhus/is": "^4.2.0", + "discord-api-types": "^0.24.0", "ow": "^0.27.0", "ts-mixer": "^6.0.0", "tslib": "^2.3.1" - }, - "dependencies": { - "discord-api-types": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.22.0.tgz", - "integrity": "sha512-l8yD/2zRbZItUQpy7ZxBJwaLX/Bs2TGaCthRppk8Sw24LOIWg12t9JEreezPoYD0SQcC2htNNo27kYEpYW/Srg==" - } } }, "@discordjs/collection": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-0.2.1.tgz", - "integrity": "sha512-vhxqzzM8gkomw0TYRF3tgx7SwElzUlXT/Aa41O7mOcyN6wIJfj5JmDWaO5XGKsGSsNx7F3i5oIlrucCCWV1Nog==" + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-0.3.2.tgz", + "integrity": "sha512-dMjLl60b2DMqObbH1MQZKePgWhsNe49XkKBZ0W5Acl5uVV43SN414i2QfZwRI7dXAqIn8pEWD2+XXQFn9KWxqg==" }, "@discordjs/form-data": { "version": "3.0.1", @@ -2287,9 +2281,9 @@ } }, "@sapphire/async-queue": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.1.4.tgz", - "integrity": "sha512-fFrlF/uWpGOX5djw5Mu2Hnnrunao75WGey0sP0J3jnhmrJ5TAPzHYOmytD5iN/+pMxS+f+u/gezqHa9tPhRHEA==" + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.1.9.tgz", + "integrity": "sha512-CbXaGwwlEMq+l1TRu01FJCvySJ1CEFKFclHT48nIfNeZXaAAmmwwy7scUKmYHPUa3GhoMp6Qr1B3eAJux6XgOQ==" }, "@sapphire/snowflake": { "version": "1.3.6", @@ -2297,9 +2291,9 @@ "integrity": "sha512-QnzuLp+p9D7agynVub/zqlDVriDza9y3STArBhNiNBUgIX8+GL5FpQxstRfw1jDr5jkZUjcuKYAHxjIuXKdJAg==" }, "@sindresorhus/is": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.0.1.tgz", - "integrity": "sha512-Qm9hBEBu18wt1PO2flE7LPb30BHMQt1eQgbV76YntdNk73XZGpn3izvGTYxbGgzXKgbCjiia0uxTd3aTNQrY/g==" + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.2.0.tgz", + "integrity": "sha512-VkE3KLBmJwcCaVARtQpfuKcKv8gcBmUubrfHGF84dXuuW6jgsRYxPtzcIhPyK9WAPpRt2/xY6zkD9MnRaJzSyw==" }, "@types/json-schema": { "version": "7.0.9", @@ -2308,14 +2302,35 @@ "dev": true }, "@types/node": { - "version": "16.7.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.7.0.tgz", - "integrity": "sha512-e66BrnjWQ3BRBZ2+iA5e85fcH9GLNe4S0n1H0T3OalK2sXg5XWEFTO4xvmGrYQ3edy+q6fdOh5t0/HOY8OAqBg==" + "version": "16.11.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.6.tgz", + "integrity": "sha512-ua7PgUoeQFjmWPcoo9khiPum3Pd60k4/2ZGXt18sm2Slk0W0xZTqt5Y0Ny1NyBiN1EVQ/+FaF9NcY4Qe6rwk5w==" + }, + "@types/node-fetch": { + "version": "2.5.12", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.12.tgz", + "integrity": "sha512-MKgC4dlq4kKNa/mYrwpKfzQMB5X3ee5U6fSprkKpToBqBmX4nFZL9cW5jl6sWn+xpRJ7ypWh2yyqqr8UUCstSw==", + "requires": { + "@types/node": "*", + "form-data": "^3.0.0" + }, + "dependencies": { + "form-data": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", + "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + } + } }, "@types/ws": { - "version": "7.4.7", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz", - "integrity": "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.2.0.tgz", + "integrity": "sha512-cyeefcUCgJlEk+hk2h3N+MqKKsPViQgF5boi9TTHSK+PoR9KWBb/C5ccPcDyAqgsbAYHTwulch725DV84+pSpg==", "requires": { "@types/node": "*" } @@ -2453,9 +2468,9 @@ "dev": true }, "ansi-regex": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true }, "ansi-styles": { @@ -2603,42 +2618,24 @@ } }, "discord-api-types": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.23.1.tgz", - "integrity": "sha512-igWmn+45mzXRWNEPU25I/pr8MwxHb767wAr51oy3VRLRcTlp5ADBbrBR0lq3SA1Rfw3MtM4TQu1xo3kxscfVdQ==" + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.24.0.tgz", + "integrity": "sha512-X0uA2a92cRjowUEXpLZIHWl4jiX1NsUpDhcEOpa1/hpO1vkaokgZ8kkPtPih9hHth5UVQ3mHBu/PpB4qjyfJ4A==" }, "discord.js": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-13.1.0.tgz", - "integrity": "sha512-gxO4CXKdHpqA+WKG+f5RNnd3srTDj5uFJHgOathksDE90YNq/Qijkd2WlMgTTMS6AJoEnHxI7G9eDQHCuZ+xDA==", + "version": "13.3.1", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-13.3.1.tgz", + "integrity": "sha512-zn4G8tL5+tMV00+0aSsVYNYcIfMSdT2g0nudKny+Ikd+XKv7m6bqI7n3Vji0GIRqXDr5ArPaw+iYFM2I1Iw3vg==", "requires": { - "@discordjs/builders": "^0.5.0", - "@discordjs/collection": "^0.2.1", + "@discordjs/builders": "^0.8.1", + "@discordjs/collection": "^0.3.2", "@discordjs/form-data": "^3.0.1", - "@sapphire/async-queue": "^1.1.4", - "@types/ws": "^7.4.7", - "discord-api-types": "^0.22.0", + "@sapphire/async-queue": "^1.1.8", + "@types/node-fetch": "^2.5.12", + "@types/ws": "^8.2.0", + "discord-api-types": "^0.24.0", "node-fetch": "^2.6.1", - "ws": "^7.5.1" - }, - "dependencies": { - "@discordjs/builders": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-0.5.0.tgz", - "integrity": "sha512-HP5y4Rqw68o61Qv4qM5tVmDbWi4mdTFftqIOGRo33SNPpLJ1Ga3KEIR2ibKofkmsoQhEpLmopD1AZDs3cKpHuw==", - "requires": { - "@sindresorhus/is": "^4.0.1", - "discord-api-types": "^0.22.0", - "ow": "^0.27.0", - "ts-mixer": "^6.0.0", - "tslib": "^2.3.0" - } - }, - "discord-api-types": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.22.0.tgz", - "integrity": "sha512-l8yD/2zRbZItUQpy7ZxBJwaLX/Bs2TGaCthRppk8Sw24LOIWg12t9JEreezPoYD0SQcC2htNNo27kYEpYW/Srg==" - } + "ws": "^8.2.3" } }, "doctrine": { @@ -2658,6 +2655,11 @@ "is-obj": "^2.0.0" } }, + "dotenv": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", + "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==" + }, "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -3556,9 +3558,9 @@ "dev": true }, "ws": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.3.tgz", - "integrity": "sha512-kQ/dHIzuLrS6Je9+uv81ueZomEwH0qVYstcAQ4/Z93K8zeko9gtAbttJWzoC5ukqXY1PpoouV3+VSOqEAFt5wg==", + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", + "integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==", "requires": {} }, "yallist": { diff --git a/package.json b/package.json index 29de236d..e297bc11 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,11 @@ "main": "src/index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "build": "rd /s /q dist & tsc && node ./scripts/deploy-commands.js", - "prestart": "npm run build", - "start": "node ./dist/index.js" + "build": "rd /s /q dist & tsc", + "start": "npm run build && node ./dist/index.js", + "dev": "npm run build && node ./scripts/deploy-commands.js && node ./dist/index.js", + "undeploy": "npm run build && node ./scripts/deploy-commands.js --undeploy", + "deploy": "npm run undeploy && node ./scripts/deploy-commands.js --global" }, "repository": { "type": "git", @@ -20,10 +22,11 @@ }, "homepage": "https://github.com/MarcusOtter/discord-needle", "dependencies": { - "@discordjs/builders": "^0.6.0", + "@discordjs/builders": "^0.8.2", "@discordjs/rest": "^0.1.0-canary.0", - "discord-api-types": "^0.23.1", - "discord.js": "^13.1.0" + "discord-api-types": "^0.24.0", + "discord.js": "^13.3.1", + "dotenv": "^10.0.0" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^4.29.2", diff --git a/scripts/deploy-commands.js b/scripts/deploy-commands.js index a093d9c3..eee28ee5 100644 --- a/scripts/deploy-commands.js +++ b/scripts/deploy-commands.js @@ -1,40 +1,57 @@ /* eslint-disable @typescript-eslint/no-var-requires */ // IMPORTANT: You need to `tsc` before running this script. -// TODO: Make this a separate script when commands are more stable (don't try to run it on npm start) +require("dotenv").config(); const { REST } = require("@discordjs/rest"); const { Routes } = require("discord-api-types/v9"); const { getOrLoadAllCommands } = require("../dist/handlers/commandHandler"); -const { getConfig } = require("../dist/helpers/configHelpers"); +const { getApiToken, getGuildId, getClientId } = require("../dist/helpers/configHelpers"); -const CONFIG = getConfig(); -if (!CONFIG) { return; } -if (!CONFIG.dev) { return; } -if (!CONFIG.dev.clientId || CONFIG.dev.clientId === "") { return; } -if (!CONFIG.dev.guildId || CONFIG.dev.guildId === "") { return; } +const API_TOKEN = getApiToken(); +const CLIENT_ID = getClientId(); +const GUILD_ID = getGuildId(); -const rest = new REST({ version: "9" }).setToken(CONFIG.discordApiToken); +if (!API_TOKEN || !CLIENT_ID || !GUILD_ID) { + console.log("API_TOKEN, CLIENT_ID, or GUILD_ID was missing from the .env file: aborting command deployment"); + console.log("Hint: If you just want to start the bot without developing commands, type \"npm start\" instead\n"); + return; +} +const route = process.argv.some(x => x === "--global") + ? Routes.applicationCommands(CLIENT_ID) + : Routes.applicationGuildCommands(CLIENT_ID, GUILD_ID); + +const rest = new REST({ version: "9" }).setToken(API_TOKEN); (async () => { - const allNeedleCommands = await getOrLoadAllCommands(); - const allSlashCommandBuilders = []; - for (const command of allNeedleCommands) { - const builder = await command.getSlashCommandBuilder(); - allSlashCommandBuilders.push(builder); - } + const builders = await getSlashCommandBuilders(); try { - console.log(`Started deploying ${allSlashCommandBuilders.length} application commands.`); + console.log(`Started deploying ${builders.length} application commands.`); await rest.put( - Routes.applicationGuildCommands(CONFIG.dev.clientId, CONFIG.dev.guildId), - { body: allSlashCommandBuilders }, + route, + { body: builders }, ); - console.log("Successfully deployed application commands."); + console.log("Successfully deployed application commands.\n"); } catch (error) { console.error(error); } })(); +async function getSlashCommandBuilders() { + if (process.argv.some(x => x === "--undeploy")) { + console.log("Undeploying guild commands"); + return []; + } + + const allNeedleCommands = await getOrLoadAllCommands(); + const allSlashCommandBuilders = []; + for (const command of allNeedleCommands) { + const builder = await command.getSlashCommandBuilder(); + allSlashCommandBuilders.push(builder); + } + + return allSlashCommandBuilders; +} diff --git a/src/commands/close.ts b/src/commands/close.ts index 1ab3b25c..c063ab4b 100644 --- a/src/commands/close.ts +++ b/src/commands/close.ts @@ -1,12 +1,12 @@ import { SlashCommandBuilder } from "@discordjs/builders"; import { CommandInteraction, GuildMember, MessageComponentInteraction, Permissions } from "discord.js"; -import { ephemeralReply, getThreadStartMessage } from "../helpers/messageHelpers"; +import { interactionReply, getThreadStartMessage, getMessage } from "../helpers/messageHelpers"; import { NeedleCommand } from "../types/needleCommand"; export const command: NeedleCommand = { name: "close", shortHelpDescription: "Closes a thread by setting the auto-archive duration to 1 hour", - longHelpDescription: "The close command lets thread owners set the auto-archive duration to 1 hour.\n\nWhen using auto-archive, the thread will automatically be archived when there have been no new messages in the thread for one hour. This can be undone by a server moderator by manually changing the auto-archive duration back to what it was previously, using Discord's own interface.", + longHelpDescription: "The close command sets the auto-archive duration to 1 hour in a thread.\n\nWhen using auto-archive, the thread will automatically be archived when there have been no new messages in the thread for one hour. This can be undone by a server moderator by manually changing the auto-archive duration back to what it was previously, using Discord's own interface.", async getSlashCommandBuilder() { return new SlashCommandBuilder() @@ -18,29 +18,35 @@ export const command: NeedleCommand = { async execute(interaction: CommandInteraction | MessageComponentInteraction): Promise { const member = interaction.member; if (!(member instanceof GuildMember)) { - return ephemeralReply(interaction, "An unexpected error occurred."); + return interactionReply(interaction, getMessage("ERR_UNKNOWN")); } const channel = interaction.channel; if (!channel?.isThread()) { - return ephemeralReply(interaction, "You can only use this command inside a thread."); + return interactionReply(interaction, getMessage("ERR_ONLY_IN_THREAD")); } - const parentMessage = await getThreadStartMessage(channel); - if (!parentMessage) { - return ephemeralReply(interaction, "Could not find the start message of this thread."); + if (channel.autoArchiveDuration === 60) { + return interactionReply(interaction, getMessage("ERR_NO_EFFECT")); } const hasChangeTitlePermissions = member.permissionsIn(channel).has(Permissions.FLAGS.MANAGE_THREADS, true); - if (!hasChangeTitlePermissions && parentMessage.author !== interaction.user) { - return ephemeralReply(interaction, "You need to be the thread owner to close the thread."); + if (hasChangeTitlePermissions) { + await channel.setAutoArchiveDuration(60); + await interactionReply(interaction, getMessage("SUCCESS_THREAD_ARCHIVE"), false); + return; } - if (channel.autoArchiveDuration === 60) { - return ephemeralReply(interaction, "This server already has the auto-archive duration set to one hour."); + const parentMessage = await getThreadStartMessage(channel); + if (!parentMessage) { + return interactionReply(interaction, getMessage("ERR_THREAD_MESSAGE_MISSING")); + } + + if (parentMessage.author !== interaction.user) { + return interactionReply(interaction, getMessage("ERR_ONLY_THREAD_OWNER")); } await channel.setAutoArchiveDuration(60); - await interaction.reply(`**This thread will be archived soon** :card_box:\n\nAs requested by <@${member.user.id}>, this thread will automatically be archived when one hour passes without any new messages.\n\nThe thread's content will still be searchable with Discord's search function, and anyone will be able to un-archive it at any point in the future by simply sending a message in the thread again.\n\nA server moderator can undo this action by manually setting the auto-archive duration back to what it was previously.`); + await interactionReply(interaction, getMessage("SUCCESS_THREAD_ARCHIVE"), false); }, }; diff --git a/src/commands/configure.ts b/src/commands/configure.ts new file mode 100644 index 00000000..a7261e73 --- /dev/null +++ b/src/commands/configure.ts @@ -0,0 +1,160 @@ +import { SlashCommandBuilder } from "@discordjs/builders"; +import { ChannelType } from "discord-api-types"; +import { CommandInteraction, GuildMember, GuildTextBasedChannel, Permissions } from "discord.js"; +import { disableAutothreading, enableAutothreading, getConfig, resetConfigToDefault, setMessage } from "../helpers/configHelpers"; +import { interactionReply, getMessage, MessageKey, isAutoThreadChannel, addMessageContext } from "../helpers/messageHelpers"; +import { NeedleCommand } from "../types/needleCommand"; +import { memberIsModerator } from "../helpers/permissionHelpers"; + +// Note: +// The important messages of these commands should not be configurable +// (prevents user made soft-locks where it's hard to figure out how to fix it) + +export const command: NeedleCommand = { + name: "configure", + shortHelpDescription: "Modify the configuration of Needle", + + async getSlashCommandBuilder() { + return new SlashCommandBuilder() + .setName("configure") + .setDescription("Modify the configuration of Needle") + .addSubcommand(subcommand => { + return subcommand + .setName("message") + .setDescription("Modify the content of a message that Needle replies with when a certain action happens") + .addStringOption(option => { + const opt = option + .setName("key") + .setDescription("The key of the message") + .setRequired(true); + + for(const messageKey of Object.keys(getConfig().messages ?? [])) { + opt.addChoice(messageKey, messageKey); + } + + return opt; + }) + .addStringOption(option => { + return option + .setName("value") + .setDescription("The new message for the selected key (shows the current value of this message key if left blank)") + .setRequired(false); + }); + }) + .addSubcommand(subcommand => { + return subcommand + .setName("default") + .setDescription("Reset the server's custom Needle configuration to the default"); + }) + .addSubcommand(subcommand => { + return subcommand + .setName("autothreading") + .setDescription("Enable or disable automatic creation of threads on every new message in a channel") + .addChannelOption(option => { + return option + .setName("channel") + .setDescription("The channel to enable/disable automatic threading in") + .addChannelType(ChannelType.GuildText) + .addChannelType(ChannelType.GuildNews) + .setRequired(true); + }) + .addBooleanOption(option => { + return option + .setName("enabled") + .setDescription("Whether or not threads should be automatically created from new messages in the selected channel") + .setRequired(true); + }) + .addStringOption(option => { + return option + .setName("custom-message") + .setDescription("The message to send when a thread is created (uses the message SUCCESS_THREAD_CREATE if left blank)") + .setRequired(false); + }); + }) + .toJSON(); + }, + + async execute(interaction: CommandInteraction): Promise { + if (!interaction.guildId || !interaction.guild) { + return interactionReply(interaction, getMessage("ERR_ONLY_IN_SERVER")); + } + + if (!memberIsModerator(interaction.member as GuildMember)) { + return interactionReply(interaction, getMessage("ERR_INSUFFICIENT_PERMS")); + } + + if (interaction.options.getSubcommand() === "default") { + const success = resetConfigToDefault(interaction.guild.id); + return interactionReply(interaction, success + ? "Successfully reset the Needle configuration to the default." + : getMessage("ERR_NO_EFFECT"), !success); + } + + if (interaction.options.getSubcommand() === "message") { + return configureMessage(interaction); + } + + if (interaction.options.getSubcommand() === "autothreading") { + return configureAutothreading(interaction); + } + + return interactionReply(interaction, getMessage("ERR_UNKNOWN")); + }, +}; + +function configureMessage(interaction: CommandInteraction): Promise { + const key = interaction.options.getString("key") as MessageKey; + const value = interaction.options.getString("value"); + + if (!interaction.guild) { + return interactionReply(interaction, getMessage("ERR_ONLY_IN_SERVER")); + } + + if (!value || value.length === 0) { + return interactionReply(interaction, `**${key}** message:\n\n>>> ${getMessage(key, false)}`); + } + + const oldValue = getMessage(key, false); + return setMessage(interaction.guild, key, value) + ? interactionReply(interaction, `Changed **${key}**\n\nOld message:\n> ${oldValue?.replaceAll("\n", "\n> ")}\n\nNew message:\n>>> ${value}`, false) + : interactionReply(interaction, getMessage("ERR_UNKNOWN")); +} + +async function configureAutothreading(interaction: CommandInteraction): Promise { + const channel = interaction.options.getChannel("channel") as GuildTextBasedChannel; + const enabled = interaction.options.getBoolean("enabled"); + const customMessage = interaction.options.getString("custom-message") ?? ""; + + if (!interaction.guild) { + return interactionReply(interaction, getMessage("ERR_ONLY_IN_SERVER")); + } + + if (!channel || enabled == null) { + return interactionReply(interaction, getMessage("ERR_PARAMETER_MISSING")); + } + + const clientUser = interaction.client.user; + if (!clientUser) return interactionReply(interaction, getMessage("ERR_UNKNOWN")); + + const botMember = await interaction.guild.members.fetch(clientUser); + if (!botMember.permissionsIn(channel.id).has(Permissions.FLAGS.VIEW_CHANNEL)) { + addMessageContext({ channel: channel }); + return interactionReply(interaction, getMessage("ERR_CHANNEL_VISIBILITY")); + } + + if (enabled) { + const success = enableAutothreading(interaction.guild, channel.id, customMessage); + return success + ? interactionReply(interaction, `Updated auto-threading settings for <#${channel.id}>`, false) + : interactionReply(interaction, getMessage("ERR_UNKNOWN")); + } + + if (!isAutoThreadChannel(channel.id, interaction.guildId)) { + return interactionReply(interaction, getMessage("ERR_NO_EFFECT")); + } + + const success = disableAutothreading(interaction.guild, channel.id); + return success + ? interactionReply(interaction, `Removed auto-threading in <#${channel.id}>`, false) + : interactionReply(interaction, getMessage("ERR_UNKNOWN")); +} diff --git a/src/commands/help.ts b/src/commands/help.ts index 6565a101..e850ad6e 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -1,6 +1,6 @@ import { SlashCommandBuilder } from "@discordjs/builders"; import { CommandInteraction, MessageActionRow, MessageEmbed } from "discord.js"; -import { APIApplicationCommandOption } from "discord.js/node_modules/discord-api-types"; +import { APIApplicationCommandOption } from "discord-api-types"; import { getCommand, getOrLoadAllCommands } from "../handlers/commandHandler"; import { getBugReportButton, getDiscordInviteButton, getFeatureRequestButton } from "../helpers/messageHelpers"; import { NeedleCommand } from "../types/needleCommand"; @@ -38,7 +38,6 @@ export const command: NeedleCommand = { ephemeral: true, }); } - }, }; @@ -49,7 +48,7 @@ async function getCommandDetailsEmbed(commandName: string): Promise { - const embed = new MessageEmbed().setTitle("Needle Commands 🪡"); // :sewing_needle: + const embed = new MessageEmbed().setTitle("🪡 Needle Commands"); // :sewing_needle: const commands = await getOrLoadAllCommands(); for (const cmd of commands) { // Help command gets special treatment @@ -83,6 +82,8 @@ async function getAllCommandsEmbed(): Promise { async function getCommandOptionString(cmd: NeedleCommand): Promise { const commandInfo = await cmd.getSlashCommandBuilder(); + if (!commandInfo.options) { return ""; } + let output = ""; for (const option of commandInfo.options) { output += ` \`${option.name}${option.required ? "" : "?"}\``; @@ -90,7 +91,7 @@ async function getCommandOptionString(cmd: NeedleCommand): Promise { return output; } -async function getCommandOptions(cmd: NeedleCommand): Promise { +async function getCommandOptions(cmd: NeedleCommand): Promise { const commandInfo = await cmd.getSlashCommandBuilder(); return commandInfo.options; } diff --git a/src/commands/title.ts b/src/commands/title.ts index e151248c..6bb3183b 100644 --- a/src/commands/title.ts +++ b/src/commands/title.ts @@ -1,12 +1,12 @@ import { SlashCommandBuilder } from "@discordjs/builders"; import { CommandInteraction, GuildMember, Permissions } from "discord.js"; -import { ephemeralReply, getThreadStartMessage } from "../helpers/messageHelpers"; +import { interactionReply, getThreadStartMessage, getMessage } from "../helpers/messageHelpers"; import { NeedleCommand } from "../types/needleCommand"; export const command: NeedleCommand = { name: "title", shortHelpDescription: "Sets the title of a thread to `value`", - longHelpDescription: "The title command lets thread owners to change the name of a thread.", + longHelpDescription: "The title command changes the title of a thread.", async getSlashCommandBuilder() { return new SlashCommandBuilder() @@ -24,30 +24,40 @@ export const command: NeedleCommand = { async execute(interaction: CommandInteraction): Promise { const member = interaction.member; if (!(member instanceof GuildMember)) { - return ephemeralReply(interaction, "An unexpected error occurred."); + return interactionReply(interaction, getMessage("ERR_UNKNOWN")); } const channel = interaction.channel; if (!channel?.isThread()) { - return ephemeralReply(interaction, "You can only use this command inside a thread."); + return interactionReply(interaction, getMessage("ERR_ONLY_IN_THREAD")); } - const parentMessage = await getThreadStartMessage(channel); - if (!parentMessage) { - return ephemeralReply(interaction, "Could not find the start message of this thread."); + const newThreadName = interaction.options.getString("value"); + if (!newThreadName) { + return interactionReply(interaction, getMessage("ERR_PARAMETER_MISSING")); + } + + const oldThreadName = channel.name; + + if (oldThreadName === newThreadName) { + return interactionReply(interaction, getMessage("ERR_NO_EFFECT")); } const hasChangeTitlePermissions = member.permissionsIn(channel).has(Permissions.FLAGS.MANAGE_THREADS, true); - if (!hasChangeTitlePermissions && parentMessage.author !== interaction.user) { - return ephemeralReply(interaction, "You need to be the thread owner to change the title."); + if (hasChangeTitlePermissions) { + await channel.setName(newThreadName, `Changed by ${member.user.tag} (${member.id})`); + await interaction.reply(`Successfully changed title from \`${oldThreadName}\` to \`${newThreadName}\`.`); + return; } - const newThreadName = interaction.options.getString("value"); - if (!newThreadName) { - return ephemeralReply(interaction, "You need to provide a new thread name when writing the command"); + const parentMessage = await getThreadStartMessage(channel); + if (!parentMessage) { + return interactionReply(interaction, getMessage("ERR_THREAD_MESSAGE_MISSING")); } - const oldThreadName = channel.name; + if (parentMessage.author !== interaction.user) { + return interactionReply(interaction, getMessage("ERR_ONLY_THREAD_OWNER")); + } // Current rate limit is 2 renames per thread per 10 minutes (2021-09-17). // If that rate limit is hit, it will wait here until it is able to rename the thread. diff --git a/src/config.json b/src/config.json index 74be215d..e41ac972 100644 --- a/src/config.json +++ b/src/config.json @@ -1,17 +1,21 @@ { - "discordApiToken": "", - "threadChannels": [""], - "threadArchiveDuration": "MAX", - "threadMessage": { - "shouldSend": true, - "shouldPin": true, - "content": "Thread created from $$channelMention by $$authorMention $$relativeTimeSince with the following message:", - "embeds": [ - "$$messageEmbed" - ] - }, - "dev": { - "clientId": "", - "guildId": "" + "threadChannels": [], + "messages": { + "ERR_UNKNOWN": "An unexpected error occurred. Please try again later.", + "ERR_ONLY_IN_SERVER": "You can only perform this action inside a server.", + "ERR_ONLY_IN_THREAD": "You can only perform this action inside a thread.", + "ERR_ONLY_THREAD_OWNER": "You need to be the thread owner to perform this action.", + "ERR_NO_EFFECT": "This action will have no effect.", + "ERR_JSON_MISSING": "The JSON content was missing.", + "ERR_JSON_INVALID": "Your input was not valid JSON. You can use an online tool such as to validate your json.", + "ERR_CONFIG_INVALID": "Your config was invalid. Remember to: \n- Pass minified JSON, because new lines inside commands does not work in Discord. You can use an online tool such as for minification.\n- Wrap the config in an object. \n- Spell property keys correctly.\n\nIf you need help with the formatting, you can see the default config of Needle at . Changes to `discordApiToken` and `dev` will be ignored by this command.", + "ERR_DURATION_INVALID": "The specified duration was invalid.", + "ERR_PARAMETER_MISSING": "A non-optional parameter is missing from the command.", + "ERR_INSUFFICIENT_PERMS": "You do not have permission to perform this action.", + "ERR_CHANNEL_VISIBILITY": "The $CHANNEL channel is not visible to the bot. Change the permissions and try again.", + "ERR_THREAD_MESSAGE_MISSING": "The starting message for this thread could not be found, which prevents this action from being performed.", + + "SUCCESS_THREAD_CREATE": "Hello $USER!\nThis thread has been automatically created from your message in $CHANNEL $TIME_AGO.\n\n**Want to unsubscribe from this thread?**\nRight-click the thread (or use the `...` menu) and select **Leave Thread**.\n\n**Want to change the title?**\nUse the `/title` command!\n\n**Done using the thread?**\nClick the button below to archive it.", + "SUCCESS_THREAD_ARCHIVE": "**This thread will be archived soon** :card_box:\nAs requested by $USER, this thread will automatically be archived when **one hour passes without any new messages**.\n\n• The thread's content will still be searchable with Discord's search function\n• Anyone will be able to un-archive the thread at any point in the future by sending a message in the thread again" } } diff --git a/src/handlers/commandHandler.ts b/src/handlers/commandHandler.ts index 449023cb..41b8c637 100644 --- a/src/handlers/commandHandler.ts +++ b/src/handlers/commandHandler.ts @@ -1,14 +1,14 @@ import { CommandInteraction, MessageComponentInteraction } from "discord.js"; import { promises } from "fs"; import { resolve as pathResolve } from "path"; -import { ephemeralReply } from "../helpers/messageHelpers"; +import { getMessage, interactionReply } from "../helpers/messageHelpers"; import { NeedleCommand } from "../types/needleCommand"; const COMMANDS_PATH = pathResolve(__dirname, "../commands"); let loadedCommands: NeedleCommand[] = []; -export async function handleCommandInteraction(interaction: CommandInteraction): Promise { +export function handleCommandInteraction(interaction: CommandInteraction): Promise { const command = getCommand(interaction.commandName); if (!command) return Promise.reject(); @@ -17,7 +17,7 @@ export async function handleCommandInteraction(interaction: CommandInteraction): } catch (error) { console.error(error); - return ephemeralReply(interaction, "There was an error while executing this command. Please try again later."); + return interactionReply(interaction, getMessage("ERR_UNKNOWN")); } } @@ -30,12 +30,12 @@ export async function handleButtonClickedInteraction(interaction: MessageCompone } catch (error) { console.error(error); - return ephemeralReply(interaction, "There was an error while executing this command. Please try again later."); + return interactionReply(interaction, getMessage("ERR_UNKNOWN")); } } -export async function getOrLoadAllCommands(alwaysLoad = false): Promise { - if (loadedCommands.length > 0 && !alwaysLoad) { +export async function getOrLoadAllCommands(allowCache = true): Promise { + if (loadedCommands.length > 0 && allowCache) { return loadedCommands; } diff --git a/src/handlers/interactionHandler.ts b/src/handlers/interactionHandler.ts index 437a9f98..e1cd4a41 100644 --- a/src/handlers/interactionHandler.ts +++ b/src/handlers/interactionHandler.ts @@ -1,11 +1,20 @@ import { Interaction } from "discord.js"; +import { resetMessageContext, addMessageContext } from "../helpers/messageHelpers"; import { handleButtonClickedInteraction, handleCommandInteraction } from "./commandHandler"; -export function handleInteractionCreate(interaction: Interaction): void { +export async function handleInteractionCreate(interaction: Interaction): Promise { + addMessageContext({ + user: interaction.user, + interaction: interaction, + channel: interaction.channel ?? undefined, + }); + if (interaction.isCommand()) { - handleCommandInteraction(interaction); + await handleCommandInteraction(interaction); } else if (interaction.isButton()) { - handleButtonClickedInteraction(interaction); + await handleButtonClickedInteraction(interaction); } + + resetMessageContext(); } diff --git a/src/handlers/messageHandler.ts b/src/handlers/messageHandler.ts index 63299e82..c135a5f2 100644 --- a/src/handlers/messageHandler.ts +++ b/src/handlers/messageHandler.ts @@ -1,5 +1,6 @@ -import { Message, MessageActionRow, MessageButton, MessageEmbed } from "discord.js"; +import { Message, MessageActionRow, MessageButton, NewsChannel, TextChannel } from "discord.js"; import { getConfig } from "../helpers/configHelpers"; +import { getMessage, resetMessageContext, addMessageContext, isAutoThreadChannel } from "../helpers/messageHelpers"; import { getRequiredPermissions } from "../helpers/permissionHelpers"; export async function handleMessageCreate(message: Message): Promise { @@ -19,10 +20,9 @@ export async function handleMessageCreate(message: Message): Promise { if (message.system) return; if (authorUser.bot) return; if (!channel.isText()) return; + if (!(channel instanceof TextChannel) && !(channel instanceof NewsChannel)) return; if (message.hasThread) return; - - const config = getConfig(); - if (!config?.threadChannels?.includes(channel.id)) return; + if (!isAutoThreadChannel(channel.id, guild.id)) return; const botMember = await guild.members.fetch(clientUser); const botPermissions = botMember.permissionsIn(message.channel.id); @@ -39,6 +39,12 @@ export async function handleMessageCreate(message: Message): Promise { return; } + addMessageContext({ + user: authorUser, + channel: channel, + message: message, + }); + const creationDate = message.createdAt.toISOString().slice(0, 10); const authorName = authorMember === null || authorMember.nickname === null ? authorUser.username @@ -46,24 +52,29 @@ export async function handleMessageCreate(message: Message): Promise { const thread = await message.startThread({ name: `${authorName} (${creationDate})`, - autoArchiveDuration: <60 | 1440 | 4320 | 10080 | "MAX"> config.threadArchiveDuration, + autoArchiveDuration: channel.defaultAutoArchiveDuration, }); const closeButton = new MessageButton() .setCustomId("close") - .setLabel("Close thread") + .setLabel("Archive thread") .setStyle("DANGER") - .setEmoji("🗑️"); + .setEmoji("🗃️"); const buttonRow = new MessageActionRow().addComponents(closeButton); - const channelMention = `<#${channel.id}>`; - const relativeTimestamp = ``; + const overrideMessageContent = getConfig(guild.id).threadChannels?.find(x => x?.channelId === channel.id)?.messageContent; + const msgContent = overrideMessageContent + ? overrideMessageContent + : getMessage("SUCCESS_THREAD_CREATE"); - await thread.send({ - content: `Hello <@${authorUser.id}>! This helpful thread has been automatically created from your message in ${channelMention} ${relativeTimestamp}.\n\nWant to unsubscribe from this thread? Right-click the thread (or use the \`...\` menu) and select **Leave Thread**.\n\nIf you are done using this thread, you can click the button below to close this thread.`, - components: [buttonRow], - }); + if (msgContent && msgContent.length > 0) { + await thread.send({ + content: msgContent, + components: [buttonRow], + }); + } await thread.leave(); + resetMessageContext(); } diff --git a/src/helpers/configHelpers.ts b/src/helpers/configHelpers.ts index 5195fca4..747d02ef 100644 --- a/src/helpers/configHelpers.ts +++ b/src/helpers/configHelpers.ts @@ -1,6 +1,115 @@ +import { Guild } from "discord.js"; import * as defaultConfig from "../config.json"; -import * as overrideConfig from "../overrideConfig.json"; +import { resolve as pathResolve } from "path"; +import * as fs from "fs"; +import { NeedleConfig } from "../types/needleConfig"; +import { MessageKey } from "./messageHelpers"; -export function getConfig(): typeof defaultConfig & typeof overrideConfig { - return Object.assign(defaultConfig, overrideConfig); +const CONFIGS_PATH = pathResolve(__dirname, "../../configs"); +const guildConfigsCache = new Map(); + +export function getConfig(guildId = ""): NeedleConfig { + const guildConfig = guildConfigsCache.get(guildId) ?? readConfigFromFile(guildId); + + const defaultConfigCopy = JSON.parse(JSON.stringify(defaultConfig)) as NeedleConfig; + if (guildConfig) { + guildConfig.messages = Object.assign({}, defaultConfigCopy.messages, guildConfig?.messages); + } + + return Object.assign({}, defaultConfigCopy, guildConfig); +} + +// Used by deploy-commands.js (!) +export function getApiToken(): string | undefined { + return process.env.DISCORD_API_TOKEN; +} + +// Used by deploy-commands.js (!) +export function getClientId(): string | undefined { + return process.env.CLIENT_ID; +} + +// Used by deploy-commands.js (!) +export function getGuildId(): string | undefined { + return process.env.GUILD_ID; +} + +export function setMessage(guild: Guild, messageKey: MessageKey, value: string): boolean { + const config = getConfig(guild.id); + if (!config || !config.messages) { return false; } + if (value.length > 2000) { return false; } + + config.messages[messageKey] = value; + return setConfig(guild, config); +} + +export function enableAutothreading(guild: Guild, channelId: string, message = ""): boolean { + const config = getConfig(guild.id); + if (!config || !config.threadChannels) { return false; } + if (message.length > 2000) { return false; } + + const index = config.threadChannels.findIndex(x => x?.channelId === channelId); + if (index > -1) { + config.threadChannels[index].messageContent = message; + } + else { + config.threadChannels.push({ channelId: channelId, messageContent: message }); + } + + return setConfig(guild, config); +} + +export function disableAutothreading(guild: Guild, channelId: string): boolean { + const config = getConfig(guild.id); + if (!config || !config.threadChannels) { return false; } + + const index = config.threadChannels.findIndex(x => x?.channelId === channelId); + if (index > -1) { + delete config.threadChannels[index]; + } + + return setConfig(guild, config); +} + +export function resetConfigToDefault(guildId: string): boolean { + const path = getGuildConfigPath(guildId); + if (!fs.existsSync(path)) return false; + fs.rmSync(path); + guildConfigsCache.delete(guildId); + return true; +} + +function readConfigFromFile(guildId: string): NeedleConfig | undefined { + const path = getGuildConfigPath(guildId); + if (!fs.existsSync(path)) return undefined; + + const jsonConfig = fs.readFileSync(path, { "encoding": "utf-8" }); + return JSON.parse(jsonConfig); +} + +function getGuildConfigPath(guildId: string) { + return `${CONFIGS_PATH}/${guildId}.json`; +} + +function setConfig(guild: Guild | null | undefined, config: NeedleConfig): boolean { + if (!guild || !config) return false; + + const path = getGuildConfigPath(guild.id); + if (!fs.existsSync(CONFIGS_PATH)) { + fs.mkdirSync(CONFIGS_PATH); + } + config.threadChannels = config.threadChannels?.filter(val => val != null && val != undefined); + + // Only save messages that are different from the defaults + const defaultConfigCopy = JSON.parse(JSON.stringify(defaultConfig)) as NeedleConfig; + if (defaultConfigCopy.messages && config.messages) { + for(const [key, message] of Object.entries(config.messages)) { + if (message !== defaultConfigCopy.messages[key as MessageKey]) continue; + delete config.messages[key as MessageKey]; + } + } + + fs.writeFileSync(path, JSON.stringify(config), { encoding: "utf-8" }); + guildConfigsCache.set(guild.id, config); + return true; } diff --git a/src/helpers/fileHelpers.ts b/src/helpers/fileHelpers.ts new file mode 100644 index 00000000..0b7912f3 --- /dev/null +++ b/src/helpers/fileHelpers.ts @@ -0,0 +1,7 @@ +import { MessageAttachment } from "discord.js"; +import { Readable } from "stream"; + +export function createJsonMessageAttachment(obj: unknown, fileName: string, indentation = 2): MessageAttachment { + const stream = Readable.from(JSON.stringify(obj, undefined, indentation), { encoding: "utf-8" }); + return new MessageAttachment(stream, fileName); +} diff --git a/src/helpers/messageHelpers.ts b/src/helpers/messageHelpers.ts index f658d5a3..2c8b7c21 100644 --- a/src/helpers/messageHelpers.ts +++ b/src/helpers/messageHelpers.ts @@ -1,4 +1,31 @@ -import { BaseCommandInteraction, Message, MessageButton, MessageComponentInteraction, TextBasedChannels } from "discord.js"; +import { + BaseCommandInteraction, + Message, + MessageButton, + MessageComponentInteraction, + TextBasedChannels, +} from "discord.js"; + +import { MessageContext } from "../types/messageContext"; +import { NeedleConfig } from "../types/needleConfig"; +import { getConfig } from "./configHelpers"; + +let context: MessageContext = {}; + +export type MessageKey = keyof NonNullable; + +export function addMessageContext(additionalContext: Partial): void { + context = Object.assign(context, additionalContext); +} + +export function resetMessageContext(): void { + context = {}; +} + +export function isAutoThreadChannel(channelId: string, guildId: string): boolean { + const config = getConfig(guildId); + return config?.threadChannels?.some(x => x?.channelId === channelId) ?? false; +} export async function getThreadStartMessage(threadChannel: TextBasedChannels | null): Promise { if (!threadChannel?.isThread()) { return null; } @@ -12,13 +39,60 @@ export async function getThreadStartMessage(threadChannel: TextBasedChannels | n return parentChannel.messages .fetch(threadChannel.id) .catch(() => { - console.error(`Start message has been deleted in thread "${threadChannel.name}"`); + console.error(`Start message is missing in thread "${threadChannel.name}"`); return null; }); } -export function ephemeralReply(interaction: BaseCommandInteraction | MessageComponentInteraction, replyContent: string): Promise { - return interaction.reply({ content: replyContent, ephemeral: true }); +export function getCodeFromCodeBlock(codeBlock: string): string { + const codeBlockStart = codeBlock.match(/^```(\w*)/ig); + + // If it has no code block + if (codeBlockStart?.length === 0) { + return codeBlock; + } + + // Replace start and end tags + const codeWithoutTags = codeBlock.replaceAll(/^```(\w*)/ig, "").replaceAll(/```$/ig, ""); + return codeWithoutTags.trim(); +} + +export function interactionReply( + interaction: BaseCommandInteraction | MessageComponentInteraction, + message?: string, + ephemeral = true): Promise { + if (!message || message.length == 0) { + return interaction.reply({ + content: getMessage("ERR_UNKNOWN"), + ephemeral: true, + }); + } + + return interaction.reply({ + content: message, + ephemeral: ephemeral, + }); +} + +export function getMessage(messageKey: MessageKey, replaceVariables = true): string | undefined { + const config = getConfig(context?.interaction?.guildId); + if (!config.messages) { return ""; } + + const message = config.messages[messageKey]; + if (!context || !message) { return message; } + + const user = context.user ? `<@${context.user.id}>` : ""; + const channel = context.channel ? `<#${context.channel.id}>` : ""; + const timeAgo = context.timeAgo || (context.message + ? `` + : ""); + + return !replaceVariables + ? message + : message + .replaceAll("$USER", user) + .replaceAll("$CHANNEL", channel) + .replaceAll("$TIME_AGO", timeAgo); } export function getDiscordInviteButton(buttonText = "Join the support server"): MessageButton { @@ -45,10 +119,17 @@ export function getBugReportButton(buttonText = "Report a bug"): MessageButton { .setEmoji("🐛"); } -export function getFeatureRequestButton(buttonText = "Suggest a feature"): MessageButton { +export function getFeatureRequestButton(buttonText = "Suggest an improvement"): MessageButton { return new MessageButton() .setLabel(buttonText) .setStyle("LINK") .setURL("https://github.com/MarcusOtter/discord-needle/issues/new/choose") .setEmoji("💡"); -} \ No newline at end of file +} + +export function getCloseConfigChannelButton(): MessageButton { + return new MessageButton() + .setCustomId("close-config-channel") + .setLabel("Close channel") + .setStyle("DANGER"); +} diff --git a/src/helpers/permissionHelpers.ts b/src/helpers/permissionHelpers.ts index daf1df3c..695a6389 100644 --- a/src/helpers/permissionHelpers.ts +++ b/src/helpers/permissionHelpers.ts @@ -1,23 +1,21 @@ -import { Permissions } from "discord.js"; -import { getConfig } from "./configHelpers"; +import { GuildMember, Permissions } from "discord.js"; export function getRequiredPermissions(): bigint[] { - const config = getConfig(); const output = [ - Permissions.FLAGS.USE_PUBLIC_THREADS, - - /* TODO: Replace with SEND_MESSAGES_IN_THREADS when it is released */ + Permissions.FLAGS.VIEW_CHANNEL, Permissions.FLAGS.SEND_MESSAGES, + Permissions.FLAGS.SEND_MESSAGES_IN_THREADS, + Permissions.FLAGS.CREATE_PUBLIC_THREADS, Permissions.FLAGS.READ_MESSAGE_HISTORY, ]; - if (config?.threadMessage?.shouldPin) { - output.push(Permissions.FLAGS.MANAGE_MESSAGES); - } + return output; +} - if (config?.threadMessage?.embeds?.length > 0) { - output.push(Permissions.FLAGS.EMBED_LINKS); - } +export function memberIsModerator(member: GuildMember): boolean { + return member.permissions.has(Permissions.FLAGS.KICK_MEMBERS); +} - return output; +export function memberIsAdmin(member: GuildMember): boolean { + return member.permissions.has(Permissions.FLAGS.ADMINISTRATOR); } diff --git a/src/index.ts b/src/index.ts index d87c5a72..8e24b7db 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,18 +2,20 @@ import { Client, Intents } from "discord.js"; import { getOrLoadAllCommands } from "./handlers/commandHandler"; import { handleInteractionCreate } from "./handlers/interactionHandler"; import { handleMessageCreate } from "./handlers/messageHandler"; -import { getConfig } from "./helpers/configHelpers"; +import { getApiToken } from "./helpers/configHelpers"; -const CLIENT = new Client({ intents: [Intents.FLAGS.GUILDS, Intents.FLAGS.GUILD_MESSAGES] }); - -CLIENT.once("ready", async () => { - console.log("Ready!"); +(async () => { + (await import("dotenv")).config(); // Initial load of all commands - await getOrLoadAllCommands(); -}); + await getOrLoadAllCommands(false); + + const CLIENT = new Client({ intents: [Intents.FLAGS.GUILDS, Intents.FLAGS.GUILD_MESSAGES] }); + CLIENT.once("ready", () => console.log("Ready!")); + + CLIENT.on("interactionCreate", interaction => handleInteractionCreate(interaction).catch(e => console.log(e))); + CLIENT.on("messageCreate", message => handleMessageCreate(message).catch(e => console.log(e))); -CLIENT.on("interactionCreate", interaction => handleInteractionCreate(interaction)); -CLIENT.on("messageCreate", message => handleMessageCreate(message)); + CLIENT.login(getApiToken() ?? undefined); +})(); -CLIENT.login(getConfig().discordApiToken); diff --git a/src/types/messageContext.ts b/src/types/messageContext.ts new file mode 100644 index 00000000..c6342825 --- /dev/null +++ b/src/types/messageContext.ts @@ -0,0 +1,13 @@ +import { CacheType, Interaction, Message, TextBasedChannels, User } from "discord.js"; + +export interface MessageContext { + interaction?: Interaction; + message?: Message; + + // Variables that can be used in messages (if they exist at the time of invocation) + // To use in message configuration, prefix with $ and convert name to SCREAMING_SNAKE_CASE + // For example, $TIME_AGO and $USER + channel?: TextBasedChannels; + user?: User; + timeAgo?: string; +} diff --git a/src/types/needleConfig.ts b/src/types/needleConfig.ts new file mode 100644 index 00000000..2213968d --- /dev/null +++ b/src/types/needleConfig.ts @@ -0,0 +1,20 @@ +export interface NeedleConfig { + threadChannels?: { channelId: string, messageContent: string }[]; + messages?: { + ERR_UNKNOWN?: string, + ERR_ONLY_IN_SERVER?: string, + ERR_ONLY_IN_THREAD?: string, + ERR_ONLY_THREAD_OWNER?: string, + ERR_NO_EFFECT?: string, + ERR_JSON_MISSING?: string, + ERR_JSON_INVALID?: string, + ERR_CONFIG_INVALID?: string; + ERR_PARAMETER_MISSING?: string, + ERR_INSUFFICIENT_PERMS?: string, + ERR_CHANNEL_VISIBILITY?: string, + ERR_THREAD_MESSAGE_MISSING?: string, + + SUCCESS_THREAD_CREATE?: string, + SUCCESS_THREAD_ARCHIVE?: string, + }, +}