diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 36be0cf..243d3b8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,13 +11,16 @@ jobs: strategy: fail-fast: false matrix: - check: [lint, typecheck, format:check, build] + check: [lint, typecheck, format:check, build, test] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm + # the lockfile is maintained with npm 11, which resolves vite's + # optional esbuild peer differently than node 22's bundled npm 10 + - run: npm install -g npm@11 - run: npm ci - run: npm run ${{ matrix.check }} diff --git a/.prettierrc b/.prettierrc index ff2f7e4..b10c283 100644 --- a/.prettierrc +++ b/.prettierrc @@ -6,7 +6,7 @@ "", "", "", - "^@(config$|commands/|db/|main/)", + "^@(config$|commands/|db/|main/|routes/)", "", "^[./]" ] diff --git a/package-lock.json b/package-lock.json index 2727239..a7a35c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "dependencies": { "@supabase/supabase-js": "^2.110.2", + "discord-embed-router": "^1.3.0", "discord.js": "^14.22.1", "dotenv": "^17.2.2", "is-online": "^11.0.0", @@ -25,7 +26,8 @@ "tsup": "^8.5.0", "tsx": "^4.20.5", "typescript": "^5.9.2", - "typescript-eslint": "^8.44.0" + "typescript-eslint": "^8.44.0", + "vitest": "^4.1.10" } }, "node_modules/@babel/code-frame": { @@ -155,15 +157,15 @@ } }, "node_modules/@discordjs/builders": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.11.3.tgz", - "integrity": "sha512-p3kf5eV49CJiRTfhtutUCeivSyQ/l2JlKodW1ZquRwwvlOWmG9+6jFShX6x8rUiYhnP6wKI96rgN/SXMy5e5aw==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", "license": "Apache-2.0", "dependencies": { - "@discordjs/formatters": "^0.6.1", - "@discordjs/util": "^1.1.1", + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", - "discord-api-types": "^0.38.16", + "discord-api-types": "^0.38.40", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" @@ -184,12 +186,12 @@ } }, "node_modules/@discordjs/formatters": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.1.tgz", - "integrity": "sha512-5cnX+tASiPCqCWtFcFslxBVUaCetB0thvM/JyavhbXInP1HJIEU+Qv/zMrnuwSsX3yWH2lVXNJZeDK3EiP4HHg==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", "license": "Apache-2.0", "dependencies": { - "discord-api-types": "^0.38.1" + "discord-api-types": "^0.38.33" }, "engines": { "node": ">=16.11.0" @@ -199,20 +201,20 @@ } }, "node_modules/@discordjs/rest": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", - "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", "license": "Apache-2.0", "dependencies": { "@discordjs/collection": "^2.1.1", - "@discordjs/util": "^1.1.1", + "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", - "@sapphire/snowflake": "^3.5.3", + "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", - "discord-api-types": "^0.38.16", - "magic-bytes.js": "^1.10.0", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "6.21.3" + "undici": "6.24.1" }, "engines": { "node": ">=18" @@ -233,11 +235,24 @@ "url": "https://github.com/discordjs/discord.js?sponsor" } }, + "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@discordjs/util": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.1.1.tgz", - "integrity": "sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, "engines": { "node": ">=18" }, @@ -295,6 +310,40 @@ "@noble/ciphers": "^1.0.0" } }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", @@ -1113,6 +1162,25 @@ "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==" }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@noble/ciphers": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", @@ -1193,6 +1261,16 @@ "node": ">= 8" } }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1204,6 +1282,270 @@ "node": ">=14" } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.52.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.0.tgz", @@ -1556,6 +1898,13 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@supabase/auth-js": { "version": "2.110.2", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.110.2.tgz", @@ -1604,9 +1953,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1621,9 +1967,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1638,9 +1981,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1655,9 +1995,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1775,6 +2112,35 @@ "node": ">=14.16" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2047,10 +2413,96 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@vladfrangu/async_event_emitter": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.6.tgz", - "integrity": "sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA==", + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.6.tgz", + "integrity": "sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA==", "license": "MIT", "engines": { "node": ">=v14.0.0", @@ -2150,6 +2602,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2241,6 +2703,16 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2375,6 +2847,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2447,34 +2926,62 @@ "node": ">=10" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/discord-api-types": { - "version": "0.38.26", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.26.tgz", - "integrity": "sha512-xpmPviHjIJ6dFu1eNwNDIGQ3N6qmPUUYFVAx/YZ64h7ZgPkTcKjnciD8bZe8Vbeji7yS5uYljyciunpq0J5NSw==", + "version": "0.38.49", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.49.tgz", + "integrity": "sha512-XnqcWmnFZFAE8ZM8SHAw9DIV8D3Or00rMQ8iQLotrEA2PmXhl+ykaf6L6q4l474hrSUH1JaYcv+iOMRWp2p6Tg==", "license": "MIT", "workspaces": [ "scripts/actions/documentation" ] }, + "node_modules/discord-embed-router": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/discord-embed-router/-/discord-embed-router-1.3.0.tgz", + "integrity": "sha512-3IDplfqGYjpqW5uZf+mumu07RZ5DpPWm/EevbUJNAvKb61AC0FYSDOD4VNqdTo25xoOWwiOCfQr0xtd4er8GMA==", + "license": "MIT", + "workspaces": [ + "examples/basic-bot" + ], + "dependencies": { + "path-to-regexp": "^8.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "discord.js": "^14.26.4" + } + }, "node_modules/discord.js": { - "version": "14.22.1", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.22.1.tgz", - "integrity": "sha512-3k+Kisd/v570Jr68A1kNs7qVhNehDwDJAPe4DZ2Syt+/zobf9zEcuYFvsfIaAOgCa0BiHMfOOKQY4eYINl0z7w==", + "version": "14.26.5", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.5.tgz", + "integrity": "sha512-SGYgjiAs0o8ZzMC97XmFXKONyairJ9YzVda+LvoSKs9YYh2gPtQhY+liaV6H/w72jxYbu9ggiSqAXoF3FLOH4A==", "license": "Apache-2.0", "dependencies": { - "@discordjs/builders": "^1.11.2", + "@discordjs/builders": "^1.14.1", "@discordjs/collection": "1.5.3", - "@discordjs/formatters": "^0.6.1", - "@discordjs/rest": "^2.6.0", - "@discordjs/util": "^1.1.1", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.1", + "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", - "discord-api-types": "^0.38.16", + "discord-api-types": "^0.38.48", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", - "magic-bytes.js": "^1.10.0", + "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "6.21.3" + "undici": "6.24.1" }, "engines": { "node": ">=18" @@ -2549,6 +3056,13 @@ "dev": true, "license": "MIT" }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", @@ -2806,6 +3320,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2816,6 +3340,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3398,6 +3932,267 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -3444,9 +4239,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.merge": { @@ -3486,15 +4281,15 @@ "license": "ISC" }, "node_modules/magic-bytes.js": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.12.1.tgz", - "integrity": "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", "license": "MIT" }, "node_modules/magic-string": { - "version": "0.30.19", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", - "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3607,6 +4402,25 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -3655,6 +4469,20 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3806,6 +4634,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -3821,9 +4659,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -3855,6 +4693,35 @@ "pathe": "^2.0.1" } }, + "node_modules/postcss": { + "version": "8.5.17", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.17.tgz", + "integrity": "sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/postcss-load-config": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", @@ -4044,6 +4911,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, "node_modules/rollup": { "version": "4.52.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.0.tgz", @@ -4144,6 +5045,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -4171,6 +5079,30 @@ "node": ">= 8" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -4413,6 +5345,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", @@ -4421,14 +5360,14 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -4437,6 +5376,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4643,9 +5592,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "6.21.3", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", - "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", "license": "MIT", "engines": { "node": ">=18.17" @@ -4667,6 +5616,211 @@ "punycode": "^2.1.0" } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", @@ -4701,6 +5855,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index a655051..16c6c3f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "scripts": { "lint": "npx eslint --config src/eslint.config.mts src", + "test": "vitest run", "typecheck": "tsc --noEmit", "dev": "tsx watch src/index.ts", "deploy-commands": "tsx src/deploy-commands.ts", @@ -13,6 +14,7 @@ }, "dependencies": { "@supabase/supabase-js": "^2.110.2", + "discord-embed-router": "^1.3.0", "discord.js": "^14.22.1", "dotenv": "^17.2.2", "is-online": "^11.0.0", @@ -32,6 +34,7 @@ "tsup": "^8.5.0", "tsx": "^4.20.5", "typescript": "^5.9.2", - "typescript-eslint": "^8.44.0" + "typescript-eslint": "^8.44.0", + "vitest": "^4.1.10" } } diff --git a/src/commands/catalog.ts b/src/commands/catalog.ts new file mode 100644 index 0000000..2d41b19 --- /dev/null +++ b/src/commands/catalog.ts @@ -0,0 +1,18 @@ +import { + ChatInputCommandInteraction, + MessageFlags, + SlashCommandBuilder, +} from "discord.js"; + +import { RingRouter } from "@routes/types"; + +export const catalog = { + data: new SlashCommandBuilder() + .setName("catalog") + .setDescription("List every RingVC command"), + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { + await router.dispatch(interaction, "/help/catalog", { + flags: [MessageFlags.Ephemeral], + }); + }, +}; diff --git a/src/commands/commandNames.ts b/src/commands/commandNames.ts index e8f70f3..856a275 100644 --- a/src/commands/commandNames.ts +++ b/src/commands/commandNames.ts @@ -1,7 +1,10 @@ export const commandNamesList = [ + "ringvc", "help", + "catalog", "delete_data", "ring", + "ring_defaults", "signup", "unsignup", "signuprole", diff --git a/src/commands/commands.ts b/src/commands/commands.ts index fdb7bdc..36c3af6 100644 --- a/src/commands/commands.ts +++ b/src/commands/commands.ts @@ -1,6 +1,6 @@ import { ChatInputCommandInteraction, SharedSlashCommand } from "discord.js"; -import { CommandName } from "@commands/commandNames"; +import { catalog } from "@commands/catalog"; import { defaultRingRecipients } from "@commands/defaultRingRecipient"; import { deleteData } from "@commands/deleteData"; import { block } from "@commands/filter/block"; @@ -12,30 +12,38 @@ import { help } from "@commands/help"; import { mode } from "@commands/mode"; import { quit } from "@commands/quit"; import { ring } from "@commands/ring"; +import { ringDefaults } from "@commands/ringDefaults"; +import { ringvc } from "@commands/ringvc"; import { signup } from "@commands/signup"; import { signuprole } from "@commands/signuprole"; import { unsignup } from "@commands/unsignup"; import { unsignuprole } from "@commands/unsignuprole"; +import { RingRouter } from "@routes/types"; +// commands are thin adapters: they parse their options and dispatch into +// the router, where all business rules live export type CommandImplementation = { data: SharedSlashCommand; execute: ( + router: RingRouter, interaction: ChatInputCommandInteraction, - commandIds: Map, ) => Promise; }; export const commands: CommandImplementation[] = [ + ringvc, help, + catalog, deleteData, ring, + ringDefaults, signup, unsignup, signuprole, unsignuprole, quit, mode, - // filter commamnds + // filter commands block, unblock, whitelist, diff --git a/src/commands/defaultRingRecipient.ts b/src/commands/defaultRingRecipient.ts index d75bef2..3a741d7 100644 --- a/src/commands/defaultRingRecipient.ts +++ b/src/commands/defaultRingRecipient.ts @@ -1,325 +1,31 @@ import { ChannelType, ChatInputCommandInteraction, - EmbedBuilder, MessageFlags, SlashCommandBuilder, } from "discord.js"; -import { CommandName } from "@commands/commandNames"; -import { getAutoRingSetting, setAutoRing, unsetAutoRing } from "@db/auto-ring"; -import { - addDefaultRingee, - clearDefaultRingees, - getDefaultRingees, - removeDefaultRingee, -} from "@db/default-ringees"; -import { mentionUser } from "@main/ring"; +import { RingRouter } from "@routes/types"; export const defaultRingRecipients = { data: new SlashCommandBuilder() .setName("default_ring_recipients") - .setDescription("Configure your default ring recipients") - .addSubcommand((subcommand) => - subcommand - .setName("help") - .setDescription( - "Show information for the default_ring_recipients command", - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("edit") - .setDescription("Edit your default ring recipients") - .addIntegerOption((option) => - option - .setName("action") - .setDescription("Choose to add or remove a default ring recipient") - .addChoices({ name: "Add", value: 1 }, { name: "Remove", value: 0 }) - .setRequired(true), - ) - .addUserOption((option) => - option - .setName("user") - .setDescription( - "The user to add/remove as a default ring recipient", - ) - .setRequired(true), - ) - .addChannelOption((option) => - option - .setName("channel") - .setDescription( - "The channel to add/remove the default ring recipient for (global if not specified)", - ) - .addChannelTypes(ChannelType.GuildVoice) - .setRequired(false), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("list") - .setDescription("List your default ring recipients") - .addChannelOption((option) => - option - .setName("channel") - .setDescription( - "The channel to list the default ring recipients for (global if not specified)", - ) - .addChannelTypes(ChannelType.GuildVoice) - .setRequired(false), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("clear") - .setDescription("Clears your default ring recipients") - .addChannelOption((option) => - option - .setName("channel") - .setDescription( - "The channel to clear the default ring recipients for", - ) - .addChannelTypes(ChannelType.GuildVoice) - .setRequired(false), - ), - ) - .addSubcommandGroup((group) => - group - .setName("auto_ring") + .setDescription("Configure your default ring recipients and auto-ring") + .addChannelOption((option) => + option + .setName("channel") .setDescription( - "Configure automatic ringing when you join a voice channel", - ) - .addSubcommand((subcommand) => - subcommand - .setName("set") - .setDescription("Set automatic ringing for a channel or globally") - .addBooleanOption((option) => - option - .setName("enabled") - .setDescription("Whether or not to enable automatic ringing") - .setRequired(true), - ) - .addChannelOption((option) => - option - .setName("channel") - .setDescription( - "The channel to set automatic ringing for (global if not specified)", - ) - .addChannelTypes(ChannelType.GuildVoice) - .setRequired(false), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("unset") - .setDescription("Unset automatic ringing override for a channel") - .addChannelOption((option) => - option - .setName("channel") - .setDescription("The channel to unset automatic ringing") - .addChannelTypes(ChannelType.GuildVoice) - .setRequired(true), - ), + "The channel whose recipients to open (global if omitted)", ) - .addSubcommand((subcommand) => - subcommand - .setName("get") - .setDescription("View your automatic ringing settings") - .addChannelOption((option) => - option - .setName("channel") - .setDescription( - "The channel to view automatic ringing settings for (global if not specified)", - ) - .addChannelTypes(ChannelType.GuildVoice) - .setRequired(false), - ), - ), + .addChannelTypes(ChannelType.GuildVoice) + .setRequired(false), ), - async execute( - interaction: ChatInputCommandInteraction, - commandIds: Map, - ) { - const subcommand = interaction.options.getSubcommand(); - if (subcommand === "help") { - // Show help information - interaction.reply({ - embeds: [ - new EmbedBuilder() - .setColor("#747ac5") - .setTitle("/default_ring_recipients") - .setDescription( - `This command allows you to configure your default ring recipients, which are used when running or if you have automatic ringing enabled`, - ) - .addFields( - { name: "help", value: "Show this help message" }, - { - name: "edit", - value: - "Add/remove a user as a default ring recipient, either globally, or for a specified channel", - }, - { - name: "list", - value: - "View all default ring recipients, either globally, or for a specified channel", - }, - { - name: "clear", - value: - "Clear all default ring recipients, either globally, or for a specified channel", - }, - { - name: "auto_ring set", - value: - "Turn automatic ringing on or off, either globally, or for a specified channel. If a channel is specified, then global auto_ring is overridden for that channel", - }, - { - name: "auto_ring unset", - value: "Unset automatic ringing override for a channel", - }, - { - name: "auto_ring get", - value: - "View whether automatic ringing is enabled, either globally, or for a specified channel", - }, - ), - ], - flags: [MessageFlags.Ephemeral], - }); - } else if (subcommand === "edit") { - // Add user to default ring recipients - const userToAddOrRemove = interaction.options.getUser("user", true); - const channel = interaction.options.getChannel("channel"); - const action = interaction.options.getInteger("action", true); - - if (action === 1) { - const added = await addDefaultRingee( - interaction.user.id, - channel?.id ?? null, - userToAddOrRemove.id, - ); - interaction - .reply({ - content: added - ? `${userToAddOrRemove} is now a${!channel ? " global" : ""} default ring recipient${channel ? ` for ${channel}` : ""}` - : `${userToAddOrRemove} is a${!channel ? " global" : ""} default ring recipient${channel ? ` for ${channel}` : ""}`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } else { - const removed = await removeDefaultRingee( - interaction.user.id, - channel?.id ?? null, - userToAddOrRemove.id, - ); - interaction - .reply({ - content: removed - ? `${userToAddOrRemove} is no longer a${!channel ? " global" : ""} default ring recipient${channel ? ` for ${channel}` : ""}` - : `${userToAddOrRemove} is not a${!channel ? " global" : ""} default ring recipient${channel ? ` for ${channel}` : ""}`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } - } else if (subcommand === "list") { - // List default ring recipients - const channel = interaction.options.getChannel("channel"); - - const defaultRingees = await getDefaultRingees( - interaction.user.id, - channel?.id ?? null, - ); - const defaultRingeesString = - defaultRingees.length > 0 - ? defaultRingees.map(mentionUser).join("\n") - : "None"; - interaction - .reply({ - content: `__Your${!channel ? " global" : ""} default ring recipients${channel ? ` for ${channel}` : ""}__\n${defaultRingeesString}`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } else if (subcommand === "clear") { - // Reset default ring recipients - const channel = interaction.options.getChannel("channel"); - - const cleared = await clearDefaultRingees( - interaction.user.id, - channel?.id ?? null, - ); - interaction - .reply({ - content: cleared - ? `Your${!channel ? " global" : ""} default ring recipients${channel ? ` for ${channel}` : ""} have been cleared.` - : `You already have no${!channel ? " global" : ""} default ring recipients${channel ? ` for ${channel}` : ""}.`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } else if (subcommand === "set") { - // Enable/disable auto ringing - const enabled = interaction.options.getBoolean("enabled", true); - const channel = interaction.options.getChannel("channel"); - - const changed = await setAutoRing( - interaction.user.id, - channel?.id ?? null, - enabled, - ); - if (changed) { - interaction - .reply({ - content: - `Automatic ringing when you join a voice channel is now \`${enabled ? "enabled" : "disabled"}\`.` + - (enabled - ? `\n\nWARNING: This will cause you to ring all of your default ring recipients every time you join ${channel ?? "a voice channel"}, even if you are in \`stealth\` mode.` - : ""), - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } else { - interaction - .reply({ - content: `Automatic ringing for ${channel ?? "voice channels"} is already \`${enabled ? "enabled" : "disabled"}\`.`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } - } else if (subcommand === "unset") { - // Unset auto ringing override for a channel - const channel = interaction.options.getChannel("channel", true); - - const unset = await unsetAutoRing(interaction.user.id, channel.id); - interaction - .reply({ - content: unset - ? `Automatic ringing override for ${channel} has been unset. Your global automatic ringing setting will now be used.` - : `You do not have an automatic ringing override set for ${channel}.`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } else if (subcommand === "get") { - // Get auto ringing status - const channel = interaction.options.getChannel("channel"); - - const [autoRingOverride, globalAutoRing] = await Promise.all([ - channel - ? getAutoRingSetting(interaction.user.id, channel.id) - : Promise.resolve(undefined), - getAutoRingSetting(interaction.user.id, null), - ]); - const autoRingEnabled = autoRingOverride ?? globalAutoRing ?? false; - interaction - .reply({ - content: - `Default ring will${autoRingEnabled ? "" : " not"} run automatically ${channel ? `for ${channel}` : "globally"} because ` + - (channel && autoRingOverride !== undefined - ? `your override for ${channel} is set to \`${autoRingOverride ? "enabled" : "disabled"}\`` - : (channel ? `you have no override for ${channel} and ` : "") + - `your global auto ring is \`${autoRingEnabled ? "enabled" : "disabled"}\``), - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { + const channel = interaction.options.getChannel("channel"); + await router.dispatch( + interaction, + `/recipients/${channel?.id ?? "global"}`, + { flags: [MessageFlags.Ephemeral] }, + ); }, }; diff --git a/src/commands/deleteData.ts b/src/commands/deleteData.ts index 6505b18..b59b1f2 100644 --- a/src/commands/deleteData.ts +++ b/src/commands/deleteData.ts @@ -1,100 +1,18 @@ import { - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, ChatInputCommandInteraction, - EmbedBuilder, MessageFlags, SlashCommandBuilder, } from "discord.js"; -import { deleteAllUserData } from "@db/users"; +import { RingRouter } from "@routes/types"; export const deleteData = { data: new SlashCommandBuilder() .setName("delete_data") .setDescription("Delete all your data stored for this bot"), - async execute(interaction: ChatInputCommandInteraction) { - const response = await interaction.reply({ - embeds: [ - new EmbedBuilder() - .setColor("#ca2b2b") - .setTitle("Delete all Data") - .setDescription( - "Are you sure you want to delete all your data? This will remove all your filters, signups, and other account settings. This is irreversible", - ), - ], - components: [ - new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setLabel("Delete") - .setStyle(ButtonStyle.Danger) - .setCustomId("confirm-delete-data"), - ) - .addComponents( - new ButtonBuilder() - .setLabel("Cancel") - .setStyle(ButtonStyle.Secondary) - .setCustomId("cancel-delete-data"), - ) - .toJSON(), - ], + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { + await router.dispatch(interaction, "/delete-data", { flags: [MessageFlags.Ephemeral], }); - - const confirmation = await response - .awaitMessageComponent({ - filter: (i) => i.user.id === interaction.user.id, - time: 60_000, - }) - .catch(() => ({ - customId: "cancel-delete-data", - update: interaction.editReply.bind(interaction), - })); - - let hadUserData = false; // check if there even was any user data to begin with - if (confirmation.customId === "confirm-delete-data") { - hadUserData = await deleteAllUserData(interaction.user.id); - } - - await confirmation.update({ - embeds: [ - new EmbedBuilder() - .setColor("#ca2b2b") - .setTitle("Delete all Data") - .setDescription( - "Are you sure you want to delete all your data? This will remove all your filters, signups, and other account settings. This is irreversible.", - ), - ], - components: [ - new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setLabel( - confirmation.customId === "confirm-delete-data" - ? hadUserData - ? "Deleted Data" - : "No Data to Delete" - : "Delete", - ) - .setDisabled(true) - .setStyle(ButtonStyle.Danger) - .setCustomId("confirm-delete-data"), - ) - .addComponents( - new ButtonBuilder() - .setLabel( - confirmation.customId === "confirm-delete-data" - ? "Cancel" - : "Cancelled", - ) - .setDisabled(true) - .setStyle(ButtonStyle.Secondary) - .setCustomId("cancel-delete-data"), - ) - .toJSON(), - ], - }); }, }; diff --git a/src/commands/filter/block.ts b/src/commands/filter/block.ts index 78668de..3ac150e 100644 --- a/src/commands/filter/block.ts +++ b/src/commands/filter/block.ts @@ -4,8 +4,7 @@ import { SlashCommandBuilder, } from "discord.js"; -import { CommandName } from "@commands/commandNames"; -import { addFilterEntry, getFilter } from "@db/filters"; +import { RingRouter } from "@routes/types"; export const block = { data: new SlashCommandBuilder() @@ -17,42 +16,12 @@ export const block = { .setDescription("Select a user to block") .setRequired(true), ), - async execute( - interaction: ChatInputCommandInteraction, - commandIds: Map, - ) { - const user = interaction.user; - const blockedUser = interaction.options.getUser("user", true); - - const globalFilter = await getFilter(user.id, null); - - if (globalFilter?.isWhitelist) { - interaction - .reply({ - content: `Your global filter is a whitelist. Either change it to a blacklist () or use instead`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - return; - } - - if (globalFilter?.entries.has(blockedUser.id)) { - interaction - .reply({ - content: `${blockedUser} is already blocked`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - return; - } - - // otherwise, add the user to the global filter - await addFilterEntry(user.id, null, blockedUser.id); - interaction - .reply({ - content: `${blockedUser} has been blocked`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { + const user = interaction.options.getUser("user", true); + await router.dispatch(interaction, "/filter/global/members", { + method: "POST", + queryParams: { intent: "block", add: user.id }, + flags: [MessageFlags.Ephemeral], + }); }, }; diff --git a/src/commands/filter/filter.ts b/src/commands/filter/filter.ts index 952f4c2..eeb8faa 100644 --- a/src/commands/filter/filter.ts +++ b/src/commands/filter/filter.ts @@ -1,257 +1,27 @@ import { ChannelType, ChatInputCommandInteraction, - EmbedBuilder, MessageFlags, SlashCommandBuilder, } from "discord.js"; -import { - addFilterEntry, - filterType, - getFilter, - removeFilterEntry, - resetFilter, - setFilterType, -} from "@db/filters"; -import { mentionUser } from "@main/ring"; +import { RingRouter } from "@routes/types"; export const filter = { data: new SlashCommandBuilder() .setName("filter") - .setDescription( - "Configure your global filter or specified voice chat filter", - ) - .addSubcommand((subcommand) => - subcommand - .setName("help") - .setDescription("Show information for the /filter command"), - ) - .addSubcommand((subcommand) => - subcommand - .setName("get") - .setDescription("Get the global filter or specified voice chat filter") - .addChannelOption((option) => - option - .setName("channel") - .setDescription("The channel to get the filter for") - .addChannelTypes(ChannelType.GuildVoice) - .setRequired(false), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("reset") - .setDescription("Resets a filter. Also resets to blacklist") - .addChannelOption((option) => - option - .setName("channel") - .setDescription( - "Resets this voice channel's filter. Leave blank for your global filter", - ) - .addChannelTypes(ChannelType.GuildVoice) - .setRequired(false), - ), - ) - .addSubcommandGroup((group) => - group - .setName("edit") - .setDescription("Edit a filter") - .addSubcommand((subcommand) => - subcommand - .setName("users") - .setDescription("Add or remove a user from the filter list") - .addIntegerOption((option) => - option - .setName("action") - .setDescription("Choose to add or remove users") - .addChoices( - { name: "Add", value: 1 }, - { name: "Remove", value: 0 }, - ) - .setRequired(true), - ) - .addUserOption((option) => - option - .setName("user") - .setDescription( - "Person that will be added or removed from the filter", - ) - .setRequired(true), - ) - .addChannelOption((option) => - option - .setName("channel") - .setDescription( - "Which channel's filter to modify. Leave blank to edit your global filter", - ) - .addChannelTypes(ChannelType.GuildVoice) - .setRequired(false), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("type") - .setDescription("Change the filter type. WARNING: RESETS LIST") - .addStringOption((option) => - option - .setName("filter_type") - .setDescription( - "Choose the new filter type. If the filter is already that type, nothing changes", - ) - .addChoices( - { name: "Whitelist", value: "whitelist" }, - { name: "Blacklist", value: "blacklist" }, - ) - .setRequired(true), - ) - .addChannelOption((option) => - option - .setName("channel") - .setDescription( - "Which channel's filter to modify. Leave blank to edit your global filter", - ) - .addChannelTypes(ChannelType.GuildVoice) - .setRequired(false), - ), - ), + .setDescription("View and edit your global or voice channel filter") + .addChannelOption((option) => + option + .setName("channel") + .setDescription("The channel whose filter to open (global if omitted)") + .addChannelTypes(ChannelType.GuildVoice) + .setRequired(false), ), - async execute(interaction: ChatInputCommandInteraction) { - const subcommand = interaction.options.getSubcommand(); - const currentUser = interaction.user; // user who started the command + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { const channel = interaction.options.getChannel("channel"); - // "your global " or "your for #channel" - const scopeName = (noun: string) => - channel ? `your ${noun} for ${channel}` : `your global ${noun}`; - // for the start of a sentence - const scopeNameCapitalized = (noun: string) => - channel ? `Your ${noun} for ${channel}` : `Your global ${noun}`; - - if (subcommand === "help") { - // Show help information - interaction.reply({ - embeds: [ - new EmbedBuilder() - .setColor("#94ab62") - .setTitle("/filter") - .setDescription("This command allows you to configure your filters") - .addFields( - { name: "help", value: "Show this help message" }, - { - name: "edit users", - value: - "Add or remove users from either your global filter or specified channel filter", - }, - { - name: "edit type", - value: - "Change the filter type of your global filter or specified channel filter.\n*WARNING: RESETS LIST*", - }, - { - name: "get", - value: - "View either your global filter or specified channel filter", - }, - { - name: "reset", - value: - "Resets either your global filter or specified channel filter", - }, - ), - ], - flags: [MessageFlags.Ephemeral], - }); - } else if (subcommand === "get") { - const filter = await getFilter(currentUser.id, channel?.id ?? null); - const userList = filter - ? [...filter.entries] - .map((userId) => `${mentionUser(userId)}\n`) - .join("") - : ""; - interaction - .reply({ - content: `__List of people in ${scopeName(filterType(filter))}__\n${userList ? userList : "None"}`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } else if (subcommand === "users") { - // modifying users list - const addOrRemove = interaction.options.getInteger("action", true); // 1 for add 0 for remove - const user = interaction.options.getUser("user", true); - - const filter = await getFilter(currentUser.id, channel?.id ?? null); - const type = filterType(filter); - - if (addOrRemove === 1) { - await addFilterEntry(currentUser.id, channel?.id ?? null, user.id); - interaction - .reply({ - content: `Added ${user} to ${scopeName(type)}`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } else { - const removed = await removeFilterEntry( - currentUser.id, - channel?.id ?? null, - user.id, - ); - interaction - .reply({ - content: removed - ? `Removed ${user} from ${scopeName(type)}` - : `${user} was not in ${scopeName(type)}`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } - } else if (subcommand === "type") { - const type = interaction.options.getString("filter_type", true); // string of either "whitelist" or "blacklist" - if (type !== "whitelist" && type !== "blacklist") return; - if (channel && channel.type !== ChannelType.GuildVoice) { - interaction - .reply({ - content: `Filters are only available on voice channels`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - return; // stop the rest of function - } - - const filter = await getFilter(currentUser.id, channel?.id ?? null); - if (filterType(filter) === type) { - interaction - .reply({ - content: `${scopeNameCapitalized("filter")} is already a \`${type}\``, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } else { - await setFilterType( - currentUser.id, - channel?.id ?? null, - type === "whitelist", - ); - interaction - .reply({ - content: `${scopeNameCapitalized("filter")} was reset and changed to a \`${type}\``, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } - } else if (subcommand === "reset") { - const wasNotDefault = await resetFilter( - currentUser.id, - channel?.id ?? null, - ); - interaction - .reply({ - content: wasNotDefault - ? `${scopeNameCapitalized("filter")} has been reset and is now a blacklist` - : `${scopeNameCapitalized("filter")} is already the default (blacklist with no users)`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } + await router.dispatch(interaction, `/filter/${channel?.id ?? "global"}`, { + flags: [MessageFlags.Ephemeral], + }); }, }; diff --git a/src/commands/filter/unblock.ts b/src/commands/filter/unblock.ts index d0ed136..33914e4 100644 --- a/src/commands/filter/unblock.ts +++ b/src/commands/filter/unblock.ts @@ -4,8 +4,7 @@ import { SlashCommandBuilder, } from "discord.js"; -import { CommandName } from "@commands/commandNames"; -import { getFilter, removeFilterEntry } from "@db/filters"; +import { RingRouter } from "@routes/types"; export const unblock = { data: new SlashCommandBuilder() @@ -17,42 +16,12 @@ export const unblock = { .setDescription("Select a user to unblock") .setRequired(true), ), - async execute( - interaction: ChatInputCommandInteraction, - commandIds: Map, - ) { - const user = interaction.user; - const blockedUser = interaction.options.getUser("user", true); - - const globalFilter = await getFilter(user.id, null); - - if (globalFilter?.isWhitelist) { - interaction - .reply({ - content: `Your global filter is a whitelist. Either change it to a blacklist () or use instead`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - return; - } - - if (!globalFilter?.entries.has(blockedUser.id)) { - interaction - .reply({ - content: `${blockedUser} isn't blocked`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - return; - } - - // otherwise, remove the user from the global filter - await removeFilterEntry(user.id, null, blockedUser.id); - interaction - .reply({ - content: `${blockedUser} has been unblocked`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { + const user = interaction.options.getUser("user", true); + await router.dispatch(interaction, "/filter/global/members", { + method: "POST", + queryParams: { intent: "block", remove: user.id }, + flags: [MessageFlags.Ephemeral], + }); }, }; diff --git a/src/commands/filter/unwhitelist.ts b/src/commands/filter/unwhitelist.ts index 9cc9f82..3579af5 100644 --- a/src/commands/filter/unwhitelist.ts +++ b/src/commands/filter/unwhitelist.ts @@ -4,8 +4,7 @@ import { SlashCommandBuilder, } from "discord.js"; -import { CommandName } from "@commands/commandNames"; -import { getFilter, removeFilterEntry } from "@db/filters"; +import { RingRouter } from "@routes/types"; export const unwhitelist = { data: new SlashCommandBuilder() @@ -17,42 +16,12 @@ export const unwhitelist = { .setDescription("Select a user to unwhitelist") .setRequired(true), ), - async execute( - interaction: ChatInputCommandInteraction, - commandIds: Map, - ) { - const user = interaction.user; - const whitelistUser = interaction.options.getUser("user", true); - - const globalFilter = await getFilter(user.id, null); - - if (!globalFilter?.isWhitelist) { - interaction - .reply({ - content: `Your global filter is not a whitelist. Either change it to a whitelist () or use instead`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - return; - } - - if (!globalFilter.entries.has(whitelistUser.id)) { - interaction - .reply({ - content: `${whitelistUser} isn't whitelisted`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - return; - } - - // otherwise, remove the user from the global filter - await removeFilterEntry(user.id, null, whitelistUser.id); - interaction - .reply({ - content: `${whitelistUser} has been unwhitelisted`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { + const user = interaction.options.getUser("user", true); + await router.dispatch(interaction, "/filter/global/members", { + method: "POST", + queryParams: { intent: "whitelist", remove: user.id }, + flags: [MessageFlags.Ephemeral], + }); }, }; diff --git a/src/commands/filter/whitelist.ts b/src/commands/filter/whitelist.ts index bcde5a9..f5735a9 100644 --- a/src/commands/filter/whitelist.ts +++ b/src/commands/filter/whitelist.ts @@ -4,8 +4,7 @@ import { SlashCommandBuilder, } from "discord.js"; -import { CommandName } from "@commands/commandNames"; -import { addFilterEntry, getFilter } from "@db/filters"; +import { RingRouter } from "@routes/types"; export const whitelist = { data: new SlashCommandBuilder() @@ -17,42 +16,12 @@ export const whitelist = { .setDescription("Select a user to whitelist") .setRequired(true), ), - async execute( - interaction: ChatInputCommandInteraction, - commandIds: Map, - ) { - const user = interaction.user; - const whitelistedUser = interaction.options.getUser("user", true); - - const globalFilter = await getFilter(user.id, null); - - if (!globalFilter?.isWhitelist) { - interaction - .reply({ - content: `Your global filter is not a whitelist. Either change it to a whitelist () or use instead`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - return; - } - - if (globalFilter.entries.has(whitelistedUser.id)) { - interaction - .reply({ - content: `${whitelistedUser} is already whitelisted`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - return; - } - - // otherwise, add the user to the global filter - await addFilterEntry(user.id, null, whitelistedUser.id); - interaction - .reply({ - content: `${whitelistedUser} has been whitelisted`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { + const user = interaction.options.getUser("user", true); + await router.dispatch(interaction, "/filter/global/members", { + method: "POST", + queryParams: { intent: "whitelist", add: user.id }, + flags: [MessageFlags.Ephemeral], + }); }, }; diff --git a/src/commands/help.ts b/src/commands/help.ts index 34278f6..b6c9e18 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -1,93 +1,18 @@ import { - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, ChatInputCommandInteraction, - EmbedBuilder, MessageFlags, SlashCommandBuilder, } from "discord.js"; -import { CommandName } from "@commands/commandNames"; +import { RingRouter } from "@routes/types"; export const help = { data: new SlashCommandBuilder() .setName("help") - .setDescription("Getting started"), - async execute( - interaction: ChatInputCommandInteraction, - commandIds: Map, - ) { - interaction - .reply({ - embeds: [ - new EmbedBuilder() - .setColor("#b574c5") - .setTitle("Getting Started") - .setDescription( - "RingVC is a bot which tries to replicate Group Chat Voice Calls in Discord Servers", - ) - .addFields( - { - name: "Signing up", - value: `Use to start being rung when someone "starts" a call in the specified voice channel`, - }, - { - name: "Role signups", - value: `Admins can use to sign up an entire role for a voice channel. When someone joins, the role gets pinged. Use to remove a role. *Individual signups won't double-ping users who have a signed-up role*`, - }, - { - name: "Quitting", - value: `Use or to stop being rung for a voice channel`, - }, - { - name: "Ringing", - value: `What the bot was named after. Use to ping someone to join once you're in a voice channel`, - }, - { - name: "Blocking people", - value: `Don't want to be rung by someone? Use to block people, and to unblock them. *This means you won't be pinged if they "start" a voice call, however, once an unblocked person joins, you will be pinged*`, - }, - { - name: "Modes", - value: `Allows you to not ping people when joining a voice channel. Use for more info`, - }, - { - name: "Auto and Default Ringing", - value: `To configure your default ring recipients and auto ring, see `, - }, - { - name: "Other commands", - value: `Other than the basics above, see to manage your filters more in depth`, - }, - { - name: "Support Server", - value: `If something isn't working for you, or you'd like to suggest some new features, feel free to join the support server linked below! Or just say hello :)`, - }, - { - name: "Github", - value: `Check out the github linked below if you want to host your own version of this bot, or just view the code`, - }, - ), - ], - components: [ - new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setLabel("Github") - .setStyle(ButtonStyle.Link) - .setURL("https://github.com/altrup/RingVC"), - ) - .addComponents( - new ButtonBuilder() - .setLabel("Support Server") - .setStyle(ButtonStyle.Link) - .setURL("https://discord.gg/bxBePEnndq"), - ) - .toJSON(), - ], - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); + .setDescription("Getting started and the full command list"), + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { + await router.dispatch(interaction, "/help", { + flags: [MessageFlags.Ephemeral], + }); }, }; diff --git a/src/commands/mode.ts b/src/commands/mode.ts index b53048f..9b8f5e2 100644 --- a/src/commands/mode.ts +++ b/src/commands/mode.ts @@ -1,112 +1,20 @@ import { ChatInputCommandInteraction, - EmbedBuilder, MessageFlags, SlashCommandBuilder, } from "discord.js"; -import { CommandName } from "@commands/commandNames"; -import { getUserMode, setUserMode } from "@db/users"; +import { RingRouter } from "@routes/types"; export const mode = { data: new SlashCommandBuilder() .setName("mode") - .setDescription("Change modes between invis, normal, and auto") - .addSubcommand((subcommand) => - subcommand - .setName("help") - .setDescription("Prints some information about what the modes do"), - ) - .addSubcommand((subcommand) => - subcommand - .setName("set") - .setDescription("Sets your mode") - .addStringOption((option) => - option - .setName("mode") - .setDescription("Which mode to switch to") - .addChoices( - { name: "Normal", value: "normal" }, - { name: "Stealth", value: "stealth" }, - { name: "Auto", value: "auto" }, - ) - .setRequired(true), - ), - ) - .addSubcommand((subcommand) => - subcommand.setName("get").setDescription("Displays your current mode"), + .setDescription( + "View and change what happens when you join a voice channel", ), - async execute( - interaction: ChatInputCommandInteraction, - commandIds: Map, - ) { - // display help message - if (interaction.options.getSubcommand() === "help") { - interaction - .reply({ - embeds: [ - new EmbedBuilder() - .setColor("#F5853F") - .setTitle("Mode Info") - .setDescription( - `Use to change your mode, and to see your current mode. \n Modes determine what happens when you join a voice channel`, - ) - .addFields( - { - name: "Normal", - value: - "Joining a voice channel will ring all applicable users", - }, - { - name: "Stealth", - value: "Joining a voice channel will not ring anyone", - }, - { - name: "Auto", - value: - "Sets mode to Stealth, if you are Invisible on Discord, and Normal otherwise", - }, - ), - ], - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } - // set mode - else if (interaction.options.getSubcommand() === "set") { - const mode = interaction.options.getString("mode", true); - // the option choices only allow valid modes - if (mode !== "normal" && mode !== "stealth" && mode !== "auto") return; - - await setUserMode(interaction.user.id, mode); - interaction - .reply({ - content: `Mode set to \`${mode}\`. ${ - mode === "auto" - ? "Your mode will automatically switch to stealth when you are invisible on Discord" - : mode === "stealth" - ? "You will not ring anyone when you join a voice channel" - : "You will ring all applicable users when you join a voice channel" - }`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } - // get mode - else if (interaction.options.getSubcommand() === "get") { - const mode = await getUserMode(interaction.user.id); - interaction - .reply({ - content: `Your current mode is \`${mode}\`. ${ - mode === "auto" - ? "Your mode will automatically switch to stealth when you are invisible on Discord" - : mode === "stealth" - ? "You will not ring anyone when you join a voice channel" - : "You will ring all applicable users when you join a voice channel" - }`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { + await router.dispatch(interaction, "/mode", { + flags: [MessageFlags.Ephemeral], + }); }, }; diff --git a/src/commands/quit.ts b/src/commands/quit.ts index 8a9ab64..9406ac8 100644 --- a/src/commands/quit.ts +++ b/src/commands/quit.ts @@ -1,4 +1,4 @@ -// literally the same as signup.js but with a different name +// the same behavior as unsignup, under the name people reach for import { ChannelType, ChatInputCommandInteraction, @@ -6,6 +6,7 @@ import { } from "discord.js"; import { unsignup } from "@commands/unsignup"; +import { RingRouter } from "@routes/types"; export const quit = { data: new SlashCommandBuilder() @@ -20,7 +21,7 @@ export const quit = { .addChannelTypes(ChannelType.GuildVoice) .setRequired(false), ), - async execute(interaction: ChatInputCommandInteraction) { - await unsignup.execute(interaction); + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { + await unsignup.execute(router, interaction); }, }; diff --git a/src/commands/ring.ts b/src/commands/ring.ts index 2ea5533..aa51f45 100644 --- a/src/commands/ring.ts +++ b/src/commands/ring.ts @@ -1,223 +1,33 @@ import { - ActionRowBuilder, ChatInputCommandInteraction, - ComponentType, - GuildMember, MessageFlags, SlashCommandBuilder, - UserSelectMenuBuilder, } from "discord.js"; -import { CommandName } from "@commands/commandNames"; -import { - joinWithAnd, - mentionUser, - ringDefaultUsers, - ring as ringUsers, -} from "@main/ring"; - -const ringUserSelectMenuBuilder = (disabled = false) => { - return new ActionRowBuilder() - .addComponents( - new UserSelectMenuBuilder() - .setPlaceholder("Select up to 25 users") - .setMinValues(1) - .setMaxValues(25) - .setDisabled(disabled) - .setCustomId("list-ring-users"), - ) - .toJSON(); -}; +import { RingRouter } from "@routes/types"; export const ring = { data: new SlashCommandBuilder() .setName("ring") - .setDescription("Rings specified user(s)") - .addSubcommand((subcommand) => - subcommand + .setDescription("Ring people into your voice channel") + .addUserOption((option) => + option .setName("user") - .setDescription("Ring a user") - .addUserOption((option) => - option - .setName("user") - .setDescription("Select a user") - .setRequired(true), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("users") - .setDescription("Ring multiple users (select after running command)"), - ) - .addSubcommand((subcommand) => - subcommand - .setName("default") - .setDescription("Ring all of your default ring recipients"), + .setDescription("Ring this user right away (leave blank for the panel)") + .setRequired(false), ), - async execute( - interaction: ChatInputCommandInteraction, - commandIds: Map, - ) { - // command must be run in a guild - if (!interaction.member) { - interaction - .reply({ - content: `This command must be run in a Discord server`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - return; - } - - const channel = (interaction.member as GuildMember).voice.channel; - // if channel doesn't exist (user not in call) - if (!channel) { - // don't send dm - interaction - .reply({ - content: `Please join a vc first`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - return; - } - - if (interaction.options.getSubcommand() === "user") { - const user = interaction.options.getUser("user", true); - // send the user an invite link to the voice channel or text channel that the interaction creator is in - const results = await Promise.allSettled([ - interaction.deferReply({ flags: [MessageFlags.Ephemeral] }), - ringUsers(channel, interaction.user.id, "wants you to join", [user.id]), - ]); - - // if deferReply failed, then there isn't a reply to edit - if (results[0].status === "rejected") return; - // otherwise, edit the reply to update about the ring - if (results[1].status === "fulfilled") { - const result = results[1].value[0]; - interaction - .editReply({ - content: - result?.status === "fulfilled" - ? `Notified ${user}` - : `Can't ring ${mentionUser(user.id)}${result ? ` because ${result.error.message}` : ``}`, - }) - .catch(console.error); - } else { - interaction - .editReply({ - content: `Can't ring ${user} because ${results[1].reason.message}`, - }) - .catch(console.error); - } - } else if (interaction.options.getSubcommand() === "users") { - const response = await interaction.reply({ - content: `Select the users you want to ring`, - components: [ringUserSelectMenuBuilder(false)], + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { + const user = interaction.options.getUser("user"); + if (user) { + await router.dispatch(interaction, "/ring/user", { + method: "POST", + queryParams: { id: user.id }, flags: [MessageFlags.Ephemeral], }); - - const selection = await response - .awaitMessageComponent({ - componentType: ComponentType.UserSelect, - filter: (i) => i.user.id === interaction.user.id, - time: 60_000, - }) - .catch(() => null); - - if (!selection) { - await interaction - .editReply({ - content: `Timed out, no users were ringed`, - components: [ringUserSelectMenuBuilder(true)], - }) - .catch(console.error); - return; - } - - const results = await Promise.allSettled([ - selection.update({ - content: `Ringing users`, - }), - ringUsers( - channel, - interaction.user.id, - "wants you to join", - selection.values, - ), - ]); - - if (results[1].status === "fulfilled") { - const ringedList = joinWithAnd( - results[1].value - .filter((r) => r.status === "fulfilled") - .map((r) => mentionUser(r.userId)), - ); - selection - .editReply({ - content: [ - ...(ringedList.length > 0 ? [`Ringed ${ringedList}`] : []), - ...results[1].value - .filter((r) => r.status === "rejected") - .map( - (r) => - `Can't ring ${mentionUser(r.userId)} because ${r.error.message}`, - ), - ].join("\n"), - components: [ringUserSelectMenuBuilder(true)], - }) - .catch(console.error); - } else { - selection - .editReply({ - content: `Can't ring ${joinWithAnd(selection.values.map(mentionUser))} because ${results[1].reason.message}`, - components: [ringUserSelectMenuBuilder(true)], - }) - .catch(console.error); - } - } else if (interaction.options.getSubcommand() === "default") { - // ring all default users - Promise.allSettled([ - interaction.deferReply({ flags: [MessageFlags.Ephemeral] }), - ringDefaultUsers(channel, interaction.user.id, "wants you to join"), - ]).then((results) => { - // if deferReply failed, then there isn't a reply to edit - if (results[0].status === "rejected") return; - // otherwise, edit the reply to update about the ring - if (results[1].status === "fulfilled") { - const ringedList = joinWithAnd( - results[1].value - .filter((r) => r.status === "fulfilled") - .map((r) => mentionUser(r.userId)), - ); - interaction - .editReply({ - content: [ - ...(ringedList.length > 0 ? [`Ringed ${ringedList}`] : []), - ...results[1].value - .filter((r) => r.status === "rejected") - .map( - (r) => - `Can't ring ${mentionUser(r.userId)} because ${r.error.message}`, - ), - ].join("\n"), - }) - .catch(console.error); - } else { - interaction - .editReply({ - content: - `Can't ring default users because ${results[1].reason.message}` + - ([ - "no default users to ring", - "no default users for whom you passed each other's filters", - ].includes(results[1].reason.message) - ? `. Use to add some` - : ""), - }) - .catch(console.error); - } - }); + return; } + await router.dispatch(interaction, "/ring", { + flags: [MessageFlags.Ephemeral], + }); }, }; diff --git a/src/commands/ringDefaults.ts b/src/commands/ringDefaults.ts new file mode 100644 index 0000000..2471cc6 --- /dev/null +++ b/src/commands/ringDefaults.ts @@ -0,0 +1,19 @@ +import { + ChatInputCommandInteraction, + MessageFlags, + SlashCommandBuilder, +} from "discord.js"; + +import { RingRouter } from "@routes/types"; + +export const ringDefaults = { + data: new SlashCommandBuilder() + .setName("ring_defaults") + .setDescription("Ring your default recipients into your voice channel"), + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { + await router.dispatch(interaction, "/ring/default", { + method: "POST", + flags: [MessageFlags.Ephemeral], + }); + }, +}; diff --git a/src/commands/ringvc.ts b/src/commands/ringvc.ts new file mode 100644 index 0000000..0a84c74 --- /dev/null +++ b/src/commands/ringvc.ts @@ -0,0 +1,18 @@ +import { + ChatInputCommandInteraction, + MessageFlags, + SlashCommandBuilder, +} from "discord.js"; + +import { RingRouter } from "@routes/types"; + +export const ringvc = { + data: new SlashCommandBuilder() + .setName("ringvc") + .setDescription("Open the RingVC home panel"), + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { + await router.dispatch(interaction, "/", { + flags: [MessageFlags.Ephemeral], + }); + }, +}; diff --git a/src/commands/signup.ts b/src/commands/signup.ts index 6865893..2c9a226 100644 --- a/src/commands/signup.ts +++ b/src/commands/signup.ts @@ -1,15 +1,11 @@ import { - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, ChannelType, ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder, } from "discord.js"; -import { CommandName } from "@commands/commandNames"; -import { addVoiceChatUser } from "@db/voice-chats"; +import { RingRouter } from "@routes/types"; export const signup = { data: new SlashCommandBuilder() @@ -22,46 +18,24 @@ export const signup = { .addChannelTypes(ChannelType.GuildVoice) .setRequired(false), ), - async execute( - interaction: ChatInputCommandInteraction, - commandIds: Map, - ) { + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { + // with an explicit channel, or run bare inside a VC's text chat, keep + // the quick-signup behavior; bare anywhere else opens the panel const channel = - interaction.options.getChannel("channel") || interaction.channel; - const user = interaction.user; - if (!channel || channel.type !== ChannelType.GuildVoice) { - const moreInfo = new ButtonBuilder() - .setLabel("Text Channels in Voice Channels") - .setStyle(ButtonStyle.Link) - .setURL( - "https://support.discord.com/hc/en-us/articles/4412085582359-Text-Channels-Text-Chat-In-Voice-Channels", - ); - interaction - .reply({ - content: `Please select a channel, or run this command in the Voice Channel you want to sign up for`, - flags: [MessageFlags.Ephemeral], - components: [new ActionRowBuilder().addComponents(moreInfo).toJSON()], - }) - .catch(console.error); - return; // stop the rest of function - } - - const added = await addVoiceChatUser(channel.id, user.id); - if (!added) { - interaction - .reply({ - content: `You are already signed up for <#${channel.id}>. Use to unsignup`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - return; // stops the rest of the function - } - - interaction - .reply({ - content: `Signed up for <#${channel.id}>. Use to unsignup`, + interaction.options.getChannel("channel") ?? + (interaction.channel?.type === ChannelType.GuildVoice + ? interaction.channel + : null); + if (channel) { + await router.dispatch(interaction, "/signups/members", { + method: "POST", + queryParams: { add: channel.id }, flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); + }); + return; + } + await router.dispatch(interaction, "/signups", { + flags: [MessageFlags.Ephemeral], + }); }, }; diff --git a/src/commands/signuprole.ts b/src/commands/signuprole.ts index 34d14bb..4a87cbf 100644 --- a/src/commands/signuprole.ts +++ b/src/commands/signuprole.ts @@ -1,17 +1,11 @@ import { - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, ChannelType, ChatInputCommandInteraction, - GuildMember, MessageFlags, - PermissionsBitField, SlashCommandBuilder, } from "discord.js"; -import { CommandName } from "@commands/commandNames"; -import { addVoiceChatRole } from "@db/voice-chats"; +import { RingRouter } from "@routes/types"; export const signuprole = { data: new SlashCommandBuilder() @@ -30,62 +24,28 @@ export const signuprole = { .addChannelTypes(ChannelType.GuildVoice) .setRequired(false), ), - async execute( - interaction: ChatInputCommandInteraction, - commandIds: Map, - ) { - const channel = - interaction.options.getChannel("channel") || interaction.channel; + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { const role = interaction.options.getRole("role", true); - const member = interaction.member as GuildMember; - - // Check permissions - user must have Manage Roles or be an admin - if ( - !member.permissions.has(PermissionsBitField.Flags.ManageRoles) && - !member.permissions.has(PermissionsBitField.Flags.Administrator) - ) { - interaction - .reply({ - content: `You need the "Manage Roles" permission to sign up roles for voice channels`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - return; - } - - if (!channel || channel.type !== ChannelType.GuildVoice) { - const moreInfo = new ButtonBuilder() - .setLabel("Text Channels in Voice Channels") - .setStyle(ButtonStyle.Link) - .setURL( - "https://support.discord.com/hc/en-us/articles/4412085582359-Text-Channels-Text-Chat-In-Voice-Channels", - ); - interaction - .reply({ - content: `Please select a channel, or run this command in the Voice Channel you want to sign up the role for`, - flags: [MessageFlags.Ephemeral], - components: [new ActionRowBuilder().addComponents(moreInfo).toJSON()], - }) - .catch(console.error); - return; - } - - const added = await addVoiceChatRole(channel.id, role.id); - if (!added) { - interaction - .reply({ - content: `<@&${role.id}> is already signed up for <#${channel.id}>. Use to remove it`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); + const channel = + interaction.options.getChannel("channel") ?? + (interaction.channel?.type === ChannelType.GuildVoice + ? interaction.channel + : null); + // without a channel, open the role's scoped panel to pick channels + if (!channel) { + await router.dispatch(interaction, `/signups/roles/by-role/${role.id}`, { + flags: [MessageFlags.Ephemeral], + }); return; } - - interaction - .reply({ - content: `Signed up <@&${role.id}> for <#${channel.id}>. Use to remove it`, + await router.dispatch( + interaction, + `/signups/roles/by-channel/${channel.id}/roles`, + { + method: "POST", + queryParams: { add: role.id }, flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); + }, + ); }, }; diff --git a/src/commands/unsignup.ts b/src/commands/unsignup.ts index 7bf1dc1..9e20cb0 100644 --- a/src/commands/unsignup.ts +++ b/src/commands/unsignup.ts @@ -1,14 +1,11 @@ import { - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, ChannelType, ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder, } from "discord.js"; -import { removeVoiceChatUser } from "@db/voice-chats"; +import { RingRouter } from "@routes/types"; export const unsignup = { data: new SlashCommandBuilder() @@ -21,42 +18,22 @@ export const unsignup = { .addChannelTypes(ChannelType.GuildVoice) .setRequired(false), ), - async execute(interaction: ChatInputCommandInteraction) { + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { const channel = - interaction.options.getChannel("channel") ?? interaction.channel; - const user = interaction.user; - if (!channel || channel.type !== ChannelType.GuildVoice) { - const moreInfo = new ButtonBuilder() - .setLabel("Text Channels in Voice Channels") - .setStyle(ButtonStyle.Link) - .setURL( - "https://support.discord.com/hc/en-us/articles/4412085582359-Text-Channels-Text-Chat-In-Voice-Channels", - ); - interaction - .reply({ - content: `Please select a channel, or run this command in the Voice Channel you want to un-sign up for`, - flags: [MessageFlags.Ephemeral], - components: [new ActionRowBuilder().addComponents(moreInfo).toJSON()], - }) - .catch(console.error); - return; // stop the rest of function - } - - const removed = await removeVoiceChatUser(channel.id, user.id); - if (removed) { - interaction - .reply({ - content: `You will no longer be "rung" for <#${channel.id}>`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); + interaction.options.getChannel("channel") ?? + (interaction.channel?.type === ChannelType.GuildVoice + ? interaction.channel + : null); + if (channel) { + await router.dispatch(interaction, "/signups/members", { + method: "POST", + queryParams: { remove: channel.id }, + flags: [MessageFlags.Ephemeral], + }); return; } - interaction - .reply({ - content: `You aren't signed up for <#${channel.id}>`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); + await router.dispatch(interaction, "/signups", { + flags: [MessageFlags.Ephemeral], + }); }, }; diff --git a/src/commands/unsignuprole.ts b/src/commands/unsignuprole.ts index 54fa8fe..53f2022 100644 --- a/src/commands/unsignuprole.ts +++ b/src/commands/unsignuprole.ts @@ -1,16 +1,11 @@ import { - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, ChannelType, ChatInputCommandInteraction, - GuildMember, MessageFlags, - PermissionsBitField, SlashCommandBuilder, } from "discord.js"; -import { removeVoiceChatRole } from "@db/voice-chats"; +import { RingRouter } from "@routes/types"; export const unsignuprole = { data: new SlashCommandBuilder() @@ -29,58 +24,29 @@ export const unsignuprole = { .addChannelTypes(ChannelType.GuildVoice) .setRequired(false), ), - async execute(interaction: ChatInputCommandInteraction) { - const channel = - interaction.options.getChannel("channel") ?? interaction.channel; + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { const role = interaction.options.getRole("role", true); - const member = interaction.member as GuildMember; - - // Check permissions - user must have Manage Roles or be an admin - if ( - !member.permissions.has(PermissionsBitField.Flags.ManageRoles) && - !member.permissions.has(PermissionsBitField.Flags.Administrator) - ) { - interaction - .reply({ - content: `You need the "Manage Roles" permission to remove roles from voice channels`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - return; - } - - if (!channel || channel.type !== ChannelType.GuildVoice) { - const moreInfo = new ButtonBuilder() - .setLabel("Text Channels in Voice Channels") - .setStyle(ButtonStyle.Link) - .setURL( - "https://support.discord.com/hc/en-us/articles/4412085582359-Text-Channels-Text-Chat-In-Voice-Channels", - ); - interaction - .reply({ - content: `Please select a channel, or run this command in the Voice Channel you want to remove the role from`, - flags: [MessageFlags.Ephemeral], - components: [new ActionRowBuilder().addComponents(moreInfo).toJSON()], - }) - .catch(console.error); - return; - } - - const removed = await removeVoiceChatRole(channel.id, role.id); - if (removed) { - interaction - .reply({ - content: `<@&${role.id}> will no longer be pinged for <#${channel.id}>`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); + const channel = + interaction.options.getChannel("channel") ?? + (interaction.channel?.type === ChannelType.GuildVoice + ? interaction.channel + : null); + // without a channel, open the role's panel to pick which to remove; the + // panel's Reset button covers clearing every channel behind its confirm + if (!channel) { + await router.dispatch(interaction, `/signups/roles/by-role/${role.id}`, { + flags: [MessageFlags.Ephemeral], + }); return; } - interaction - .reply({ - content: `<@&${role.id}> isn't signed up for <#${channel.id}>`, + await router.dispatch( + interaction, + `/signups/roles/by-role/${role.id}/channels`, + { + method: "POST", + queryParams: { remove: channel.id }, flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); + }, + ); }, }; diff --git a/src/index.ts b/src/index.ts index 76935b0..cb3e37c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import { EmbedRouter } from "discord-embed-router"; import { ChatInputCommandInteraction, Client, @@ -14,6 +15,8 @@ import { } from "@commands/commands"; import { DISCORD_TOKEN } from "@config"; import { onVoiceChannelJoin } from "@main/ring"; +import { registerRoutes } from "@routes/index"; +import { Globals } from "@routes/types"; const client = new Client({ intents: [ @@ -24,12 +27,35 @@ const client = new Client({ partials: [Partials.Channel], }); +// the router handles every component/modal interaction its builders created; +// commands dispatch into it explicitly below +const router = new EmbedRouter(client, { name: "ringvc" }); +router.onError((err, interaction) => { + console.error(err); + if ( + interaction && + "reply" in interaction && + !interaction.replied && + !interaction.deferred + ) { + interaction + .reply({ + content: "Something went wrong handling that interaction", + flags: [MessageFlags.Ephemeral], + }) + .catch(console.error); + } +}); + +const commandIds = new Map(); +router.setGlobals({ commandIds }); +registerRoutes(router); + // load commands const commands = new Collection(); for (const command of commandsArray) { commands.set(command.data.name, command); } -const commandIds = new Map(); // When the client is ready, set status client.once("clientReady", async () => { @@ -46,6 +72,10 @@ client.once("clientReady", async () => { console.error("Unknown command was registered: ", command.name); } }); + + // populate client.application.emojis.cache so branded emoji on buttons + // resolve; the cache is empty until fetched + await client.application?.emojis.fetch(); }); client.on("shardError", async () => { // console.log('disconnected'); @@ -70,7 +100,7 @@ client.on("interactionCreate", async (interaction) => { try { if (interaction instanceof ChatInputCommandInteraction) { - await command.execute(interaction, commandIds); + await command.execute(router, interaction); } } catch (error) { console.error(error); diff --git a/src/main/db/auto-ring.ts b/src/main/db/auto-ring.ts index 65d1f2b..266250f 100644 --- a/src/main/db/auto-ring.ts +++ b/src/main/db/auto-ring.ts @@ -42,12 +42,21 @@ export const setAutoRing = async ( enabled: boolean, ): Promise => { const current = await getAutoRingSetting(userId, channelId); - // for the global scope, an unset value behaves as false - if ( - current === enabled || - (channelId === null && current === undefined && !enabled) - ) + + if (channelId !== null) { + // a channel override only exists when it differs from the global + // setting, so setting it back to the global value drops it entirely + const globalEnabled = (await getAutoRingSetting(userId, null)) ?? false; + const effective = current ?? globalEnabled; + if (enabled === effective) return false; + if (enabled === globalEnabled) { + await unsetAutoRing(userId, channelId); + return true; + } + } else if (current === enabled || (current === undefined && !enabled)) { + // global scope: an unset value behaves as false return false; + } await ensureUser(userId); throwOnError( diff --git a/src/main/db/default-ringees.ts b/src/main/db/default-ringees.ts index 61a7eb5..e8c2c52 100644 --- a/src/main/db/default-ringees.ts +++ b/src/main/db/default-ringees.ts @@ -78,7 +78,7 @@ export const removeDefaultRingee = async ( }; // returns whether the list had any ringees -export const clearDefaultRingees = async ( +export const resetDefaultRingees = async ( userId: string, channelId: string | null, ): Promise => { diff --git a/src/main/db/filters.ts b/src/main/db/filters.ts index c0eefe8..ae1a3df 100644 --- a/src/main/db/filters.ts +++ b/src/main/db/filters.ts @@ -177,7 +177,8 @@ export const removeFilterEntry = async ( return deleted.length > 0; }; -// sets the filter type and clears its entries +// sets the filter type, keeping its entries (the same list is reinterpreted: +// a blacklist's blocked users become a whitelist's allowed users) export const setFilterType = async ( userId: string, channelId: string | null, @@ -192,12 +193,6 @@ export const setFilterType = async ( { onConflict: "user_id,channel_id" }, ), ); - throwOnError( - await scopeFilter( - db.from("filter_entries").delete().eq("user_id", userId), - channelId, - ), - ); }; // deletes the filter for a scope entirely (back to the default: an empty diff --git a/src/main/db/voice-chats.ts b/src/main/db/voice-chats.ts index 28f70fd..29beee6 100644 --- a/src/main/db/voice-chats.ts +++ b/src/main/db/voice-chats.ts @@ -27,6 +27,37 @@ export const getVoiceChatSignups = async ( }; }; +// every channel the user is signed up for, across all guilds; callers +// scope the result to a guild by intersecting with its channels +export const getUserVoiceChatSignups = async ( + userId: string, +): Promise => { + const rows = rowsOf( + await db + .from("voice_chat_users") + .select("channel_id") + .eq("user_id", userId), + ); + return rows.map((row) => row.channel_id); +}; + +// role signups for a set of channels (typically one guild's voice channels) +export const getVoiceChatRoleSignups = async ( + channelIds: string[], +): Promise<{ channelId: string; roleId: string }[]> => { + if (channelIds.length === 0) return []; + const rows = rowsOf( + await db + .from("voice_chat_roles") + .select("channel_id, role_id") + .in("channel_id", channelIds), + ); + return rows.map((row) => ({ + channelId: row.channel_id, + roleId: row.role_id, + })); +}; + // returns whether the user was newly signed up (false if already signed up) export const addVoiceChatUser = async ( channelId: string, diff --git a/src/main/ring.test.ts b/src/main/ring.test.ts new file mode 100644 index 0000000..4c09286 --- /dev/null +++ b/src/main/ring.test.ts @@ -0,0 +1,274 @@ +import { User, VoiceBasedChannel } from "discord.js"; +import { beforeEach, expect, test, vi } from "vitest"; + +import { isAutoRingEnabled } from "@db/auto-ring"; +import { getAllDefaultRingees } from "@db/default-ringees"; +import { getFiltersForUsers, UserFilters } from "@db/filters"; +import { DiscordUserMode, getUserModes } from "@db/users"; +import { getVoiceChatSignups } from "@db/voice-chats"; +import { onVoiceChannelJoin, ring, validateRing } from "@main/ring"; + +vi.mock("@db/filters", async (importOriginal) => ({ + ...(await importOriginal()), + getFiltersForUsers: vi.fn(), +})); +vi.mock("@db/users", () => ({ + getUserModes: vi.fn(), +})); +vi.mock("@db/voice-chats", () => ({ + getVoiceChatSignups: vi.fn(), +})); +vi.mock("@db/auto-ring", () => ({ + isAutoRingEnabled: vi.fn(), +})); +vi.mock("@db/default-ringees", () => ({ + getAllDefaultRingees: vi.fn(), +})); + +type MemberOverrides = { + presenceStatus?: string; + roleIds?: string[]; +}; + +const makeMember = (id: string, overrides: MemberOverrides = {}) => ({ + id, + displayName: `name-${id}`, + presence: { status: overrides.presenceStatus ?? "online" }, + roles: { + cache: { + has: (roleId: string) => overrides.roleIds?.includes(roleId) ?? false, + }, + }, +}); + +// a stub voice channel whose guild resolves any member id; ids listed in +// cannotConnect fail the Connect permission check +const makeChannel = ({ + memberIds = [] as string[], + cannotConnect = [] as string[], + memberOverrides = {} as Record, + roleIds = [] as string[], +} = {}) => { + const memberOf = (id: string) => makeMember(id, memberOverrides[id] ?? {}); + const channel = { + id: "vc1", + name: "General", + members: new Map(memberIds.map((id) => [id, memberOf(id)])), + guild: { + members: { + resolve: memberOf, + fetch: (id: string) => Promise.resolve(memberOf(id)), + }, + roles: { + resolve: (id: string) => (roleIds.includes(id) ? { id } : null), + fetch: () => Promise.resolve(null), + }, + }, + permissionsFor: (id: string) => ({ + has: () => !cannotConnect.includes(id), + }), + send: vi.fn().mockResolvedValue(undefined), + toString: () => "#General", + }; + return channel as unknown as VoiceBasedChannel & { + send: ReturnType; + }; +}; + +const asUser = (id: string) => ({ id }) as unknown as User; + +// filters where blockerId has targetId on their global blacklist +const blockedBy = ( + blockerId: string, + targetId: string, +): Map => + new Map([ + [ + blockerId, + { + channel: null, + global: { isWhitelist: false, entries: new Set([targetId]) }, + }, + ], + ]); + +const modes = (entries: Record = {}) => + new Map(Object.entries(entries)); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getFiltersForUsers).mockResolvedValue(new Map()); + vi.mocked(getUserModes).mockResolvedValue(modes()); + vi.mocked(getVoiceChatSignups).mockResolvedValue({ + userIds: [], + roleIds: [], + }); + vi.mocked(isAutoRingEnabled).mockResolvedValue(false); + vi.mocked(getAllDefaultRingees).mockResolvedValue([]); +}); + +// validateRing rules + +test("ringing yourself is rejected", () => { + expect(() => validateRing(makeChannel(), "a", "a", new Map())).toThrow( + "yourself", + ); +}); + +test("ringing someone who can't connect to the channel is rejected", () => { + expect(() => + validateRing(makeChannel({ cannotConnect: ["b"] }), "a", "b", new Map()), + ).toThrow("can't join"); +}); + +test("ringing someone already in the channel is rejected", () => { + expect(() => + validateRing(makeChannel({ memberIds: ["b"] }), "a", "b", new Map()), + ).toThrow("already in"); +}); + +test("ringing someone you blocked is rejected", () => { + expect(() => + validateRing(makeChannel(), "a", "b", blockedBy("a", "b")), + ).toThrow("you blocked"); +}); + +test("ringing someone who blocked you is rejected", () => { + expect(() => + validateRing(makeChannel(), "a", "b", blockedBy("b", "a")), + ).toThrow("blocked you"); +}); + +test("a ring with no objections passes", () => { + expect(() => validateRing(makeChannel(), "a", "b", new Map())).not.toThrow(); +}); + +// ring() + +test("ring pings only the ringees who pass validation and reports each result", async () => { + vi.mocked(getFiltersForUsers).mockResolvedValue(blockedBy("caller", "bad")); + const channel = makeChannel(); + + const results = await ring(channel, "caller", "wants you to join", [ + "good", + "bad", + ]); + + expect(results).toStrictEqual([ + { userId: "good", status: "fulfilled" }, + { userId: "bad", status: "rejected", error: expect.any(Error) }, + ]); + expect(channel.send).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ allowedMentions: { users: ["good"] } }), + ); +}); + +// signup pings on voice channel join + +test("a signed-up user is pinged when someone starts a call", async () => { + vi.mocked(getVoiceChatSignups).mockResolvedValue({ + userIds: ["s"], + roleIds: [], + }); + const channel = makeChannel({ memberIds: ["j"] }); + + await onVoiceChannelJoin(channel, asUser("j")); + + expect(channel.send).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ allowedMentions: { users: ["s"], roles: [] } }), + ); +}); + +test("a joiner in stealth mode pings nobody", async () => { + vi.mocked(getVoiceChatSignups).mockResolvedValue({ + userIds: ["s"], + roleIds: [], + }); + vi.mocked(getUserModes).mockResolvedValue(modes({ j: "stealth" })); + const channel = makeChannel({ memberIds: ["j"] }); + + await onVoiceChannelJoin(channel, asUser("j")); + + expect(channel.send).not.toHaveBeenCalled(); +}); + +test("auto mode counts as stealth while the joiner appears offline", async () => { + vi.mocked(getVoiceChatSignups).mockResolvedValue({ + userIds: ["s"], + roleIds: [], + }); + vi.mocked(getUserModes).mockResolvedValue(modes({ j: "auto" })); + const channel = makeChannel({ + memberIds: ["j"], + memberOverrides: { j: { presenceStatus: "offline" } }, + }); + + await onVoiceChannelJoin(channel, asUser("j")); + + expect(channel.send).not.toHaveBeenCalled(); +}); + +test("nobody is pinged when a call is already active in the channel", async () => { + vi.mocked(getVoiceChatSignups).mockResolvedValue({ + userIds: ["s"], + roleIds: ["r"], + }); + const channel = makeChannel({ memberIds: ["j", "other"], roleIds: ["r"] }); + + await onVoiceChannelJoin(channel, asUser("j")); + + expect(channel.send).not.toHaveBeenCalled(); +}); + +test("a signed-up user with a signed-up role is pinged via the role only", async () => { + vi.mocked(getVoiceChatSignups).mockResolvedValue({ + userIds: ["s"], + roleIds: ["r"], + }); + const channel = makeChannel({ + memberIds: ["j"], + roleIds: ["r"], + memberOverrides: { s: { roleIds: ["r"] } }, + }); + + await onVoiceChannelJoin(channel, asUser("j")); + + expect(channel.send).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ allowedMentions: { users: [], roles: ["r"] } }), + ); +}); + +// auto-ring on voice channel join + +test("auto-ring rings the joiner's default recipients when enabled", async () => { + vi.mocked(isAutoRingEnabled).mockResolvedValue(true); + vi.mocked(getAllDefaultRingees).mockResolvedValue(["d"]); + const channel = makeChannel({ memberIds: ["j"] }); + + await onVoiceChannelJoin(channel, asUser("j")); + + expect(channel.send).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + content: expect.stringContaining("wants you to join"), + allowedMentions: { users: ["d"] }, + }), + ); +}); + +test("auto-ring with no default recipients is a quiet no-op", async () => { + vi.mocked(isAutoRingEnabled).mockResolvedValue(true); + const channel = makeChannel({ memberIds: ["j"] }); + + await onVoiceChannelJoin(channel, asUser("j")); + + expect(channel.send).not.toHaveBeenCalled(); +}); + +test("default recipients are not auto-rung when auto-ring is disabled", async () => { + vi.mocked(getAllDefaultRingees).mockResolvedValue(["d"]); + const channel = makeChannel({ memberIds: ["j"] }); + + await onVoiceChannelJoin(channel, asUser("j")); + + expect(channel.send).not.toHaveBeenCalled(); +}); diff --git a/src/main/ring.ts b/src/main/ring.ts index a806213..41e2aee 100644 --- a/src/main/ring.ts +++ b/src/main/ring.ts @@ -17,6 +17,7 @@ import { getVoiceChatSignups } from "@db/voice-chats"; export const mentionUser = (userId: string) => `<@${userId}>`; export const mentionRole = (roleId: string) => `<@&${roleId}>`; +export const mentionChannel = (channelId: string) => `<#${channelId}>`; export const joinWithAnd = (list: string[]): string => { return list.length >= 2 diff --git a/src/routes/_shared.ts b/src/routes/_shared.ts new file mode 100644 index 0000000..ac30fab --- /dev/null +++ b/src/routes/_shared.ts @@ -0,0 +1 @@ +export const COLOR = "#a082c3"; diff --git a/src/routes/about/get.ts b/src/routes/about/get.ts new file mode 100644 index 0000000..5e3e3a1 --- /dev/null +++ b/src/routes/about/get.ts @@ -0,0 +1,39 @@ +import { ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js"; + +import { navBar, row } from "@routes/lib/components"; +import { withFlash } from "@routes/lib/flash"; +import { Handler } from "@routes/types"; + +const COLOR = "#5865f2"; + +const GITHUB_URL = "https://github.com/altrup/RingVC"; +const SUPPORT_URL = "https://discord.gg/bxBePEnndq"; + +export const aboutGet: Handler<"GET"> = (router, interaction, state) => { + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("โ„น๏ธ About") + .setDescription( + withFlash( + state.queryParams, + "RingVC is free and open source. Star it on GitHub, or join the support server for help and updates.", + ), + ), + ], + components: [ + row( + new ButtonBuilder() + .setLabel("Github") + .setStyle(ButtonStyle.Link) + .setURL(GITHUB_URL), + new ButtonBuilder() + .setLabel("Support Server") + .setStyle(ButtonStyle.Link) + .setURL(SUPPORT_URL), + ), + navBar(router, interaction), + ], + }; +}; diff --git a/src/routes/delete-data/_shared.ts b/src/routes/delete-data/_shared.ts new file mode 100644 index 0000000..b896830 --- /dev/null +++ b/src/routes/delete-data/_shared.ts @@ -0,0 +1,2 @@ +export const PANEL = "/delete-data"; +export const CONFIRMATION = "DELETE EVERYTHING"; diff --git a/src/routes/delete-data/delete-data.test.ts b/src/routes/delete-data/delete-data.test.ts new file mode 100644 index 0000000..6a4dd20 --- /dev/null +++ b/src/routes/delete-data/delete-data.test.ts @@ -0,0 +1,59 @@ +import { Interaction } from "discord.js"; +import { beforeEach, expect, test, vi } from "vitest"; + +import { deleteAllUserData } from "@db/users"; + +import { CONFIRMATION } from "./_shared"; +import { deleteDataPost } from "./post"; + +vi.mock("@db/users", () => ({ + deleteAllUserData: vi.fn(), +})); + +const interaction = { user: { id: "caller" } } as unknown as Interaction; + +const state = (confirmation: string) => + ({ + params: {}, + path: "/delete-data", + queryParams: new URLSearchParams(), + timestamp: 0, + fields: { getTextInputValue: () => confirmation }, + }) as unknown as Parameters[2]; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test("typing the confirmation word exactly deletes all user data", async () => { + vi.mocked(deleteAllUserData).mockResolvedValue(true); + + const result = await deleteDataPost( + undefined as never, + interaction, + state(CONFIRMATION), + ); + + expect(deleteAllUserData).toHaveBeenCalledExactlyOnceWith("caller"); + expect(result.redirect).toBe("/delete-data"); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("success"); + expect(flashParams.get("done")).toBe("1"); +}); + +test("a mismatched confirmation deletes nothing", async () => { + const result = await deleteDataPost( + undefined as never, + interaction, + state(CONFIRMATION.toLowerCase()), + ); + + expect(deleteAllUserData).not.toHaveBeenCalled(); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("warn"); + expect(flashParams.get("flash")).toContain("did not match"); +}); diff --git a/src/routes/delete-data/get.ts b/src/routes/delete-data/get.ts new file mode 100644 index 0000000..1f51f4e --- /dev/null +++ b/src/routes/delete-data/get.ts @@ -0,0 +1,40 @@ +import { RouteButtonBuilder } from "discord-embed-router"; +import { ButtonStyle, EmbedBuilder } from "discord.js"; + +import { navBar, row } from "@routes/lib/components"; +import { withFlash } from "@routes/lib/flash"; +import { Handler } from "@routes/types"; + +import { PANEL } from "./_shared"; + +const COLOR = "#da373c"; + +export const deleteDataGet: Handler<"GET"> = (router, interaction, state) => { + // set by the deletion redirect: the panel has done its job, so its + // buttons render disabled + const done = state.queryParams.get("done") === "1"; + + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("๐Ÿ—‘๏ธ Delete all data") + .setDescription( + withFlash( + state.queryParams, + "Are you sure you want to delete all your data? This removes your filters, signups, and other account settings. **This can't be undone.**", + ), + ), + ], + components: [ + row( + new RouteButtonBuilder(router) + .setLabel("Delete everything") + .setStyle(ButtonStyle.Danger) + .setDisabled(done) + .setTo(PANEL, { method: "MODAL" }), + ), + navBar(router, interaction), + ], + }; +}; diff --git a/src/routes/delete-data/modal.ts b/src/routes/delete-data/modal.ts new file mode 100644 index 0000000..f2ee9d6 --- /dev/null +++ b/src/routes/delete-data/modal.ts @@ -0,0 +1,11 @@ +import { confirmModal } from "@routes/lib/confirm"; +import { Handler } from "@routes/types"; + +import { CONFIRMATION, PANEL } from "./_shared"; + +export const deleteDataModal: Handler<"MODAL"> = (router) => + confirmModal(router, { + to: PANEL, + title: "Delete all data", + word: CONFIRMATION, + }); diff --git a/src/routes/delete-data/post.ts b/src/routes/delete-data/post.ts new file mode 100644 index 0000000..d39d1be --- /dev/null +++ b/src/routes/delete-data/post.ts @@ -0,0 +1,29 @@ +import { deleteAllUserData } from "@db/users"; +import { confirmed } from "@routes/lib/confirm"; +import { flashRedirect } from "@routes/lib/flash"; +import { Handler } from "@routes/types"; + +import { CONFIRMATION, PANEL } from "./_shared"; + +export const deleteDataPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + if (!confirmed(state.fields, CONFIRMATION)) + return flashRedirect( + PANEL, + "Confirmation text did not match, nothing was deleted", + "warn", + ); + + const hadData = await deleteAllUserData(interaction.user.id); + return flashRedirect( + PANEL, + hadData + ? "All your data has been deleted" + : "You had no stored data to delete", + hadData ? "success" : "warn", + { done: "1" }, + ); +}; diff --git a/src/routes/filter/[scope]/get.ts b/src/routes/filter/[scope]/get.ts new file mode 100644 index 0000000..a64c959 --- /dev/null +++ b/src/routes/filter/[scope]/get.ts @@ -0,0 +1,112 @@ +import { + RouteButtonBuilder, + RouteChannelSelectMenuBuilder, + RouteUserSelectMenuBuilder, +} from "discord-embed-router"; +import { + ActionRowBuilder, + ButtonStyle, + ChannelType, + EmbedBuilder, +} from "discord.js"; + +import { filterType, getFilter } from "@db/filters"; +import { + editSelectRow, + navBar, + pagedControls, + showOptionsOf, +} from "@routes/lib/components"; +import { withFlash } from "@routes/lib/flash"; +import { pagedCountLine, paginate } from "@routes/lib/paging"; +import { channelIdOf, scopeName, scopeOf } from "@routes/lib/scope"; +import { Handler } from "@routes/types"; + +import { panelPath } from "../_shared"; + +const COLOR = "#63a471"; + +export const filterGet: Handler<"GET"> = async (router, interaction, state) => { + const scope = scopeOf(state.params); + const channelId = channelIdOf(scope); + const filter = await getFilter(interaction.user.id, channelId); + const type = filterType(filter); + const entries = [...(filter?.entries ?? [])].sort(); + const { pageItems, page, pageCount } = paginate( + entries, + state.queryParams.get("page"), + ); + + const description = withFlash( + state.queryParams, + `${scopeName(scope, "filter", { capitalize: true })} is a **${type}**.\n` + + (type === "whitelist" + ? "Only the people listed below can ring you, and you only ring them." + : "The people listed below can't ring you, and you won't ring them.") + + `\n\n${pagedCountLine("Members", entries.length)}`, + ); + + const scopeSelect = new ActionRowBuilder() + .addComponents( + new RouteChannelSelectMenuBuilder(router) + .setChannelTypes(ChannelType.GuildVoice) + .setMinValues(0) + .setMaxValues(1) + .setPlaceholder( + channelId + ? "Viewing a channel filter (clear for global)" + : "View a voice channel's filter", + ) + .setDefaultChannels(...(channelId ? [channelId] : [])) + .setPattern("/filter{/:channelId}"), + ) + .toJSON(); + + const editSelect = editSelectRow( + new RouteUserSelectMenuBuilder(router).setDefaultUsers(...pageItems), + { + noun: "members", + pattern: `${panelPath(scope)}/members`, + page, + timestamp: state.timestamp, + }, + ); + + const filterOptions = [ + new RouteButtonBuilder(router) + .setLabel( + type === "blacklist" ? "Switch to Whitelist" : "Switch to Blacklist", + ) + .setStyle(ButtonStyle.Secondary) + .setTo(`${panelPath(scope)}/type`, { + method: "POST", + queryParams: { to: type === "blacklist" ? "whitelist" : "blacklist" }, + }), + new RouteButtonBuilder(router) + .setLabel("Reset") + .setStyle(ButtonStyle.Secondary) + .setTo(`${panelPath(scope)}/reset`, { method: "MODAL" }), + ]; + + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle(scope === "global" ? "๐Ÿ›ก๏ธ Global filter" : "๐Ÿ›ก๏ธ Channel filter") + .setDescription(description), + ], + // top to bottom: the scope select leads as context, then the member list + // and its control row; the section bar is the global nav pinned at the bottom + components: [ + scopeSelect, + editSelect, + ...pagedControls(router, panelPath(scope), { + page, + pageCount, + showOptions: showOptionsOf(state.queryParams), + options: filterOptions, + }), + navBar(router, interaction), + ], + }; +}; diff --git a/src/routes/filter/[scope]/members/post.ts b/src/routes/filter/[scope]/members/post.ts new file mode 100644 index 0000000..1c52f7f --- /dev/null +++ b/src/routes/filter/[scope]/members/post.ts @@ -0,0 +1,122 @@ +import { + addFilterEntry, + filterType, + getFilter, + removeFilterEntry, +} from "@db/filters"; +import { joinWithAnd, mentionUser } from "@main/ring"; +import { flashRedirect } from "@routes/lib/flash"; +import { + paginate, + resolveSelectionEdit, + withPageLabel, +} from "@routes/lib/paging"; +import { channelIdOf, scopeName, scopeOf } from "@routes/lib/scope"; +import { Handler } from "@routes/types"; + +import { panelPath } from "../../_shared"; + +// one members editor serves the panel select and the /block, /unblock, +// /whitelist, /unwhitelist adapters; `intent` keys the guard that stops +// blocklist edits on a whitelist (and vice versa), keeping the rule in one place +export const filterMembersPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const scope = scopeOf(state.params); + const channelId = channelIdOf(scope); + const userId = interaction.user.id; + const query = state.queryParams; + const intent = query.get("intent") ?? "edit"; + const panel = panelPath(scope); + + const filter = await getFilter(userId, channelId); + const type = filterType(filter); + + if (intent === "block" && type === "whitelist") + return flashRedirect( + panel, + "Your global filter is a whitelist. Switch it to a blacklist below, or use /whitelist and /unwhitelist instead.", + "warn", + ); + if (intent === "whitelist" && type === "blacklist") + return flashRedirect( + panel, + "Your global filter is a blacklist. Switch it to a whitelist below, or use /block and /unblock instead.", + "warn", + ); + + const entries = [...(filter?.entries ?? [])].sort(); + const { addsRequested, removesRequested, alreadyPresent } = + resolveSelectionEdit({ + current: entries, + values: state.values, + queryParams: query, + }); + + const entrySet = new Set(entries); + const toAdd = addsRequested.filter((id) => !entrySet.has(id)); + const toRemove = removesRequested.filter((id) => entrySet.has(id)); + await Promise.all([ + ...toAdd.map((id) => addFilterEntry(userId, channelId, id)), + ...toRemove.map((id) => removeFilterEntry(userId, channelId, id)), + ]); + + const changed = toAdd.length > 0 || toRemove.length > 0; + // the post-edit list places every entry the flash mentions, so entries + // that land or live off the rendered page get a page label + const removeSet = new Set(toRemove); + const after = [ + ...entries.filter((id) => !removeSet.has(id)), + ...toAdd, + ].sort(); + const { page } = paginate(after, query.get("page")); + const label = withPageLabel(after, mentionUser, page); + + const targetId = addsRequested[0] ?? removesRequested[0] ?? ""; + // mention with a page label when the target is in the list, plain when + // the flash says it isn't + const target = mentionUser(targetId); + const listedTarget = label(targetId); + let flash: string; + if (intent === "block") { + flash = + addsRequested.length > 0 + ? toAdd.length > 0 + ? `Blocked ${listedTarget}` + : `${listedTarget} is already blocked` + : toRemove.length > 0 + ? `Unblocked ${target}` + : `${target} isn't blocked`; + } else if (intent === "whitelist") { + flash = + addsRequested.length > 0 + ? toAdd.length > 0 + ? `Whitelisted ${listedTarget}` + : `${listedTarget} is already whitelisted` + : toRemove.length > 0 + ? `Removed ${target} from your whitelist` + : `${target} isn't on your whitelist`; + } else { + const parts = [ + ...(toAdd.length > 0 ? [`Added ${joinWithAnd(toAdd.map(label))}`] : []), + ...(toRemove.length > 0 + ? [`Removed ${joinWithAnd(toRemove.map(mentionUser))}`] + : []), + ...(alreadyPresent.length > 0 + ? [ + `${joinWithAnd(alreadyPresent.map(label))} ${alreadyPresent.length > 1 ? "are" : "is"} already listed`, + ] + : []), + ]; + flash = + parts.length > 0 + ? `${parts.join(". ")} (${scopeName(scope, type)})` + : `No changes to ${scopeName(scope, type)}`; + } + + return flashRedirect(panel, flash, changed ? "success" : "warn", { + page: query.get("page") ?? "0", + }); +}; diff --git a/src/routes/filter/[scope]/reset/modal.ts b/src/routes/filter/[scope]/reset/modal.ts new file mode 100644 index 0000000..5219ac0 --- /dev/null +++ b/src/routes/filter/[scope]/reset/modal.ts @@ -0,0 +1,16 @@ +import { confirmModal } from "@routes/lib/confirm"; +import { scopeOf } from "@routes/lib/scope"; +import { Handler } from "@routes/types"; + +import { panelPath } from "../../_shared"; + +export const filterResetModal: Handler<"MODAL"> = ( + router, + interaction, + state, +) => + confirmModal(router, { + to: `${panelPath(scopeOf(state.params))}/reset`, + title: "Reset filter", + word: "RESET", + }); diff --git a/src/routes/filter/[scope]/reset/post.ts b/src/routes/filter/[scope]/reset/post.ts new file mode 100644 index 0000000..627a34e --- /dev/null +++ b/src/routes/filter/[scope]/reset/post.ts @@ -0,0 +1,37 @@ +import { resetFilter } from "@db/filters"; +import { confirmed } from "@routes/lib/confirm"; +import { flashRedirect } from "@routes/lib/flash"; +import { channelIdOf, scopeName, scopeOf } from "@routes/lib/scope"; +import { Handler } from "@routes/types"; + +import { panelPath } from "../../_shared"; + +export const filterResetPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const scope = scopeOf(state.params); + const panel = panelPath(scope); + if (!confirmed(state.fields, "RESET")) + return flashRedirect( + panel, + "Confirmation text did not match, the filter was not reset", + "warn", + ); + const wasNotDefault = await resetFilter( + interaction.user.id, + channelIdOf(scope), + ); + return wasNotDefault + ? flashRedirect( + panel, + `${scopeName(scope, "filter", { capitalize: true })} has been reset and is an empty blacklist`, + "success", + ) + : flashRedirect( + panel, + `${scopeName(scope, "filter", { capitalize: true })} is already the default (an empty blacklist)`, + "warn", + ); +}; diff --git a/src/routes/filter/[scope]/type/post.ts b/src/routes/filter/[scope]/type/post.ts new file mode 100644 index 0000000..2bc5fab --- /dev/null +++ b/src/routes/filter/[scope]/type/post.ts @@ -0,0 +1,42 @@ +import { filterType, getFilter, setFilterType } from "@db/filters"; +import { flashRedirect } from "@routes/lib/flash"; +import { channelIdOf, scopeName, scopeOf } from "@routes/lib/scope"; +import { Handler } from "@routes/types"; + +import { panelPath } from "../../_shared"; + +export const filterTypePost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const scope = scopeOf(state.params); + const channelId = channelIdOf(scope); + const panel = panelPath(scope); + const to = + state.queryParams.get("to") === "whitelist" ? "whitelist" : "blacklist"; + + const filter = await getFilter(interaction.user.id, channelId); + if (filterType(filter) === to) + return flashRedirect( + panel, + `${scopeName(scope, "filter", { capitalize: true })} is already a ${to}`, + "warn", + ); + + await setFilterType(interaction.user.id, channelId, to === "whitelist"); + + // entries carry over and the switch reverses their meaning, so the notice + // says so: a blacklist's blocked users become the only ones a whitelist admits + const lead = `${scopeName(scope, "filter", { capitalize: true })} is now a ${to}`; + if ((filter?.entries.size ?? 0) === 0) + return flashRedirect(panel, lead, "success"); + + return flashRedirect( + panel, + to === "whitelist" + ? `${lead}. The people on it were blocked, and are now the only people who can ring you` + : `${lead}. The people on it were the only people who could ring you, and are now blocked`, + "warn", + ); +}; diff --git a/src/routes/filter/_shared.ts b/src/routes/filter/_shared.ts new file mode 100644 index 0000000..d46e846 --- /dev/null +++ b/src/routes/filter/_shared.ts @@ -0,0 +1 @@ +export const panelPath = (scope: string) => `/filter/${scope}`; diff --git a/src/routes/filter/filter.test.ts b/src/routes/filter/filter.test.ts new file mode 100644 index 0000000..eac0ed8 --- /dev/null +++ b/src/routes/filter/filter.test.ts @@ -0,0 +1,188 @@ +import { Interaction } from "discord.js"; +import { beforeEach, expect, test, vi } from "vitest"; + +import { + addFilterEntry, + getFilter, + removeFilterEntry, + resetFilter, + setFilterType, +} from "@db/filters"; + +import { filterMembersPost } from "./[scope]/members/post"; +import { filterResetPost } from "./[scope]/reset/post"; +import { filterTypePost } from "./[scope]/type/post"; + +vi.mock("@db/filters", async (importOriginal) => ({ + ...(await importOriginal()), + getFilter: vi.fn(), + addFilterEntry: vi.fn(), + removeFilterEntry: vi.fn(), + setFilterType: vi.fn(), + resetFilter: vi.fn(), +})); + +const interaction = { user: { id: "caller" } } as unknown as Interaction; + +const membersState = (query: string, values?: string[]) => + ({ + params: { scope: "global" }, + path: "/filter/global/members", + queryParams: new URLSearchParams(query), + timestamp: 0, + globals: { commandIds: new Map() }, + values, + }) as unknown as Parameters[2]; + +const membersPost = (query: string, values?: string[]) => + filterMembersPost( + undefined as never, + interaction, + membersState(query, values), + ); + +const typePost = (query: string) => + filterTypePost(undefined as never, interaction, { + params: { scope: "global" }, + path: "/filter/global/type", + queryParams: new URLSearchParams(query), + timestamp: 0, + } as unknown as Parameters[2]); + +const resetPost = (confirmation: string) => + filterResetPost(undefined as never, interaction, { + params: { scope: "global" }, + path: "/filter/global/reset", + queryParams: new URLSearchParams(), + timestamp: 0, + fields: { getTextInputValue: () => confirmation }, + } as unknown as Parameters[2]); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test("blocking while the global filter is a whitelist explains and mutates nothing", async () => { + vi.mocked(getFilter).mockResolvedValue({ + isWhitelist: true, + entries: new Set(), + }); + + const result = await membersPost("intent=block&add=42"); + + expect(result.redirect).toBe("/filter/global"); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("warn"); + expect(flashParams.get("flash")).toContain("whitelist"); + expect(addFilterEntry).not.toHaveBeenCalled(); + expect(removeFilterEntry).not.toHaveBeenCalled(); +}); + +test("a select submission applies adds and removes from the visible page together", async () => { + vi.mocked(getFilter).mockResolvedValue({ + isWhitelist: false, + entries: new Set(["1", "2"]), + }); + + const result = await membersPost("page=0", ["2", "9"]); + + expect(addFilterEntry).toHaveBeenCalledExactlyOnceWith("caller", null, "9"); + expect(removeFilterEntry).toHaveBeenCalledExactlyOnceWith( + "caller", + null, + "1", + ); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("success"); +}); + +test("a filter reset with matching confirmation text resets the filter", async () => { + vi.mocked(resetFilter).mockResolvedValue(true); + + const result = await resetPost("RESET"); + + expect(resetFilter).toHaveBeenCalledExactlyOnceWith("caller", null); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("success"); +}); + +test("a filter reset without matching confirmation text mutates nothing", async () => { + const result = await resetPost("nope"); + + expect(resetFilter).not.toHaveBeenCalled(); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("warn"); + expect(flashParams.get("flash")).toContain("did not match"); +}); + +// entries carry over and reverse meaning on a type switch, so a filter that +// has any warns instead of confirming +test("switching a blacklist that has entries sets the type and warns", async () => { + vi.mocked(getFilter).mockResolvedValue({ + isWhitelist: false, + entries: new Set(["1", "2"]), + }); + + const result = await typePost("to=whitelist"); + + expect(setFilterType).toHaveBeenCalledExactlyOnceWith("caller", null, true); + expect( + new URLSearchParams(result.queryParams as Record).get( + "level", + ), + ).toBe("warn"); +}); + +test("switching a whitelist that has entries sets the type and warns", async () => { + vi.mocked(getFilter).mockResolvedValue({ + isWhitelist: true, + entries: new Set(["1"]), + }); + + const result = await typePost("to=blacklist"); + + expect(setFilterType).toHaveBeenCalledExactlyOnceWith("caller", null, false); + expect( + new URLSearchParams(result.queryParams as Record).get( + "level", + ), + ).toBe("warn"); +}); + +test("switching a filter with no entries confirms without warning", async () => { + vi.mocked(getFilter).mockResolvedValue({ + isWhitelist: false, + entries: new Set(), + }); + + const result = await typePost("to=whitelist"); + + expect( + new URLSearchParams(result.queryParams as Record).get( + "level", + ), + ).toBe("success"); +}); + +test("blocking an already blocked user reports it without a duplicate write", async () => { + vi.mocked(getFilter).mockResolvedValue({ + isWhitelist: false, + entries: new Set(["42"]), + }); + + const result = await membersPost("intent=block&add=42"); + + expect(addFilterEntry).not.toHaveBeenCalled(); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("flash")).toContain("already"); +}); diff --git a/src/routes/get.ts b/src/routes/get.ts new file mode 100644 index 0000000..df9bc33 --- /dev/null +++ b/src/routes/get.ts @@ -0,0 +1,27 @@ +import { EmbedBuilder } from "discord.js"; + +import { navBar } from "@routes/lib/components"; +import { emojiIconURL, RINGVC_EMOJI_ID } from "@routes/lib/emoji"; +import { withFlash } from "@routes/lib/flash"; +import { Handler } from "@routes/types"; + +import { COLOR } from "./_shared"; + +export const homeGet: Handler<"GET"> = (router, interaction, state) => { + // the branded author line (icon + "RingVC") is the panel's header, so + // there is no separate title to duplicate it + const embed = new EmbedBuilder() + .setColor(COLOR) + .setAuthor({ name: "RingVC", iconURL: emojiIconURL(RINGVC_EMOJI_ID) }) + .setDescription( + withFlash( + state.queryParams, + "RingVC replicates group-chat voice calls in Discord servers: sign up for a voice channel and get pinged when someone starts a call there.", + ), + ); + + return { + embeds: [embed], + components: [navBar(router, interaction)], + }; +}; diff --git a/src/routes/help/_shared.ts b/src/routes/help/_shared.ts new file mode 100644 index 0000000..0d630d4 --- /dev/null +++ b/src/routes/help/_shared.ts @@ -0,0 +1,12 @@ +import { subNav } from "@routes/lib/components"; +import { RingRouter } from "@routes/types"; + +export const COLOR = "#6197cd"; +export const HELP = "/help"; +export const CATALOG = "/help/catalog"; + +export const helpSubNav = (router: RingRouter, active: "help" | "catalog") => + subNav(router, [ + { label: "Getting started", path: HELP, active: active === "help" }, + { label: "Catalog", path: CATALOG, active: active === "catalog" }, + ]); diff --git a/src/routes/help/catalog/get.ts b/src/routes/help/catalog/get.ts new file mode 100644 index 0000000..836dec0 --- /dev/null +++ b/src/routes/help/catalog/get.ts @@ -0,0 +1,92 @@ +import { EmbedBuilder } from "discord.js"; + +import { CommandName } from "@commands/commandNames"; +import { navBar } from "@routes/lib/components"; +import { withFlash } from "@routes/lib/flash"; +import { commandMention } from "@routes/lib/mentions"; +import { Handler } from "@routes/types"; + +import { COLOR, helpSubNav } from "../_shared"; +import { canManageRoleSignups } from "../../signups/_shared"; + +export const catalogGet: Handler<"GET"> = (router, interaction, state) => { + const mention = (name: CommandName) => commandMention(state.globals, name); + const groups: { title: string; entries: [CommandName, string][] }[] = [ + { + title: "Ringing", + entries: [ + ["ring", "ring someone into your voice channel (with a user)"], + ["ring_defaults", "ring your saved defaults into your voice channel"], + ], + }, + { + title: "Signups", + entries: [ + ["signup", "sign up for a voice channel (bare, in a VC's text chat)"], + ["unsignup", "remove a signup"], + ["quit", "same as /unsignup"], + ], + }, + // role signups are a Manage Roles action, so the group only shows to + // members who can use it + ...(canManageRoleSignups(interaction) + ? [ + { + title: "Role signups (Manage Roles)", + entries: [ + ["signuprole", "sign up a role for a channel"], + ["unsignuprole", "remove a role signup"], + ] as [CommandName, string][], + }, + ] + : []), + { + title: "Filters", + entries: [ + ["block", "block someone from ringing you"], + ["unblock", "unblock them"], + ["whitelist", "allow someone through your whitelist"], + ["unwhitelist", "remove them from your whitelist"], + ], + }, + { + title: "Panels", + entries: [ + ["ringvc", "the home panel"], + ["help", "getting started"], + ["catalog", "this command catalog"], + ["signup", "your signups panel (bare, outside a voice channel)"], + ["filter", "your filter panel"], + ["default_ring_recipients", "ring recipients and auto-ring panel"], + ["mode", "mode panel"], + ["ring", "ring panel (without a user)"], + ["delete_data", "data deletion panel"], + ], + }, + ]; + const renderGroup = ({ + title, + entries, + }: { + title: string; + entries: [CommandName, string][]; + }) => + `**${title}**\n` + + entries.map(([name, blurb]) => `${mention(name)} ยท ${blurb}`).join("\n"); + + const description = withFlash( + state.queryParams, + "Every RingVC command. Run one below, or use the section menu to open any panel.\n\n" + + groups.map(renderGroup).join("\n\n"), + ); + + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("๐Ÿ“– Catalog") + .setDescription(description), + ], + components: [helpSubNav(router, "catalog"), navBar(router, interaction)], + }; +}; diff --git a/src/routes/help/get.ts b/src/routes/help/get.ts new file mode 100644 index 0000000..4f9dfa0 --- /dev/null +++ b/src/routes/help/get.ts @@ -0,0 +1,45 @@ +import { EmbedBuilder } from "discord.js"; + +import { CommandName } from "@commands/commandNames"; +import { navBar } from "@routes/lib/components"; +import { withFlash } from "@routes/lib/flash"; +import { commandMention } from "@routes/lib/mentions"; +import { Handler } from "@routes/types"; + +import { canManageRoleSignups } from "../signups/_shared"; +import { COLOR, helpSubNav } from "./_shared"; + +export const helpGet: Handler<"GET"> = (router, interaction, state) => { + const mention = (name: CommandName) => commandMention(state.globals, name); + + const lines = [ + `**Ring** ยท ${mention("ring")} pings someone to join the voice channel you're in. Save default people and ping them with ${mention("ring_defaults")}, or turn on auto-ring to ping them automatically whenever you start a call.`, + `**Sign up** ยท ${mention("signup")} in a voice channel's text chat signs you up, so you get pinged when someone starts a call there (joins it while it's empty). Manage several from the Signups panel.`, + // role signups are a Manage Roles action, so the guidance only shows to + // members who can use it + ...(canManageRoleSignups(interaction) + ? [ + `**Role signups** ยท with Manage Roles, sign a whole role up for a channel via ${mention("signuprole")} so its members get pinged.`, + ] + : []), + `**Filters** ยท ${mention("block")} stops someone from ringing you, and ${mention("whitelist")} restricts ringing to only the people you list.`, + `**Modes** ยท Normal pings your signed-up people when you start a call, Stealth pings no one, Auto goes stealth only while you're invisible. Set it with ${mention("mode")}.`, + ]; + + const description = withFlash( + state.queryParams, + "RingVC replicates group-chat calls in Discord servers. A server can't ring people the way a group chat does, so RingVC pings them in the voice channel's text chat instead.\n\n" + + `${lines.join("\n")}\n\n` + + "-# Full command list on the Catalog tab. Jump to any panel with the section menu below.", + ); + + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("๐Ÿ“– Getting started") + .setDescription(description), + ], + components: [helpSubNav(router, "help"), navBar(router, interaction)], + }; +}; diff --git a/src/routes/index.ts b/src/routes/index.ts new file mode 100644 index 0000000..14178fe --- /dev/null +++ b/src/routes/index.ts @@ -0,0 +1,110 @@ +import { RingRouter } from "@routes/types"; + +import { aboutGet } from "./about/get"; +import { deleteDataGet } from "./delete-data/get"; +import { deleteDataModal } from "./delete-data/modal"; +import { deleteDataPost } from "./delete-data/post"; +import { filterGet } from "./filter/[scope]/get"; +import { filterMembersPost } from "./filter/[scope]/members/post"; +import { filterResetModal } from "./filter/[scope]/reset/modal"; +import { filterResetPost } from "./filter/[scope]/reset/post"; +import { filterTypePost } from "./filter/[scope]/type/post"; +import { homeGet } from "./get"; +import { catalogGet } from "./help/catalog/get"; +import { helpGet } from "./help/get"; +import { modeGet } from "./mode/get"; +import { modePost } from "./mode/post"; +import { pageJumpModal } from "./page-jump/modal"; +import { pageJumpPost } from "./page-jump/post"; +import { recipientsAutoRingPost } from "./recipients/[scope]/auto-ring/post"; +import { recipientsGet } from "./recipients/[scope]/get"; +import { recipientsMembersPost } from "./recipients/[scope]/members/post"; +import { recipientsResetModal } from "./recipients/[scope]/reset/modal"; +import { recipientsResetPost } from "./recipients/[scope]/reset/post"; +import { ringDefaultPost } from "./ring/default/post"; +import { ringGet } from "./ring/get"; +import { ringUserPost } from "./ring/user/post"; +import { ringUsersPost } from "./ring/users/post"; +import { signupsGet } from "./signups/get"; +import { signupsMembersPost } from "./signups/members/post"; +import { signupsResetModal } from "./signups/reset/modal"; +import { signupsResetPost } from "./signups/reset/post"; +import { rolesByChannelGet } from "./signups/roles/by-channel/get"; +import { rolesByChannelResetModal } from "./signups/roles/by-channel/reset/modal"; +import { rolesByChannelResetPost } from "./signups/roles/by-channel/reset/post"; +import { rolesByChannelEditPost } from "./signups/roles/by-channel/roles/post"; +import { rolesByRoleEditPost } from "./signups/roles/by-role/channels/post"; +import { rolesByRoleGet } from "./signups/roles/by-role/get"; +import { rolesByRoleResetModal } from "./signups/roles/by-role/reset/modal"; +import { rolesByRoleResetPost } from "./signups/roles/by-role/reset/post"; +import { rolesGet } from "./signups/roles/get"; + +// handlers live in files mirroring their route: the folder is the path (with +// [param] segments) and the file is the method +export const registerRoutes = (router: RingRouter) => { + router.get("/", homeGet); + router.get("/help", helpGet); + router.get("/help/catalog", catalogGet); + router.get("/about", aboutGet); + + // scoped panels answer their bare path as the global scope, so the scope-switch + // select can target "{/:channelId}" and fall back to global when cleared + router.get(["/filter", "/filter/:scope"], filterGet); + router.post("/filter/:scope/members", filterMembersPost); + router.post("/filter/:scope/type", filterTypePost); + router.route("/filter/:scope/reset", { + modal: filterResetModal, + post: filterResetPost, + }); + + router.get(["/recipients", "/recipients/:scope"], recipientsGet); + router.post("/recipients/:scope/members", recipientsMembersPost); + router.route("/recipients/:scope/reset", { + modal: recipientsResetModal, + post: recipientsResetPost, + }); + router.post("/recipients/:scope/auto-ring", recipientsAutoRingPost); + + router.route("/mode", { get: modeGet, post: modePost }); + + // the shared page-jump modal every paginated panel's middle button opens; + // the originating panel rides along in the `to` query param + router.route("/page-jump", { modal: pageJumpModal, post: pageJumpPost }); + + router.get("/signups", signupsGet); + router.post("/signups/members", signupsMembersPost); + router.route("/signups/reset", { + modal: signupsResetModal, + post: signupsResetPost, + }); + + // role signups open on a neutral view with a channel select and a role select; + // picking either sets the orientation. Clearing a scope returns to the neutral path + router.get( + ["/signups/roles", "/signups/roles/by-channel", "/signups/roles/by-role"], + rolesGet, + ); + router.get("/signups/roles/by-channel/:scope", rolesByChannelGet); + router.post("/signups/roles/by-channel/:scope/roles", rolesByChannelEditPost); + router.route("/signups/roles/by-channel/:scope/reset", { + modal: rolesByChannelResetModal, + post: rolesByChannelResetPost, + }); + router.get("/signups/roles/by-role/:scope", rolesByRoleGet); + router.post("/signups/roles/by-role/:scope/channels", rolesByRoleEditPost); + router.route("/signups/roles/by-role/:scope/reset", { + modal: rolesByRoleResetModal, + post: rolesByRoleResetPost, + }); + + router.get("/ring", ringGet); + router.post("/ring/users", ringUsersPost); + router.post("/ring/user", ringUserPost); + router.post("/ring/default", ringDefaultPost); + + router.route("/delete-data", { + get: deleteDataGet, + post: deleteDataPost, + modal: deleteDataModal, + }); +}; diff --git a/src/routes/lib/components.ts b/src/routes/lib/components.ts new file mode 100644 index 0000000..a3e2c71 --- /dev/null +++ b/src/routes/lib/components.ts @@ -0,0 +1,216 @@ +import { + RouteButtonBuilder, + RouteChannelSelectMenuBuilder, + RouteRoleSelectMenuBuilder, + RouteStringSelectMenuBuilder, + RouteStringSelectMenuOptionBuilder, + RouteUserSelectMenuBuilder, +} from "discord-embed-router"; +import { + ActionRowBuilder, + APIActionRowComponent, + APIComponentInMessageActionRow, + ButtonBuilder, + ButtonStyle, + Interaction, +} from "discord.js"; + +import { buttonEmoji, VC_EMOJI_ID } from "@routes/lib/emoji"; +import { Page, pagedEditPattern, SELECT_MAX_VALUES } from "@routes/lib/paging"; +import { PAGE_JUMP } from "@routes/page-jump/_shared"; +import { RingButton, RingRouter } from "@routes/types"; + +export const row = ( + ...components: ButtonBuilder[] +): APIActionRowComponent => + new ActionRowBuilder().addComponents(...components).toJSON(); + +// the add/remove select every paged member panel shares: the page's entries +// ride in as the caller-set defaults, and selecting or deselecting them posts +// the diff. The caller supplies the builder (defaults and channel-type filter +// already set) and the noun the shared placeholder reads +export const editSelectRow = < + B extends + | RouteUserSelectMenuBuilder + | RouteRoleSelectMenuBuilder + | RouteChannelSelectMenuBuilder, +>( + builder: B, + { + noun, + pattern, + page, + timestamp, + }: { noun: string; pattern: string; page: number; timestamp: number }, +): APIActionRowComponent => { + builder + .setMinValues(0) + .setMaxValues(SELECT_MAX_VALUES) + .setPlaceholder(`Edit ${noun}: select to add, deselect to remove`) + .setPattern(pattern, pagedEditPattern(page, timestamp)); + return new ActionRowBuilder().addComponents(builder).toJSON(); +}; + +export const homeButton = (router: RingRouter): RingButton => + new RouteButtonBuilder(router) + .setLabel("๐Ÿ  Home") + .setStyle(ButtonStyle.Secondary) + .setTo("/"); + +export const backButton = (router: RingRouter, path: string): RingButton => + new RouteButtonBuilder(router) + .setLabel("Back") + .setStyle(ButtonStyle.Secondary) + .setTo(path); + +// the top-level sections, one per entry in the section bar (SECTIONS below) +export type Section = + | "home" + | "signups" + | "filters" + | "ringees" + | "mode" + | "help" + | "about" + | "delete"; + +type Tab = { section: Section; label: string; path: string }; + +// every section the bar offers, in display order +const SECTIONS: readonly Tab[] = [ + { section: "home", label: "๐Ÿ  Home", path: "/" }, + { section: "signups", label: "๐Ÿ”” Signups", path: "/signups" }, + { section: "filters", label: "๐Ÿ›ก๏ธ Filters", path: "/filter/global" }, + { section: "ringees", label: "๐Ÿ“ฃ Ring", path: "/recipients/global" }, + { section: "mode", label: "๐Ÿ’ค Mode", path: "/mode" }, + { section: "help", label: "๐Ÿ“– Help", path: "/help" }, + { section: "about", label: "โ„น๏ธ About", path: "/about" }, + { section: "delete", label: "๐Ÿ—‘๏ธ Delete data", path: "/delete-data" }, +]; + +// the persistent section bar every panel ends on. A string select fits all +// sections in one row (dodging the five-button cap); the placeholder is an +// action prompt since the panel title already names where you are +export const navBar = ( + router: RingRouter, + interaction: Interaction, +): APIActionRowComponent => { + const vc = buttonEmoji(interaction, VC_EMOJI_ID); + // the Ring option lands on the immediate ring action when in a voice channel, + // else the default-recipients settings, so it never opens the "not in VC" notice + const inVoice = !!( + interaction.member && + "voice" in interaction.member && + interaction.member.voice.channel + ); + + const option = ({ section, label, path }: Tab) => { + const target = section === "ringees" && inVoice ? "/ring" : path; + const builder = new RouteStringSelectMenuOptionBuilder(router).setTo( + target, + ); + if (section === "signups" && vc) builder.setLabel("Signups").setEmoji(vc); + else builder.setLabel(label); + return builder; + }; + + // each option encodes its own target; the default pattern routes to the picked one + const select = new RouteStringSelectMenuBuilder(router) + .setPlaceholder("Switch sections") + .setTos(SECTIONS.map(option)); + + return new ActionRowBuilder() + .addComponents(select) + .toJSON(); +}; + +// the sub-view switch a section shows above the bar when it has sibling views. +// The active view is an inert Primary prefixed "Viewing:"; the others are links +export const subNav = ( + router: RingRouter, + items: { label: string; path: string; active?: boolean }[], +): APIActionRowComponent => + row( + ...items.map((item) => + new RouteButtonBuilder(router) + .setLabel(item.active ? `Viewing: ${item.label}` : item.label) + .setStyle(item.active ? ButtonStyle.Primary : ButtonStyle.Secondary) + .setDisabled(item.active ?? false) + // a sub-nav item can share a path with the bar's active tab; the key + // keeps their customIds distinct, which Discord requires per message + .setTo(item.path, { key: "subnav" }), + ), + ); + +// a paged panel's control row. With one page the options show directly; only +// when a pager also needs the row does a โš™ toggle swap between page controls +// and options, keyed by the `options` query param. No pager, no options: no row +export const pagedControls = ( + router: RingRouter, + basePath: string, + { + page, + pageCount, + showOptions, + options, + }: Pick & { + showOptions: boolean; + options: RingButton[]; + }, +): APIActionRowComponent[] => { + const pageButton = (label: string, target: number, disabled: boolean) => + new RouteButtonBuilder(router) + .setLabel(label) + .setStyle(ButtonStyle.Secondary) + .setDisabled(disabled) + .setTo(basePath, { queryParams: { page: String(target) } }); + const pager = + pageCount <= 1 + ? [] + : [ + pageButton("โ—€ Prev", page - 1, page === 0), + // the middle button opens a page-jump modal that redirects back to basePath + new RouteButtonBuilder(router) + .setLabel(`Page ${page + 1} of ${pageCount}`) + .setStyle(ButtonStyle.Secondary) + .setTo(PAGE_JUMP, { + method: "MODAL", + queryParams: { + to: basePath, + page: String(page), + pageCount: String(pageCount), + }, + }), + pageButton("Next โ–ถ", page + 1, page === pageCount - 1), + ]; + if (options.length === 0) return pager.length > 0 ? [row(...pager)] : []; + // no pager competing for the row: the options own it and show directly + if (pager.length === 0) return [row(...options)]; + // pager and options share the row: the โš™ toggle leads (a fixed slot the + // variable-width pager can't shift), and the open options lead with a way back + if (showOptions) + return [ + row( + new RouteButtonBuilder(router) + .setLabel("โ—€ Back") + .setStyle(ButtonStyle.Secondary) + .setTo(basePath, { queryParams: { page: String(page) } }), + ...options, + ), + ]; + return [ + row( + new RouteButtonBuilder(router) + .setLabel("โš™ Options") + .setStyle(ButtonStyle.Secondary) + .setTo(basePath, { + queryParams: { page: String(page), options: "1" }, + }), + ...pager, + ), + ]; +}; + +// reads the `options` query param pagedControls' switch buttons carry +export const showOptionsOf = (queryParams: URLSearchParams): boolean => + queryParams.get("options") === "1"; diff --git a/src/routes/lib/confirm.ts b/src/routes/lib/confirm.ts new file mode 100644 index 0000000..eb990ea --- /dev/null +++ b/src/routes/lib/confirm.ts @@ -0,0 +1,36 @@ +import { RouteModalBuilder } from "discord-embed-router"; +import { + LabelBuilder, + MessageFlags, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; + +import { RingModal, RingRouter } from "@routes/types"; + +// typed-word confirmation for destructive actions: a button opens this modal +// with method MODAL, and the POST it targets verifies the input via confirmed() +export const confirmModal = ( + router: RingRouter, + { to, title, word }: { to: string; title: string; word: string }, +): RingModal => + new RouteModalBuilder(router) + .setTo(to, { method: "POST", flags: [MessageFlags.Ephemeral] }) + .setTitle(`โš ๏ธ ${title}`) + .addLabelComponents( + new LabelBuilder() + .setLabel(`Type ${word} to confirm`) + .setDescription("This can't be undone.") + .setTextInputComponent( + new TextInputBuilder() + .setCustomId("confirm") + .setStyle(TextInputStyle.Short) + .setRequired(true) + .setPlaceholder(word), + ), + ); + +export const confirmed = ( + fields: { getTextInputValue(customId: string): string } | undefined, + word: string, +) => (fields?.getTextInputValue("confirm") ?? "") === word; diff --git a/src/routes/lib/emoji.ts b/src/routes/lib/emoji.ts new file mode 100644 index 0000000..a8a6d70 --- /dev/null +++ b/src/routes/lib/emoji.ts @@ -0,0 +1,25 @@ +import { ComponentEmojiResolvable, Interaction } from "discord.js"; + +// branded custom emoji: identity mark and voice-channel action mark +export const RINGVC_EMOJI_ID = "1324809350899961877"; +export const VC_EMOJI_ID = "1326000686575521883"; + +// the emoji image on the CDN, usable as an embed author or thumbnail icon. +// A plain image URL renders for anyone, so it needs no access check; an +// invalid id simply 404s and the icon is dropped +export const emojiIconURL = (id: string): string => + `https://cdn.discordapp.com/emojis/${id}.webp`; + +// a custom emoji on a button only renders when the bot can use it: an +// application emoji it owns (fetched into client.application.emojis at +// startup) or an emoji from a guild it shares. Otherwise Discord shows a +// broken glyph, so callers fall back to no emoji +export const buttonEmoji = ( + interaction: Interaction, + id: string, +): ComponentEmojiResolvable | undefined => { + const client = interaction.client; + return client.application?.emojis.cache.has(id) || client.emojis.cache.has(id) + ? { id } + : undefined; +}; diff --git a/src/routes/lib/flash.test.ts b/src/routes/lib/flash.test.ts new file mode 100644 index 0000000..f28ce1f --- /dev/null +++ b/src/routes/lib/flash.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from "vitest"; + +import { flashLine, flashRedirect } from "@routes/lib/flash"; + +test("flashRedirect carries the flash text and level as redirect query params", () => { + expect( + flashRedirect("/filter/global", "Blocked <@1>", "success"), + ).toStrictEqual({ + redirect: "/filter/global", + queryParams: { flash: "Blocked <@1>", level: "success" }, + }); +}); + +test("flashRedirect merges extra params like the page to stay on", () => { + expect( + flashRedirect("/signups", "Signed up", "success", { page: "2" }), + ).toStrictEqual({ + redirect: "/signups", + queryParams: { flash: "Signed up", level: "success", page: "2" }, + }); +}); + +test("flashLine renders a blockquote with the level icon and a bold lead", () => { + expect( + flashLine(new URLSearchParams({ flash: "Done", level: "success" })), + ).toBe("> โœ… **Done**"); + expect( + flashLine(new URLSearchParams({ flash: "Careful", level: "warn" })), + ).toBe("> โš ๏ธ **Careful**"); +}); + +test("flashLine bolds only the opening clause through the first period", () => { + expect( + flashLine( + new URLSearchParams({ + flash: "Auto-ring enabled. You'll ring these people.", + level: "success", + }), + ), + ).toBe("> โœ… **Auto-ring enabled.** You'll ring these people."); +}); + +test("flashLine keeps the bold lead within the first line of a multi-line flash", () => { + expect( + flashLine( + new URLSearchParams({ + flash: "Rang @a\nCan't ring @b because they blocked you.", + level: "warn", + }), + ), + ).toBe("> โš ๏ธ **Rang @a**\n> Can't ring @b because they blocked you."); +}); + +test("flashLine returns null when no flash is present", () => { + expect(flashLine(new URLSearchParams())).toBeNull(); + expect(flashLine(new URLSearchParams({ flash: "" }))).toBeNull(); +}); diff --git a/src/routes/lib/flash.ts b/src/routes/lib/flash.ts new file mode 100644 index 0000000..fa15af4 --- /dev/null +++ b/src/routes/lib/flash.ts @@ -0,0 +1,48 @@ +import { RouteRedirect } from "discord-embed-router"; + +export type FlashLevel = "success" | "warn"; + +// the redirect a mutation handler returns so its outcome shows as a notice on +// the target panel. It rides the in-flight query params only โ€” the components +// the GET builds don't carry it โ€” so the notice clears on the next interaction +export const flashRedirect = ( + redirect: string, + flash: string, + level: FlashLevel, + extraParams: Record = {}, +): RouteRedirect => ({ + redirect, + queryParams: { flash, level, ...extraParams }, +}); + +// bolds the flash's opening clause (through the first period, or the whole +// text when it has none) so the outcome reads at a glance +const boldLead = (text: string): string => { + const periodIndex = text.indexOf("."); + if (periodIndex === -1) return `**${text}**`; + return `**${text.slice(0, periodIndex + 1)}**${text.slice(periodIndex + 1)}`; +}; + +// the notice a panel renders at the bottom of its embed, as a markdown +// blockquote so it separates from the body; the level picks the icon +export const flashLine = (queryParams: URLSearchParams): string | null => { + const flash = queryParams.get("flash"); + if (!flash) return null; + const icon = queryParams.get("level") === "warn" ? "โš ๏ธ" : "โœ…"; + // bold only the first line's lead: Discord bold can't span the newline + // between blockquote lines, so the markers must open and close on one line + const [first = "", ...rest] = flash.split("\n"); + return [`${icon} ${boldLead(first)}`, ...rest] + .map((line) => `> ${line}`) + .join("\n"); +}; + +// an embed description with the flash notice, when present, below the body, +// always with one blank line between the body and the blockquote +export const withFlash = ( + queryParams: URLSearchParams, + body: string, +): string => { + const line = flashLine(queryParams); + return line ? `${body}\n\n${line}` : body; +}; diff --git a/src/routes/lib/mentions.ts b/src/routes/lib/mentions.ts new file mode 100644 index 0000000..d657563 --- /dev/null +++ b/src/routes/lib/mentions.ts @@ -0,0 +1,12 @@ +import { CommandName } from "@commands/commandNames"; +import { Globals } from "@routes/types"; + +// a clickable command mention when the deployed id is known, else plain text +// (ids are fetched after the client is ready, so early interactions may miss them) +export const commandMention = ( + globals: Globals | undefined, + name: CommandName, +): string => { + const id = globals?.commandIds.get(name); + return id ? `` : `/${name}`; +}; diff --git a/src/routes/lib/paging.test.ts b/src/routes/lib/paging.test.ts new file mode 100644 index 0000000..ee7d9f3 --- /dev/null +++ b/src/routes/lib/paging.test.ts @@ -0,0 +1,135 @@ +import { expect, test } from "vitest"; + +import { + diffSelection, + PAGE_SIZE, + pagedCountLine, + paginate, + withPageLabel, +} from "@routes/lib/paging"; + +const ids = (count: number, offset = 0) => + Array.from({ length: count }, (_, i) => `${i + offset + 1}`); + +test("a partial page fits one page with no pagination", () => { + expect(paginate(ids(PAGE_SIZE - 1), null)).toStrictEqual({ + pageItems: ids(PAGE_SIZE - 1), + page: 0, + pageCount: 1, + }); +}); + +test("an exactly full page adds no trailing page while the select has room", () => { + expect(paginate(ids(PAGE_SIZE), null)).toStrictEqual({ + pageItems: ids(PAGE_SIZE), + page: 0, + pageCount: 1, + }); +}); + +test("lists over the page size split into pages of PAGE_SIZE", () => { + const items = ids(PAGE_SIZE + 5); + expect(paginate(items, "1")).toStrictEqual({ + pageItems: items.slice(PAGE_SIZE), + page: 1, + pageCount: 2, + }); +}); + +test("a list that is an exact multiple of the page size adds no trailing page", () => { + const items = ids(PAGE_SIZE * 2); + expect(paginate(items, "1")).toStrictEqual({ + pageItems: items.slice(PAGE_SIZE), + page: 1, + pageCount: 2, + }); +}); + +test("the count line counts only pages that hold entries", () => { + expect(pagedCountLine("Signups", 0)).toBe("**Signups** ยท None"); + expect(pagedCountLine("Signups", PAGE_SIZE)).toBe( + `**Signups** ยท ${PAGE_SIZE}`, + ); + expect(pagedCountLine("Signups", PAGE_SIZE + 1)).toBe( + `**Signups** ยท ${PAGE_SIZE + 1} across 2 pages`, + ); + expect(pagedCountLine("Signups", PAGE_SIZE * 2)).toBe( + `**Signups** ยท ${PAGE_SIZE * 2} across 2 pages`, + ); +}); + +test("a stale page index clamps to the last page", () => { + const items = ids(PAGE_SIZE + 5); + expect(paginate(items, "7").page).toBe(1); +}); + +test("garbage and negative page indexes fall back to the first page", () => { + expect(paginate(ids(PAGE_SIZE + 5), "abc").page).toBe(0); + expect(paginate(ids(PAGE_SIZE + 5), "-3").page).toBe(0); +}); + +test("selecting new values adds them without touching other pages", () => { + const allItems = ids(PAGE_SIZE + 5); + const { pageItems } = paginate(allItems, "1"); + expect( + diffSelection({ allItems, pageItems, submitted: [...pageItems, "999"] }), + ).toStrictEqual({ added: ["999"], removed: [], alreadyPresent: [] }); +}); + +test("deselecting page entries removes only them", () => { + const allItems = ids(PAGE_SIZE + 5); + const { pageItems } = paginate(allItems, "1"); + expect( + diffSelection({ allItems, pageItems, submitted: pageItems.slice(1) }), + ).toStrictEqual({ added: [], removed: [pageItems[0]], alreadyPresent: [] }); +}); + +test("adds and removes apply together from one submission", () => { + const allItems = ids(PAGE_SIZE + 5); + const { pageItems } = paginate(allItems, "1"); + expect( + diffSelection({ + allItems, + pageItems, + submitted: [...pageItems.slice(1), "999"], + }), + ).toStrictEqual({ + added: ["999"], + removed: [pageItems[0]], + alreadyPresent: [], + }); +}); + +test("a submitted value that already lives on another page is not re-added", () => { + const allItems = ids(PAGE_SIZE + 5); + const { pageItems } = paginate(allItems, "1"); + // "1" is on page 0; selecting it on page 1 must not report an add + expect( + diffSelection({ allItems, pageItems, submitted: [...pageItems, "1"] }), + ).toStrictEqual({ added: [], removed: [], alreadyPresent: ["1"] }); +}); + +test("deselecting everything on a page removes the whole page only", () => { + const allItems = ids(PAGE_SIZE + 5); + const { pageItems } = paginate(allItems, "0"); + expect(diffSelection({ allItems, pageItems, submitted: [] })).toStrictEqual({ + added: [], + removed: pageItems, + alreadyPresent: [], + }); +}); + +test("withPageLabel points at the page an existing entry lives on", () => { + const allItems = ids(PAGE_SIZE * 2); + const label = withPageLabel(allItems, (id) => `#${id}`, 2); + expect(label("1")).toBe("#1 (page 1)"); + expect(label(`${PAGE_SIZE + 1}`)).toBe(`#${PAGE_SIZE + 1} (page 2)`); +}); + +test("withPageLabel leaves entries on the viewed page unlabeled", () => { + const allItems = ids(PAGE_SIZE * 2); + expect(withPageLabel(allItems, (id) => `#${id}`, 0)("1")).toBe("#1"); + expect(withPageLabel(allItems, (id) => `#${id}`, 1)(`${PAGE_SIZE + 1}`)).toBe( + `#${PAGE_SIZE + 1}`, + ); +}); diff --git a/src/routes/lib/paging.ts b/src/routes/lib/paging.ts new file mode 100644 index 0000000..d0b24c3 --- /dev/null +++ b/src/routes/lib/paging.ts @@ -0,0 +1,127 @@ +// how many entries a paged panel shows at once; a deliberate readability +// choice well under SELECT_MAX_VALUES +export const PAGE_SIZE = 10; +// Discord's cap on select menu max_values; edit selects allow it in full so +// users can add more entries per submit than one page displays. +export const SELECT_MAX_VALUES = 25; + +export type Page = { + pageItems: string[]; + page: number; + pageCount: number; +}; + +// the "**Label** ยท 23 across 3 pages" summary line, counting only pages that +// hold entries so pageCountOf's trailing add page never inflates the count +export const pagedCountLine = (label: string, total: number): string => { + const contentPages = Math.ceil(total / PAGE_SIZE); + return `**${label}** ยท ${total > 0 ? total : "None"}${contentPages > 1 ? ` across ${contentPages} pages` : ""}`; +}; + +// setPattern options for a paged edit select's POST. The per-render key gives +// every render a fresh customId: Discord keeps a select's in-flight selection +// when a message edit leaves the component unchanged, so a fresh id resets it +export const pagedEditPattern = ( + page: number, + timestamp: number, +): { method: "POST"; queryParams: Record; key: string } => ({ + method: "POST", + queryParams: { page: String(page) }, + // base36 for compactness; the key only needs to differ between renders + key: timestamp.toString(36), +}); + +// how many pages a list spans. A full last page gets a trailing empty page +// only when a page fills the whole edit select (PAGE_SIZE >= SELECT_MAX_VALUES); +// otherwise the last page still has room to add entries. Empty list: one page +export const pageCountOf = (total: number): number => { + const fullPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const needsAddPage = + PAGE_SIZE >= SELECT_MAX_VALUES && total > 0 && total % PAGE_SIZE === 0; + return fullPages + (needsAddPage ? 1 : 0); +}; + +// slices one select-menu page out of a list; stale page indexes clamp to +// the last page +export const paginate = (items: string[], rawPage: string | null): Page => { + const pageCount = pageCountOf(items.length); + const parsed = parseInt(rawPage ?? "0"); + const page = Math.min(Math.max(isNaN(parsed) ? 0 : parsed, 0), pageCount - 1); + return { + pageItems: items.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE), + page, + pageCount, + }; +}; + +// diffs a select submission against the visible page: values missing from the +// submission are removals, values not in the whole list are additions. Values +// on another page are no-ops, reported as `alreadyPresent` so the flash can +// point at the existing entry instead of claiming "no changes" +export const diffSelection = ({ + allItems, + pageItems, + submitted, +}: { + allItems: string[]; + pageItems: string[]; + submitted: string[]; +}): { added: string[]; removed: string[]; alreadyPresent: string[] } => { + const allSet = new Set(allItems); + const pageSet = new Set(pageItems); + const submittedSet = new Set(submitted); + return { + added: submitted.filter((value) => !allSet.has(value)), + removed: pageItems.filter((value) => !submittedSet.has(value)), + alreadyPresent: submitted.filter( + (value) => allSet.has(value) && !pageSet.has(value), + ), + }; +}; + +// labels a value with the page that displays it, e.g. "#general (page 2)", so a +// flash points at where an entry lives; skipped on the page already in view +export const withPageLabel = + (allItems: string[], mention: (id: string) => string, viewedPage: number) => + (value: string): string => { + const page = Math.floor(allItems.indexOf(value) / PAGE_SIZE); + return page === viewedPage + ? mention(value) + : `${mention(value)} (page ${page + 1})`; + }; + +// resolves an add/remove edit from either input a handler gets: a select +// submission (diffed against the page) or `add`/`remove` query params from a +// command adapter. Callers still filter against their current set before mutating +export const resolveSelectionEdit = ({ + current, + values, + queryParams, +}: { + current: string[]; + values: string[] | undefined; + queryParams: URLSearchParams; +}): { + addsRequested: string[]; + removesRequested: string[]; + alreadyPresent: string[]; +} => { + if (values) { + const { pageItems } = paginate(current, queryParams.get("page")); + const { added, removed, alreadyPresent } = diffSelection({ + allItems: current, + pageItems, + submitted: values, + }); + return { + addsRequested: added, + removesRequested: removed, + alreadyPresent, + }; + } + return { + addsRequested: queryParams.getAll("add"), + removesRequested: queryParams.getAll("remove"), + alreadyPresent: [], + }; +}; diff --git a/src/routes/lib/scope.ts b/src/routes/lib/scope.ts new file mode 100644 index 0000000..c3de17a --- /dev/null +++ b/src/routes/lib/scope.ts @@ -0,0 +1,24 @@ +// scoped panels (filter, recipients) address their scope in the path: +// "global", or a voice channel id + +export const scopeOf = ( + params: Partial>, +): string => { + const scope = params.scope; + return typeof scope === "string" && scope.length > 0 ? scope : "global"; +}; + +export const channelIdOf = (scope: string): string | null => + scope === "global" ? null : scope; + +// "your global filter" / "your filter for <#id>" +export const scopeName = ( + scope: string, + noun: string, + { capitalize = false }: { capitalize?: boolean } = {}, +): string => { + const your = capitalize ? "Your" : "your"; + return scope === "global" + ? `${your} global ${noun}` + : `${your} ${noun} for <#${scope}>`; +}; diff --git a/src/routes/mode/_shared.ts b/src/routes/mode/_shared.ts new file mode 100644 index 0000000..a659011 --- /dev/null +++ b/src/routes/mode/_shared.ts @@ -0,0 +1,28 @@ +import { DiscordUserMode } from "@db/users"; + +export const PATH = "/mode"; + +export const MODES: { + mode: DiscordUserMode; + label: string; + effect: string; +}[] = [ + { + mode: "normal", + label: "Normal", + effect: "Joining a voice channel rings all applicable users", + }, + { + mode: "stealth", + label: "Stealth", + effect: "Joining a voice channel rings nobody", + }, + { + mode: "auto", + label: "Auto", + effect: "Stealth while you are invisible on Discord, normal otherwise", + }, +]; + +export const isMode = (value: string | null): value is DiscordUserMode => + MODES.some(({ mode }) => mode === value); diff --git a/src/routes/mode/get.ts b/src/routes/mode/get.ts new file mode 100644 index 0000000..49e7e85 --- /dev/null +++ b/src/routes/mode/get.ts @@ -0,0 +1,50 @@ +import { RouteButtonBuilder } from "discord-embed-router"; +import { ButtonStyle, EmbedBuilder } from "discord.js"; + +import { getUserMode } from "@db/users"; +import { navBar, row } from "@routes/lib/components"; +import { withFlash } from "@routes/lib/flash"; +import { Handler } from "@routes/types"; + +import { MODES, PATH } from "./_shared"; + +const COLOR = "#b28b45"; + +// compact one-line legend replacing a field per mode +const LEGEND = + "-# **Normal**: rings everyone ยท **Stealth**: nobody ยท **Auto**: stealth while invisible"; + +export const modeGet: Handler<"GET"> = async (router, interaction, state) => { + const current = await getUserMode(interaction.user.id); + const currentLabel = + MODES.find(({ mode }) => mode === current)?.label ?? current; + + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("๐Ÿ’ค Mode") + .setDescription( + withFlash( + state.queryParams, + "Modes decide what happens when you join a voice channel.\n\n" + + `**Current mode** ยท ${currentLabel}\n${LEGEND}`, + ), + ), + ], + components: [ + row( + ...MODES.map(({ mode, label }) => + new RouteButtonBuilder(router) + .setLabel(label) + .setStyle( + mode === current ? ButtonStyle.Primary : ButtonStyle.Secondary, + ) + .setDisabled(mode === current) + .setTo(PATH, { method: "POST", queryParams: { set: mode } }), + ), + ), + navBar(router, interaction), + ], + }; +}; diff --git a/src/routes/mode/mode.test.ts b/src/routes/mode/mode.test.ts new file mode 100644 index 0000000..c85f2a4 --- /dev/null +++ b/src/routes/mode/mode.test.ts @@ -0,0 +1,57 @@ +import { Interaction } from "discord.js"; +import { beforeEach, expect, test, vi } from "vitest"; + +import { getUserMode, setUserMode } from "@db/users"; + +import { modePost } from "./post"; + +vi.mock("@db/users", () => ({ + getUserMode: vi.fn(), + setUserMode: vi.fn(), +})); + +const interaction = { user: { id: "caller" } } as unknown as Interaction; + +const state = (query: string) => + ({ + params: {}, + path: "/mode", + queryParams: new URLSearchParams(query), + timestamp: 0, + }) as unknown as Parameters[2]; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test("setting a mode persists it and confirms what it does", async () => { + vi.mocked(getUserMode).mockResolvedValue("normal"); + + const result = await modePost( + undefined as never, + interaction, + state("set=stealth"), + ); + + expect(setUserMode).toHaveBeenCalledExactlyOnceWith("caller", "stealth"); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("success"); +}); + +test("an unknown mode value writes nothing", async () => { + vi.mocked(getUserMode).mockResolvedValue("normal"); + + const result = await modePost( + undefined as never, + interaction, + state("set=loud"), + ); + + expect(setUserMode).not.toHaveBeenCalled(); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("warn"); +}); diff --git a/src/routes/mode/post.ts b/src/routes/mode/post.ts new file mode 100644 index 0000000..ab8f331 --- /dev/null +++ b/src/routes/mode/post.ts @@ -0,0 +1,19 @@ +import { getUserMode, setUserMode } from "@db/users"; +import { flashRedirect } from "@routes/lib/flash"; +import { Handler } from "@routes/types"; + +import { isMode, MODES, PATH } from "./_shared"; + +export const modePost: Handler<"POST"> = async (router, interaction, state) => { + const target = state.queryParams.get("set"); + if (!isMode(target)) + return flashRedirect(PATH, "Unknown mode, nothing changed", "warn"); + + const current = await getUserMode(interaction.user.id); + if (current === target) + return flashRedirect(PATH, `Your mode is already ${target}`, "warn"); + + await setUserMode(interaction.user.id, target); + const effect = MODES.find(({ mode }) => mode === target)?.effect ?? ""; + return flashRedirect(PATH, `Mode set to ${target}. ${effect}`, "success"); +}; diff --git a/src/routes/page-jump/_shared.ts b/src/routes/page-jump/_shared.ts new file mode 100644 index 0000000..6b63a39 --- /dev/null +++ b/src/routes/page-jump/_shared.ts @@ -0,0 +1 @@ +export const PAGE_JUMP = "/page-jump"; diff --git a/src/routes/page-jump/modal.ts b/src/routes/page-jump/modal.ts new file mode 100644 index 0000000..a3b5ceb --- /dev/null +++ b/src/routes/page-jump/modal.ts @@ -0,0 +1,34 @@ +import { RouteModalBuilder } from "discord-embed-router"; +import { LabelBuilder, TextInputBuilder, TextInputStyle } from "discord.js"; + +import { Handler } from "@routes/types"; + +import { PAGE_JUMP } from "./_shared"; + +// opened by the "Page n of m" pagination button of any paged panel; the +// button carries the panel's path in `to` so one modal serves them all +export const pageJumpModal: Handler<"MODAL"> = (router, interaction, state) => { + const query = state.queryParams; + const pageCount = query.get("pageCount") ?? ""; + return new RouteModalBuilder(router) + .setTo(PAGE_JUMP, { + method: "POST", + queryParams: { + to: query.get("to") ?? "/", + page: query.get("page") ?? "0", + pageCount, + }, + }) + .setTitle("Go to page") + .addLabelComponents( + new LabelBuilder() + .setLabel(`Page number (1โ€“${pageCount})`) + .setTextInputComponent( + new TextInputBuilder() + .setCustomId("page") + .setStyle(TextInputStyle.Short) + .setRequired(true) + .setPlaceholder(`1โ€“${pageCount}`), + ), + ); +}; diff --git a/src/routes/page-jump/page-jump.test.ts b/src/routes/page-jump/page-jump.test.ts new file mode 100644 index 0000000..7eec1b7 --- /dev/null +++ b/src/routes/page-jump/page-jump.test.ts @@ -0,0 +1,69 @@ +import { Interaction } from "discord.js"; +import { expect, test } from "vitest"; + +import { pageJumpPost } from "./post"; + +const interaction = { user: { id: "caller" } } as unknown as Interaction; + +const jump = async (query: string, input: string) => + pageJumpPost(undefined as never, interaction, { + params: {}, + path: "/page-jump", + queryParams: new URLSearchParams(query), + timestamp: 0, + fields: { getTextInputValue: () => input }, + } as unknown as Parameters[2]); + +const flashParams = (result: Awaited>) => + new URLSearchParams(result.queryParams as Record); + +test("a page number in range redirects to that page zero-indexed", async () => { + const result = await jump("to=/signups&page=0&pageCount=3", "2"); + + expect(result.redirect).toBe("/signups"); + expect(result.queryParams).toEqual({ page: "1" }); +}); + +test("both range bounds are accepted", async () => { + expect( + (await jump("to=/signups&page=1&pageCount=3", "1")).queryParams, + ).toEqual({ page: "0" }); + expect( + (await jump("to=/signups&page=1&pageCount=3", "3")).queryParams, + ).toEqual({ page: "2" }); +}); + +test("a page number past the last page is rejected, staying on the origin page", async () => { + const result = await jump("to=/signups&page=1&pageCount=3", "4"); + + expect(result.redirect).toBe("/signups"); + const params = flashParams(result); + expect(params.get("level")).toBe("warn"); + expect(params.get("flash")).toContain('"4" is not a valid page number'); + expect(params.get("page")).toBe("1"); +}); + +test("zero is rejected (pages are one-based)", async () => { + const params = flashParams(await jump("to=/signups&page=0&pageCount=3", "0")); + + expect(params.get("level")).toBe("warn"); + expect(params.get("flash")).toContain('"0" is not a valid page number'); +}); + +test("non-numeric input is rejected", async () => { + const params = flashParams( + await jump("to=/signups&page=0&pageCount=3", "abc"), + ); + + expect(params.get("level")).toBe("warn"); + expect(params.get("flash")).toContain('"abc" is not a valid page number'); +}); + +test("a missing pageCount param only accepts page 1", async () => { + expect((await jump("to=/signups&page=0", "1")).queryParams).toEqual({ + page: "0", + }); + expect(flashParams(await jump("to=/signups&page=0", "2")).get("level")).toBe( + "warn", + ); +}); diff --git a/src/routes/page-jump/post.ts b/src/routes/page-jump/post.ts new file mode 100644 index 0000000..cf13d5f --- /dev/null +++ b/src/routes/page-jump/post.ts @@ -0,0 +1,16 @@ +import { flashRedirect } from "@routes/lib/flash"; +import { Handler } from "@routes/types"; + +export const pageJumpPost: Handler<"POST"> = (router, interaction, state) => { + const query = state.queryParams; + const to = query.get("to") ?? "/"; + const input = state.fields?.getTextInputValue("page")?.trim() ?? ""; + const parsed = /^\d+$/.test(input) ? Number(input) : NaN; + const pageCount = parseInt(query.get("pageCount") ?? "1", 10) || 1; + // a bad or out-of-range entry stays on the page the modal was opened from + if (isNaN(parsed) || parsed < 1 || parsed > pageCount) + return flashRedirect(to, `"${input}" is not a valid page number`, "warn", { + page: query.get("page") ?? "0", + }); + return { redirect: to, queryParams: { page: String(parsed - 1) } }; +}; diff --git a/src/routes/recipients/[scope]/auto-ring/post.ts b/src/routes/recipients/[scope]/auto-ring/post.ts new file mode 100644 index 0000000..438367b --- /dev/null +++ b/src/routes/recipients/[scope]/auto-ring/post.ts @@ -0,0 +1,36 @@ +import { setAutoRing } from "@db/auto-ring"; +import { flashRedirect } from "@routes/lib/flash"; +import { channelIdOf, scopeOf } from "@routes/lib/scope"; +import { Handler } from "@routes/types"; + +import { panelPath, scopeSuffix } from "../../_shared"; + +export const recipientsAutoRingPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const scope = scopeOf(state.params); + const channelId = channelIdOf(scope); + const enable = state.queryParams.get("enable") === "1"; + const panel = panelPath(scope); + + const changed = await setAutoRing(interaction.user.id, channelId, enable); + if (!changed) + return flashRedirect( + panel, + `Auto-ring is already ${enable ? "enabled" : "disabled"} ${scopeSuffix(scope)}`, + "warn", + ); + return enable + ? flashRedirect( + panel, + `Auto-ring is now enabled ${scopeSuffix(scope)}. WARNING: joining ${channelId ? `<#${channelId}>` : "a voice channel"} now rings all of your default ring recipients, even in stealth mode`, + "warn", + ) + : flashRedirect( + panel, + `Auto-ring is now disabled ${scopeSuffix(scope)}`, + "success", + ); +}; diff --git a/src/routes/recipients/[scope]/get.ts b/src/routes/recipients/[scope]/get.ts new file mode 100644 index 0000000..3635bbe --- /dev/null +++ b/src/routes/recipients/[scope]/get.ts @@ -0,0 +1,154 @@ +import { + RouteButtonBuilder, + RouteChannelSelectMenuBuilder, + RouteUserSelectMenuBuilder, +} from "discord-embed-router"; +import { + ActionRowBuilder, + ButtonStyle, + ChannelType, + EmbedBuilder, +} from "discord.js"; + +import { getAutoRingSetting } from "@db/auto-ring"; +import { getDefaultRingees } from "@db/default-ringees"; +import { + editSelectRow, + navBar, + pagedControls, + showOptionsOf, + subNav, +} from "@routes/lib/components"; +import { withFlash } from "@routes/lib/flash"; +import { commandMention } from "@routes/lib/mentions"; +import { pagedCountLine, paginate } from "@routes/lib/paging"; +import { channelIdOf, scopeOf } from "@routes/lib/scope"; +import { Handler } from "@routes/types"; + +import { panelPath } from "../_shared"; + +const COLOR = "#8c89cd"; + +// the channel override when in channel scope, the global setting, and the +// value that actually applies for the scope +const autoRingStatus = async (userId: string, channelId: string | null) => { + const [override, global] = await Promise.all([ + channelId === null + ? Promise.resolve(undefined) + : getAutoRingSetting(userId, channelId), + getAutoRingSetting(userId, null), + ]); + return { + override, + global: global ?? false, + effective: override ?? global ?? false, + }; +}; + +export const recipientsGet: Handler<"GET"> = async ( + router, + interaction, + state, +) => { + const scope = scopeOf(state.params); + const channelId = channelIdOf(scope); + const userId = interaction.user.id; + const [ringees, autoRing] = await Promise.all([ + getDefaultRingees(userId, channelId), + autoRingStatus(userId, channelId), + ]); + const sorted = [...ringees].sort(); + const { pageItems, page, pageCount } = paginate( + sorted, + state.queryParams.get("page"), + ); + + const autoRingValue = + channelId === null + ? autoRing.effective + ? "On" + : "Off" + : autoRing.override !== undefined + ? `${autoRing.override ? "On" : "Off"} for <#${channelId}> (global: ${autoRing.global ? "on" : "off"})` + : `${autoRing.effective ? "On" : "Off"} (from your global setting)`; + + // channel ringees ring on top of the global defaults, so the channel scope + // says so; global scope's "these people" already is everyone + const ringsClause = channelId + ? `These people (along with your global default ringees) get rung when you use ${commandMention(state.globals, "ring_defaults")} in <#${channelId}>` + : `These people get rung when you use ${commandMention(state.globals, "ring_defaults")}`; + const description = withFlash( + state.queryParams, + `${ringsClause}, or on every voice channel join if auto-ring is on.\n\n` + + `**Auto-ring** ยท ${autoRingValue}\n` + + pagedCountLine(channelId ? "Channel ringees" : "Ringees", sorted.length), + ); + + const scopeSelect = new ActionRowBuilder() + .addComponents( + new RouteChannelSelectMenuBuilder(router) + .setChannelTypes(ChannelType.GuildVoice) + .setMinValues(0) + .setMaxValues(1) + .setPlaceholder( + channelId + ? "Viewing channel ringees (clear for global)" + : "View a voice channel's ringees", + ) + .setDefaultChannels(...(channelId ? [channelId] : [])) + .setPattern("/recipients{/:channelId}"), + ) + .toJSON(); + + const editSelect = editSelectRow( + new RouteUserSelectMenuBuilder(router).setDefaultUsers(...pageItems), + { + noun: "ringees", + pattern: `${panelPath(scope)}/members`, + page, + timestamp: state.timestamp, + }, + ); + + const ringOptions = [ + new RouteButtonBuilder(router) + .setLabel(autoRing.effective ? "Disable auto-ring" : "Enable auto-ring") + .setStyle(ButtonStyle.Secondary) + .setTo(`${panelPath(scope)}/auto-ring`, { + method: "POST", + queryParams: { enable: autoRing.effective ? "0" : "1" }, + }), + new RouteButtonBuilder(router) + .setLabel("Reset") + .setStyle(ButtonStyle.Secondary) + .setTo(`${panelPath(scope)}/reset`, { method: "MODAL" }), + ]; + + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle( + scope === "global" ? "๐Ÿ“ฃ Default ringees" : "๐Ÿ“ฃ Channel ringees", + ) + .setDescription(description), + ], + // top to bottom: the scope select leads as context, then the ringee list + // and its control row; the sub-nav and section bar are pinned at the bottom + components: [ + scopeSelect, + editSelect, + ...pagedControls(router, panelPath(scope), { + page, + pageCount, + showOptions: showOptionsOf(state.queryParams), + options: ringOptions, + }), + subNav(router, [ + { label: "Quick ring", path: "/ring" }, + { label: "Default ringees", path: panelPath(scope), active: true }, + ]), + navBar(router, interaction), + ], + }; +}; diff --git a/src/routes/recipients/[scope]/members/post.ts b/src/routes/recipients/[scope]/members/post.ts new file mode 100644 index 0000000..3e90fdb --- /dev/null +++ b/src/routes/recipients/[scope]/members/post.ts @@ -0,0 +1,74 @@ +import { + addDefaultRingee, + getDefaultRingees, + removeDefaultRingee, +} from "@db/default-ringees"; +import { joinWithAnd, mentionUser } from "@main/ring"; +import { flashRedirect } from "@routes/lib/flash"; +import { + paginate, + resolveSelectionEdit, + withPageLabel, +} from "@routes/lib/paging"; +import { channelIdOf, scopeOf } from "@routes/lib/scope"; +import { Handler } from "@routes/types"; + +import { panelPath, scopeSuffix } from "../../_shared"; + +export const recipientsMembersPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const scope = scopeOf(state.params); + const channelId = channelIdOf(scope); + const userId = interaction.user.id; + const panel = panelPath(scope); + + const ringees = [...(await getDefaultRingees(userId, channelId))].sort(); + const { addsRequested, removesRequested, alreadyPresent } = + resolveSelectionEdit({ + current: ringees, + values: state.values, + queryParams: state.queryParams, + }); + const ringeeSet = new Set(ringees); + const added = addsRequested.filter((id) => !ringeeSet.has(id)); + const removed = removesRequested.filter((id) => ringeeSet.has(id)); + + await Promise.all([ + ...added.map((id) => addDefaultRingee(userId, channelId, id)), + ...removed.map((id) => removeDefaultRingee(userId, channelId, id)), + ]); + + // the post-edit list places every entry the flash mentions, so entries + // that land or live off the rendered page get a page label + const removedSet = new Set(removed); + const after = [ + ...ringees.filter((id) => !removedSet.has(id)), + ...added, + ].sort(); + const { page } = paginate(after, state.queryParams.get("page")); + const label = withPageLabel(after, mentionUser, page); + + const parts = [ + ...(added.length > 0 ? [`Added ${joinWithAnd(added.map(label))}`] : []), + ...(removed.length > 0 + ? [`Removed ${joinWithAnd(removed.map(mentionUser))}`] + : []), + ...(alreadyPresent.length > 0 + ? [ + `${joinWithAnd(alreadyPresent.map(label))} ${alreadyPresent.length > 1 ? "are" : "is"} already added`, + ] + : []), + ]; + const changed = added.length > 0 || removed.length > 0; + return flashRedirect( + panel, + parts.length > 0 + ? `${parts.join(". ")} (default ring recipients ${scopeSuffix(scope)})` + : `No changes to your default ring recipients ${scopeSuffix(scope)}`, + changed ? "success" : "warn", + { page: state.queryParams.get("page") ?? "0" }, + ); +}; diff --git a/src/routes/recipients/[scope]/reset/modal.ts b/src/routes/recipients/[scope]/reset/modal.ts new file mode 100644 index 0000000..1bef557 --- /dev/null +++ b/src/routes/recipients/[scope]/reset/modal.ts @@ -0,0 +1,16 @@ +import { confirmModal } from "@routes/lib/confirm"; +import { scopeOf } from "@routes/lib/scope"; +import { Handler } from "@routes/types"; + +import { panelPath } from "../../_shared"; + +export const recipientsResetModal: Handler<"MODAL"> = ( + router, + interaction, + state, +) => + confirmModal(router, { + to: `${panelPath(scopeOf(state.params))}/reset`, + title: "Reset ring recipients", + word: "RESET", + }); diff --git a/src/routes/recipients/[scope]/reset/post.ts b/src/routes/recipients/[scope]/reset/post.ts new file mode 100644 index 0000000..316a1cf --- /dev/null +++ b/src/routes/recipients/[scope]/reset/post.ts @@ -0,0 +1,36 @@ +import { resetDefaultRingees } from "@db/default-ringees"; +import { confirmed } from "@routes/lib/confirm"; +import { flashRedirect } from "@routes/lib/flash"; +import { channelIdOf, scopeOf } from "@routes/lib/scope"; +import { Handler } from "@routes/types"; + +import { panelPath, scopeSuffix } from "../../_shared"; + +export const recipientsResetPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const scope = scopeOf(state.params); + if (!confirmed(state.fields, "RESET")) + return flashRedirect( + panelPath(scope), + "Confirmation text did not match, your recipients were not cleared", + "warn", + ); + const hadRingees = await resetDefaultRingees( + interaction.user.id, + channelIdOf(scope), + ); + return hadRingees + ? flashRedirect( + panelPath(scope), + `Cleared your default ring recipients ${scopeSuffix(scope)}`, + "success", + ) + : flashRedirect( + panelPath(scope), + `You already have no default ring recipients ${scopeSuffix(scope)}`, + "warn", + ); +}; diff --git a/src/routes/recipients/_shared.ts b/src/routes/recipients/_shared.ts new file mode 100644 index 0000000..6f260e2 --- /dev/null +++ b/src/routes/recipients/_shared.ts @@ -0,0 +1,5 @@ +export const panelPath = (scope: string) => `/recipients/${scope}`; + +// "for <#id>" / "globally" +export const scopeSuffix = (scope: string) => + scope === "global" ? "globally" : `for <#${scope}>`; diff --git a/src/routes/recipients/recipients.test.ts b/src/routes/recipients/recipients.test.ts new file mode 100644 index 0000000..3656d7c --- /dev/null +++ b/src/routes/recipients/recipients.test.ts @@ -0,0 +1,97 @@ +import { Interaction } from "discord.js"; +import { beforeEach, expect, test, vi } from "vitest"; + +import { setAutoRing } from "@db/auto-ring"; +import { resetDefaultRingees } from "@db/default-ringees"; + +import { recipientsAutoRingPost } from "./[scope]/auto-ring/post"; +import { recipientsResetPost } from "./[scope]/reset/post"; + +vi.mock("@db/auto-ring", () => ({ + getAutoRingSetting: vi.fn(), + setAutoRing: vi.fn(), +})); +vi.mock("@db/default-ringees", () => ({ + getDefaultRingees: vi.fn(), + addDefaultRingee: vi.fn(), + removeDefaultRingee: vi.fn(), + resetDefaultRingees: vi.fn(), +})); + +const interaction = { user: { id: "caller" } } as unknown as Interaction; + +const autoRingState = (scope: string, query: string) => + ({ + params: { scope }, + path: `/recipients/${scope}/auto-ring`, + queryParams: new URLSearchParams(query), + timestamp: 0, + }) as unknown as Parameters[2]; + +const resetPost = (confirmation: string) => + recipientsResetPost(undefined as never, interaction, { + params: { scope: "global" }, + path: "/recipients/global/reset", + queryParams: new URLSearchParams(), + timestamp: 0, + fields: { getTextInputValue: () => confirmation }, + } as unknown as Parameters[2]); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test("a recipients reset with matching confirmation text clears the list", async () => { + vi.mocked(resetDefaultRingees).mockResolvedValue(true); + + const result = await resetPost("RESET"); + + expect(resetDefaultRingees).toHaveBeenCalledExactlyOnceWith("caller", null); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("success"); +}); + +test("a recipients reset without matching confirmation text mutates nothing", async () => { + const result = await resetPost("nope"); + + expect(resetDefaultRingees).not.toHaveBeenCalled(); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("warn"); + expect(flashParams.get("flash")).toContain("did not match"); +}); + +test("enabling auto-ring warns that joins ring default recipients even in stealth", async () => { + vi.mocked(setAutoRing).mockResolvedValue(true); + + const result = await recipientsAutoRingPost( + undefined as never, + interaction, + autoRingState("123", "enable=1"), + ); + + expect(setAutoRing).toHaveBeenCalledExactlyOnceWith("caller", "123", true); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("warn"); + expect(flashParams.get("flash")).toContain("stealth"); +}); + +test("toggling auto-ring to its current value reports no change", async () => { + vi.mocked(setAutoRing).mockResolvedValue(false); + + const result = await recipientsAutoRingPost( + undefined as never, + interaction, + autoRingState("global", "enable=0"), + ); + + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("flash")).toContain("already"); +}); diff --git a/src/routes/ring/_shared.ts b/src/routes/ring/_shared.ts new file mode 100644 index 0000000..12f5b98 --- /dev/null +++ b/src/routes/ring/_shared.ts @@ -0,0 +1,77 @@ +import { Interaction, VoiceBasedChannel } from "discord.js"; + +import { + getErrorMessage, + joinWithAnd, + mentionUser, + ring, + UserRingResult, +} from "@main/ring"; +import { flashRedirect } from "@routes/lib/flash"; + +export const PANEL = "/ring"; +export const NOT_IN_VC = + "RingVC needs to know which voice channel to ring people in. Join one, then run /ring again."; +export const SERVER_ONLY = + "RingVC can only ring people from within a Discord server. Run /ring in a server instead."; + +// panels outlive the voice state that was true at render time, so every +// mutation re-checks it at click time +export const voiceChannelOf = ( + interaction: Interaction, +): VoiceBasedChannel | null => + interaction.member && "voice" in interaction.member + ? interaction.member.voice.channel + : null; + +// why there is no channel to ring in: server-only outside a guild, otherwise +// a prompt to join a voice channel +export const noVoiceChannelFlash = (interaction: Interaction): string => + interaction.inGuild() ? NOT_IN_VC : SERVER_ONLY; + +export const ringResultsFlash = (results: UserRingResult[]) => { + const ringed = results + .filter((result) => result.status === "fulfilled") + .map((result) => mentionUser(result.userId)); + const lines = [ + ...(ringed.length > 0 ? [`Ringed ${joinWithAnd(ringed)}`] : []), + ...results + .filter((result) => result.status === "rejected") + .map( + (result) => + `Can't ring ${mentionUser(result.userId)} because ${result.error.message}`, + ), + ]; + return { + flash: lines.join("\n"), + level: ringed.length > 0 ? ("success" as const) : ("warn" as const), + }; +}; + +export const ringUserIds = async ( + interaction: Interaction, + userIds: string[], +) => { + const channel = voiceChannelOf(interaction); + if (!channel) + return flashRedirect(PANEL, noVoiceChannelFlash(interaction), "warn"); + if (userIds.length === 0) + return flashRedirect(PANEL, "Nobody was selected to ring", "warn"); + + try { + const results = await ring( + channel, + interaction.user.id, + "wants you to join", + userIds, + ); + const { flash, level } = ringResultsFlash(results); + return flashRedirect(PANEL, flash, level); + } catch (err) { + return flashRedirect( + PANEL, + `Can't ring because ${getErrorMessage(err)}`, + "warn", + ); + } +}; diff --git a/src/routes/ring/default/post.ts b/src/routes/ring/default/post.ts new file mode 100644 index 0000000..e89e716 --- /dev/null +++ b/src/routes/ring/default/post.ts @@ -0,0 +1,44 @@ +import { getErrorMessage, ringDefaultUsers } from "@main/ring"; +import { flashRedirect } from "@routes/lib/flash"; +import { commandMention } from "@routes/lib/mentions"; +import { Handler } from "@routes/types"; + +import { + noVoiceChannelFlash, + PANEL, + ringResultsFlash, + voiceChannelOf, +} from "../_shared"; + +export const ringDefaultPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const channel = voiceChannelOf(interaction); + if (!channel) + return flashRedirect(PANEL, noVoiceChannelFlash(interaction), "warn"); + + try { + const results = await ringDefaultUsers( + channel, + interaction.user.id, + "wants you to join", + ); + const { flash, level } = ringResultsFlash(results); + return flashRedirect(PANEL, flash, level); + } catch (err) { + const message = getErrorMessage(err); + return message === "no default users to ring" + ? flashRedirect( + PANEL, + `You have no default ring recipients. Use the Ring recipients panel on the home page or ${commandMention(state.globals, "default_ring_recipients")} to add some`, + "warn", + ) + : flashRedirect( + PANEL, + `Can't ring your default recipients because ${message}`, + "warn", + ); + } +}; diff --git a/src/routes/ring/get.ts b/src/routes/ring/get.ts new file mode 100644 index 0000000..ba2369c --- /dev/null +++ b/src/routes/ring/get.ts @@ -0,0 +1,70 @@ +import { + RouteButtonBuilder, + RouteUserSelectMenuBuilder, +} from "discord-embed-router"; +import { ActionRowBuilder, ButtonStyle, EmbedBuilder } from "discord.js"; + +import { getAllDefaultRingees } from "@db/default-ringees"; +import { navBar, row, subNav } from "@routes/lib/components"; +import { withFlash } from "@routes/lib/flash"; +import { SELECT_MAX_VALUES } from "@routes/lib/paging"; +import { Handler } from "@routes/types"; + +import { noVoiceChannelFlash, PANEL, voiceChannelOf } from "./_shared"; + +const COLOR = "#c87a6d"; + +export const ringGet: Handler<"GET"> = async (router, interaction, state) => { + // the Ring section's two sibling views; this panel is the "Ring now" one + const ringViews = subNav(router, [ + { label: "Quick ring", path: PANEL, active: true }, + { label: "Default ringees", path: "/recipients/global" }, + ]); + + const channel = voiceChannelOf(interaction); + if (!channel) + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("๐Ÿ“ฃ Quick ring") + .setDescription(`โš ๏ธ ${noVoiceChannelFlash(interaction)}`), + ], + components: [ringViews, navBar(router, interaction)], + }; + + const defaults = await getAllDefaultRingees(interaction.user.id, channel.id); + const description = withFlash( + state.queryParams, + `Ringing people in <#${channel.id}>.\n\n` + + `**Default ringees** ยท ${defaults.length > 0 ? defaults.length : "None"}`, + ); + + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("๐Ÿ“ฃ Quick ring") + .setDescription(description), + ], + components: [ + new ActionRowBuilder() + .addComponents( + new RouteUserSelectMenuBuilder(router) + .setMinValues(1) + .setMaxValues(SELECT_MAX_VALUES) + .setPlaceholder("Select up to 25 people to ring") + .setPattern(`${PANEL}/users`, { method: "POST" }), + ) + .toJSON(), + row( + new RouteButtonBuilder(router) + .setLabel("Ring defaults") + .setStyle(ButtonStyle.Success) + .setTo(`${PANEL}/default`, { method: "POST" }), + ), + ringViews, + navBar(router, interaction), + ], + }; +}; diff --git a/src/routes/ring/ring.test.ts b/src/routes/ring/ring.test.ts new file mode 100644 index 0000000..b5c388b --- /dev/null +++ b/src/routes/ring/ring.test.ts @@ -0,0 +1,141 @@ +import { Interaction } from "discord.js"; +import { beforeEach, expect, test, vi } from "vitest"; + +import { ring, ringDefaultUsers } from "@main/ring"; + +import { ringDefaultPost } from "./default/post"; +import { ringUsersPost } from "./users/post"; + +vi.mock("@main/ring", async (importOriginal) => ({ + ...(await importOriginal()), + ring: vi.fn(), + ringDefaultUsers: vi.fn(), +})); +vi.mock("@db/default-ringees", () => ({ + getAllDefaultRingees: vi.fn(), +})); + +const voiceChannel = { id: "vc1" }; +const makeInteraction = (inVoice: boolean) => + ({ + user: { id: "caller" }, + member: { voice: { channel: inVoice ? voiceChannel : null } }, + inGuild: () => true, + }) as unknown as Interaction; + +const state = (query: string, values?: string[]) => + ({ + params: {}, + path: "/ring/users", + queryParams: new URLSearchParams(query), + timestamp: 0, + globals: { commandIds: new Map() }, + values, + }) as unknown as Parameters[2]; + +const defaultState = () => + ({ + params: {}, + path: "/ring/default", + queryParams: new URLSearchParams(), + timestamp: 0, + globals: { commandIds: new Map() }, + }) as unknown as Parameters[2]; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test("ringing defaults rings the saved list and returns to the Quick ring panel", async () => { + vi.mocked(ringDefaultUsers).mockResolvedValue([ + { userId: "9", status: "fulfilled" }, + ]); + + const result = await ringDefaultPost( + undefined as never, + makeInteraction(true), + defaultState(), + ); + + expect(ringDefaultUsers).toHaveBeenCalledExactlyOnceWith( + voiceChannel, + "caller", + "wants you to join", + ); + expect(result.redirect).toBe("/ring"); +}); + +test("ringing defaults while not in a voice channel warns without ringing", async () => { + const result = await ringDefaultPost( + undefined as never, + makeInteraction(false), + defaultState(), + ); + + expect(ringDefaultUsers).not.toHaveBeenCalled(); + expect(result.redirect).toBe("/ring"); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("warn"); +}); + +test("ringing while not in a voice channel flashes the join hint and rings nobody", async () => { + const result = await ringUsersPost( + undefined as never, + makeInteraction(false), + state("", ["9"]), + ); + + expect(ring).not.toHaveBeenCalled(); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("warn"); + expect(flashParams.get("flash")).toContain("voice channel"); +}); + +test("ringing from a DM flashes the server-only hint and rings nobody", async () => { + const dmInteraction = { + user: { id: "caller" }, + member: null, + inGuild: () => false, + } as unknown as Interaction; + const result = await ringUsersPost( + undefined as never, + dmInteraction, + state("", ["9"]), + ); + + expect(ring).not.toHaveBeenCalled(); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("warn"); + expect(flashParams.get("flash")).toContain("Discord server"); +}); + +test("a selection rings the submitted users and reports per-user outcomes", async () => { + vi.mocked(ring).mockResolvedValue([ + { userId: "9", status: "fulfilled" }, + { userId: "8", status: "rejected", error: new Error("you blocked <@8>") }, + ]); + + const result = await ringUsersPost( + undefined as never, + makeInteraction(true), + state("", ["9", "8"]), + ); + + expect(ring).toHaveBeenCalledExactlyOnceWith( + voiceChannel, + "caller", + "wants you to join", + ["9", "8"], + ); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("flash")).toContain("Ringed <@9>"); + expect(flashParams.get("flash")).toContain("you blocked <@8>"); +}); diff --git a/src/routes/ring/user/post.ts b/src/routes/ring/user/post.ts new file mode 100644 index 0000000..af3ea9e --- /dev/null +++ b/src/routes/ring/user/post.ts @@ -0,0 +1,13 @@ +import { Handler } from "@routes/types"; + +import { ringUserIds } from "../_shared"; + +// the /ring user:@x quick path +export const ringUserPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const userId = state.queryParams.get("id"); + return ringUserIds(interaction, userId ? [userId] : []); +}; diff --git a/src/routes/ring/users/post.ts b/src/routes/ring/users/post.ts new file mode 100644 index 0000000..69dbc31 --- /dev/null +++ b/src/routes/ring/users/post.ts @@ -0,0 +1,9 @@ +import { Handler } from "@routes/types"; + +import { ringUserIds } from "../_shared"; + +export const ringUsersPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => ringUserIds(interaction, state.values ?? []); diff --git a/src/routes/signups/_shared.ts b/src/routes/signups/_shared.ts new file mode 100644 index 0000000..9f946c6 --- /dev/null +++ b/src/routes/signups/_shared.ts @@ -0,0 +1,81 @@ +import { + EmbedBuilder, + Guild, + Interaction, + PermissionsBitField, +} from "discord.js"; + +import { + getUserVoiceChatSignups, + getVoiceChatRoleSignups, +} from "@db/voice-chats"; +import { backButton, homeButton, row } from "@routes/lib/components"; +import { Handler } from "@routes/types"; + +export const COLOR = "#31a5a5"; +export const PANEL = "/signups"; +export const ROLES = "/signups/roles"; + +export const canManageRoleSignups = (interaction: Interaction): boolean => + interaction.memberPermissions?.has(PermissionsBitField.Flags.ManageRoles) === + true || + interaction.memberPermissions?.has( + PermissionsBitField.Flags.Administrator, + ) === true; + +export const guildVoiceChannelIds = (guild: Guild): string[] => + [...guild.channels.cache.values()] + .filter((channel) => channel.isVoiceBased()) + .map((channel) => channel.id); + +// panels are ephemeral and per-guild; anything reached outside a guild +// (e.g. a DM) has nothing to show +export const guildOnlyRender = (router: Parameters>[0]) => ({ + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("Signups") + .setDescription("โš ๏ธ Signups only work inside a Discord server"), + ], + components: [row(homeButton(router))], +}); + +// the user's signups, restricted to this guild's voice channels and sorted +// by channel name for stable paging +export const guildSignups = async ( + userId: string, + guild: Guild, +): Promise => { + const channelIds = new Set(guildVoiceChannelIds(guild)); + return (await getUserVoiceChatSignups(userId)) + .filter((channelId) => channelIds.has(channelId)) + .sort((a, b) => + (guild.channels.cache.get(a)?.name ?? a).localeCompare( + guild.channels.cache.get(b)?.name ?? b, + ), + ); +}; + +// stable ordering for paging: by role name, then channel name +export const sortedRoleSignups = async (guild: Guild) => { + const nameOf = (roleId: string, channelId: string) => + `${guild.roles.cache.get(roleId)?.name ?? roleId} ${guild.channels.cache.get(channelId)?.name ?? channelId}`; + return (await getVoiceChatRoleSignups(guildVoiceChannelIds(guild))).sort( + (a, b) => + nameOf(a.roleId, a.channelId).localeCompare( + nameOf(b.roleId, b.channelId), + ), + ); +}; + +export const noPermissionRender = (router: Parameters>[0]) => ({ + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("Role signups") + .setDescription( + "โš ๏ธ You need the Manage Roles permission to manage role signups", + ), + ], + components: [row(backButton(router, PANEL), homeButton(router))], +}); diff --git a/src/routes/signups/get.ts b/src/routes/signups/get.ts new file mode 100644 index 0000000..600abd1 --- /dev/null +++ b/src/routes/signups/get.ts @@ -0,0 +1,102 @@ +import { + RouteButtonBuilder, + RouteChannelSelectMenuBuilder, +} from "discord-embed-router"; +import { + ActionRowBuilder, + ButtonStyle, + ChannelType, + EmbedBuilder, +} from "discord.js"; + +import { + navBar, + pagedControls, + showOptionsOf, + subNav, +} from "@routes/lib/components"; +import { withFlash } from "@routes/lib/flash"; +import { + pagedCountLine, + pagedEditPattern, + paginate, + SELECT_MAX_VALUES, +} from "@routes/lib/paging"; +import { Handler } from "@routes/types"; + +import { + canManageRoleSignups, + COLOR, + guildOnlyRender, + guildSignups, + PANEL, + ROLES, +} from "./_shared"; + +export const signupsGet: Handler<"GET"> = async ( + router, + interaction, + state, +) => { + const guild = interaction.guild; + if (!guild) return guildOnlyRender(router); + + const signups = await guildSignups(interaction.user.id, guild); + const { pageItems, page, pageCount } = paginate( + signups, + state.queryParams.get("page"), + ); + + const description = withFlash( + state.queryParams, + "You get rung when someone starts a call in one of your signed-up channels. Edit them below.\n\n" + + pagedCountLine("Signups", signups.length), + ); + + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("๐Ÿ”” Signups") + .setDescription(description), + ], + components: [ + new ActionRowBuilder() + .addComponents( + new RouteChannelSelectMenuBuilder(router) + .setChannelTypes(ChannelType.GuildVoice) + .setMinValues(0) + .setMaxValues(SELECT_MAX_VALUES) + .setPlaceholder("Edit signups: select to add, deselect to remove") + .setDefaultChannels(...pageItems) + .setPattern( + `${PANEL}/members`, + pagedEditPattern(page, state.timestamp), + ), + ) + .toJSON(), + ...pagedControls(router, PANEL, { + page, + pageCount, + showOptions: showOptionsOf(state.queryParams), + options: [ + new RouteButtonBuilder(router) + .setLabel("Reset") + .setStyle(ButtonStyle.Secondary) + .setTo(`${PANEL}/reset`, { method: "MODAL" }), + ], + }), + // the switch into role signups is a management action, so it only + // appears for members who can manage role signups + ...(canManageRoleSignups(interaction) + ? [ + subNav(router, [ + { label: "My signups", path: PANEL, active: true }, + { label: "Role signups", path: ROLES }, + ]), + ] + : []), + navBar(router, interaction), + ], + }; +}; diff --git a/src/routes/signups/members/post.ts b/src/routes/signups/members/post.ts new file mode 100644 index 0000000..c4c4edf --- /dev/null +++ b/src/routes/signups/members/post.ts @@ -0,0 +1,95 @@ +import { addVoiceChatUser, removeVoiceChatUser } from "@db/voice-chats"; +import { joinWithAnd, mentionChannel } from "@main/ring"; +import { flashRedirect } from "@routes/lib/flash"; +import { + paginate, + resolveSelectionEdit, + withPageLabel, +} from "@routes/lib/paging"; +import { Handler } from "@routes/types"; + +import { guildSignups, PANEL } from "../_shared"; + +// serves the panel's diff select and the /signup, /unsignup and /quit +// adapters (which pass add/remove channel ids as query params) +export const signupsMembersPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const guild = interaction.guild; + if (!guild) + return flashRedirect( + PANEL, + "Signups only work inside a Discord server", + "warn", + ); + + const userId = interaction.user.id; + const query = state.queryParams; + // only the select path needs the current signups to diff against; command + // adapters pass the ids to add/remove directly, so skip the fetch for them + const signups = state.values ? await guildSignups(userId, guild) : []; + const { addsRequested, removesRequested, alreadyPresent } = + resolveSelectionEdit({ + current: signups, + values: state.values, + queryParams: query, + }); + + const addResults = await Promise.all( + addsRequested.map(async (channelId) => ({ + channelId, + ok: await addVoiceChatUser(channelId, userId), + })), + ); + const added = addResults.filter((r) => r.ok).map((r) => r.channelId); + const alreadySignedUp = addResults + .filter((r) => !r.ok) + .map((r) => r.channelId); + + const removeResults = await Promise.all( + removesRequested.map(async (channelId) => ({ + channelId, + ok: await removeVoiceChatUser(channelId, userId), + })), + ); + const removed = removeResults.filter((r) => r.ok).map((r) => r.channelId); + const notSignedUp = removeResults + .filter((r) => !r.ok) + .map((r) => r.channelId); + + // the re-fetched list places every entry the flash mentions, so entries + // that land or live off the rendered page get a page label + const after = await guildSignups(userId, guild); + const { page } = paginate(after, query.get("page")); + const label = withPageLabel(after, mentionChannel, page); + + const parts = [ + ...(added.length > 0 + ? [`Signed up for ${joinWithAnd(added.map(label))}`] + : []), + ...(alreadySignedUp.length > 0 || alreadyPresent.length > 0 + ? [ + `You are already signed up for ${joinWithAnd([...alreadySignedUp, ...alreadyPresent].map(label))}`, + ] + : []), + ...(removed.length > 0 + ? [ + `You will no longer be rung for ${joinWithAnd(removed.map(mentionChannel))}`, + ] + : []), + ...(notSignedUp.length > 0 + ? [ + `You aren't signed up for ${joinWithAnd(notSignedUp.map(mentionChannel))}`, + ] + : []), + ]; + const changed = added.length > 0 || removed.length > 0; + return flashRedirect( + PANEL, + parts.length > 0 ? parts.join(". ") : "No changes to your signups", + changed ? "success" : "warn", + { page: query.get("page") ?? "0" }, + ); +}; diff --git a/src/routes/signups/reset/modal.ts b/src/routes/signups/reset/modal.ts new file mode 100644 index 0000000..cd520aa --- /dev/null +++ b/src/routes/signups/reset/modal.ts @@ -0,0 +1,11 @@ +import { confirmModal } from "@routes/lib/confirm"; +import { Handler } from "@routes/types"; + +import { PANEL } from "../_shared"; + +export const signupsResetModal: Handler<"MODAL"> = (router) => + confirmModal(router, { + to: `${PANEL}/reset`, + title: "Reset your signups", + word: "RESET", + }); diff --git a/src/routes/signups/reset/post.ts b/src/routes/signups/reset/post.ts new file mode 100644 index 0000000..6a5ccd5 --- /dev/null +++ b/src/routes/signups/reset/post.ts @@ -0,0 +1,41 @@ +import { removeVoiceChatUser } from "@db/voice-chats"; +import { confirmed } from "@routes/lib/confirm"; +import { flashRedirect } from "@routes/lib/flash"; +import { Handler } from "@routes/types"; + +import { guildSignups, PANEL } from "../_shared"; + +// clears the caller's signups in this guild only; other guilds keep theirs +export const signupsResetPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const guild = interaction.guild; + if (!guild) + return flashRedirect( + PANEL, + "Signups only work inside a Discord server", + "warn", + ); + if (!confirmed(state.fields, "RESET")) + return flashRedirect( + PANEL, + "Confirmation text did not match, your signups were not cleared", + "warn", + ); + + const userId = interaction.user.id; + const signups = await guildSignups(userId, guild); + if (signups.length === 0) + return flashRedirect(PANEL, "You have no signups in this server", "warn"); + + await Promise.all( + signups.map((channelId) => removeVoiceChatUser(channelId, userId)), + ); + return flashRedirect( + PANEL, + `Cleared your ${signups.length} signup${signups.length > 1 ? "s" : ""} in this server`, + "success", + ); +}; diff --git a/src/routes/signups/roles/_shared.ts b/src/routes/signups/roles/_shared.ts new file mode 100644 index 0000000..9486aac --- /dev/null +++ b/src/routes/signups/roles/_shared.ts @@ -0,0 +1,171 @@ +import { RouteRedirect } from "discord-embed-router"; +import { + APIActionRowComponent, + APIComponentInMessageActionRow, + EmbedBuilder, + Guild, + Interaction, +} from "discord.js"; + +import { joinWithAnd } from "@main/ring"; +import { navBar, subNav } from "@routes/lib/components"; +import { flashRedirect, withFlash } from "@routes/lib/flash"; +import { paginate, withPageLabel } from "@routes/lib/paging"; +import { RingRouter } from "@routes/types"; + +import { canManageRoleSignups, COLOR, PANEL, ROLES } from "../_shared"; + +export const BY_CHANNEL = "/signups/roles/by-channel"; +export const BY_ROLE = "/signups/roles/by-role"; + +// the shared lead sentence; both orientations describe the same feature +export const LEAD = + "When someone joins a voice channel, its signed-up roles get pinged."; + +// the scope carried in the path, or null when nothing is picked yet (unlike +// filter/recipients, role signups have no global scope to fall back to) +export const roleScopeOf = ( + params: Partial>, +): string | null => + typeof params.scope === "string" && params.scope.length > 0 + ? params.scope + : null; + +// the guard every role-signups mutation shares: rejects edits outside a +// guild, without Manage Roles, or before a scope is picked. On success +// returns the resolved guild and scope; otherwise the flash to redirect with +export const roleEditGuard = ( + interaction: Interaction, + params: Partial>, + { base, noun }: { base: string; noun: string }, +): { guild: Guild; scope: string } | RouteRedirect => { + const guild = interaction.guild; + if (!guild) + return flashRedirect( + base, + "Signups only work inside a Discord server", + "warn", + ); + if (!canManageRoleSignups(interaction)) + return flashRedirect( + base, + "You need the Manage Roles permission to manage role signups", + "warn", + ); + const scope = roleScopeOf(params); + if (!scope) return flashRedirect(base, `Pick a ${noun} first`, "warn"); + return { guild, scope }; +}; + +// stable name ordering so a scope's links paginate the same way on the GET +// that renders the select and the POST that diffs its submission +export const sortRoleIds = (guild: Guild, ids: string[]): string[] => + [...ids].sort((a, b) => + (guild.roles.cache.get(a)?.name ?? a).localeCompare( + guild.roles.cache.get(b)?.name ?? b, + ), + ); + +export const sortChannelIds = (guild: Guild, ids: string[]): string[] => + [...ids].sort((a, b) => + (guild.channels.cache.get(a)?.name ?? a).localeCompare( + guild.channels.cache.get(b)?.name ?? b, + ), + ); + +// applies an add/remove edit against a scope's current links and reports it as +// a flash. Adds/removes are filtered against the current set so the notice +// reflects real changes; both orientations mutate the same table via callbacks +export const commitRoleEdit = async ({ + redirect, + page, + current, + addsRequested, + removesRequested, + alreadyPresent = [], + mutateAdd, + mutateRemove, + itemMention, + sortItems, +}: { + redirect: string; + page: number; + current: string[]; + addsRequested: string[]; + removesRequested: string[]; + alreadyPresent?: string[]; + mutateAdd: (item: string) => Promise; + mutateRemove: (item: string) => Promise; + itemMention: (id: string) => string; + sortItems: (items: string[]) => string[]; +}): Promise => { + const currentSet = new Set(current); + const toAdd = addsRequested.filter((item) => !currentSet.has(item)); + const toRemove = removesRequested.filter((item) => currentSet.has(item)); + await Promise.all([...toAdd.map(mutateAdd), ...toRemove.map(mutateRemove)]); + + // the post-edit list places every entry the flash mentions, so entries + // that land or live off the rendered page get a page label + const removeSet = new Set(toRemove); + const after = sortItems([ + ...current.filter((item) => !removeSet.has(item)), + ...toAdd, + ]); + const { page: viewedPage } = paginate(after, String(page)); + const label = withPageLabel(after, itemMention, viewedPage); + + const parts = [ + ...(toAdd.length > 0 ? [`Added ${joinWithAnd(toAdd.map(label))}`] : []), + ...(toRemove.length > 0 + ? [`Removed ${joinWithAnd(toRemove.map(itemMention))}`] + : []), + ...(alreadyPresent.length > 0 + ? [ + `${joinWithAnd(alreadyPresent.map(label))} ${alreadyPresent.length > 1 ? "are" : "is"} already signed up`, + ] + : []), + ]; + const changed = toAdd.length > 0 || toRemove.length > 0; + const flash = + parts.length > 0 + ? parts.join(". ") + : addsRequested.length > 0 + ? "Already signed up." + : removesRequested.length > 0 + ? "Those weren't signed up." + : "No changes to role signups."; + return flashRedirect(redirect, flash, changed ? "success" : "warn", { + page: String(page), + }); +}; + +// the frame every role-signups view ends in: the caller's content rows, then +// the My signups / Role signups switch, then the section bar +export const roleFrame = ({ + router, + interaction, + queryParams, + body, + rows, +}: { + router: RingRouter; + interaction: Interaction; + queryParams: URLSearchParams; + body: string; + rows: APIActionRowComponent[]; +}) => ({ + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("๐Ÿ”” Role signups") + .setDescription(withFlash(queryParams, body)), + ], + components: [ + ...rows, + subNav(router, [ + { label: "My signups", path: PANEL }, + { label: "Role signups", path: ROLES, active: true }, + ]), + navBar(router, interaction), + ], +}); diff --git a/src/routes/signups/roles/by-channel/get.ts b/src/routes/signups/roles/by-channel/get.ts new file mode 100644 index 0000000..b591eee --- /dev/null +++ b/src/routes/signups/roles/by-channel/get.ts @@ -0,0 +1,102 @@ +import { + RouteButtonBuilder, + RouteChannelSelectMenuBuilder, + RouteRoleSelectMenuBuilder, +} from "discord-embed-router"; +import { ActionRowBuilder, ButtonStyle, ChannelType } from "discord.js"; + +import { getVoiceChatSignups } from "@db/voice-chats"; +import { mentionChannel } from "@main/ring"; +import { + editSelectRow, + pagedControls, + showOptionsOf, +} from "@routes/lib/components"; +import { pagedCountLine, paginate } from "@routes/lib/paging"; +import { Handler } from "@routes/types"; + +import { + BY_CHANNEL, + LEAD, + roleFrame, + roleScopeOf, + sortRoleIds, +} from "../_shared"; +import { + canManageRoleSignups, + guildOnlyRender, + noPermissionRender, +} from "../../_shared"; +import { rolesGet } from "../get"; + +export const rolesByChannelGet: Handler<"GET"> = async ( + router, + interaction, + state, +) => { + const guild = interaction.guild; + if (!guild) return guildOnlyRender(router); + if (!canManageRoleSignups(interaction)) return noPermissionRender(router); + + const scope = roleScopeOf(state.params); + if (!scope) return rolesGet(router, interaction, state); + + const roleIds = sortRoleIds( + guild, + (await getVoiceChatSignups(scope)).roleIds, + ); + const { pageItems, page, pageCount } = paginate( + roleIds, + state.queryParams.get("page"), + ); + + const scopeSelectRow = new ActionRowBuilder() + .addComponents( + new RouteChannelSelectMenuBuilder(router) + .setChannelTypes(ChannelType.GuildVoice) + .setMinValues(0) + .setMaxValues(1) + .setPlaceholder( + "Viewing a voice channel's roles (clear to pick another)", + ) + .setDefaultChannels(scope) + .setPattern(`${BY_CHANNEL}{/:channelId}`), + ) + .toJSON(); + + const editSelect = editSelectRow( + new RouteRoleSelectMenuBuilder(router).setDefaultRoles(...pageItems), + { + noun: "roles", + pattern: `${BY_CHANNEL}/${scope}/roles`, + page, + timestamp: state.timestamp, + }, + ); + + return roleFrame({ + router, + interaction, + queryParams: state.queryParams, + body: + `${LEAD}\n\n**Viewing** ${mentionChannel(scope)}\n` + + pagedCountLine("Roles", roleIds.length), + // the scope select leads as the page's context, then its edit list and + // pager; the switch and section bar follow in the frame + rows: [ + scopeSelectRow, + editSelect, + ...pagedControls(router, `${BY_CHANNEL}/${scope}`, { + page, + pageCount, + showOptions: showOptionsOf(state.queryParams), + options: [ + new RouteButtonBuilder(router) + .setLabel("Reset") + .setStyle(ButtonStyle.Secondary) + .setTo(`${BY_CHANNEL}/${scope}/reset`, { method: "MODAL" }), + ], + }), + ], + }); +}; diff --git a/src/routes/signups/roles/by-channel/reset/modal.ts b/src/routes/signups/roles/by-channel/reset/modal.ts new file mode 100644 index 0000000..ddd7857 --- /dev/null +++ b/src/routes/signups/roles/by-channel/reset/modal.ts @@ -0,0 +1,15 @@ +import { confirmModal } from "@routes/lib/confirm"; +import { Handler } from "@routes/types"; + +import { BY_CHANNEL, roleScopeOf } from "../../_shared"; + +export const rolesByChannelResetModal: Handler<"MODAL"> = ( + router, + interaction, + state, +) => + confirmModal(router, { + to: `${BY_CHANNEL}/${roleScopeOf(state.params) ?? ""}/reset`, + title: "Reset this channel's roles", + word: "RESET", + }); diff --git a/src/routes/signups/roles/by-channel/reset/post.ts b/src/routes/signups/roles/by-channel/reset/post.ts new file mode 100644 index 0000000..305a105 --- /dev/null +++ b/src/routes/signups/roles/by-channel/reset/post.ts @@ -0,0 +1,46 @@ +import { getVoiceChatSignups, removeVoiceChatRole } from "@db/voice-chats"; +import { mentionChannel } from "@main/ring"; +import { confirmed } from "@routes/lib/confirm"; +import { flashRedirect } from "@routes/lib/flash"; +import { Handler } from "@routes/types"; + +import { BY_CHANNEL, roleEditGuard } from "../../_shared"; + +// clears every role pinged in one voice channel +export const rolesByChannelResetPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const guard = roleEditGuard(interaction, state.params, { + base: BY_CHANNEL, + noun: "channel", + }); + if (!("guild" in guard)) return guard; + const { scope } = guard; + + const panel = `${BY_CHANNEL}/${scope}`; + if (!confirmed(state.fields, "RESET")) + return flashRedirect( + panel, + "Confirmation text did not match, the roles were not cleared", + "warn", + ); + + const { roleIds } = await getVoiceChatSignups(scope); + if (roleIds.length === 0) + return flashRedirect( + panel, + `${mentionChannel(scope)} has no roles signed up`, + "warn", + ); + + await Promise.all( + roleIds.map((roleId) => removeVoiceChatRole(scope, roleId)), + ); + return flashRedirect( + panel, + `Cleared the ${roleIds.length} role${roleIds.length > 1 ? "s" : ""} pinged in ${mentionChannel(scope)}`, + "success", + ); +}; diff --git a/src/routes/signups/roles/by-channel/roles/post.ts b/src/routes/signups/roles/by-channel/roles/post.ts new file mode 100644 index 0000000..215f6d9 --- /dev/null +++ b/src/routes/signups/roles/by-channel/roles/post.ts @@ -0,0 +1,56 @@ +import { + addVoiceChatRole, + getVoiceChatSignups, + removeVoiceChatRole, +} from "@db/voice-chats"; +import { mentionRole } from "@main/ring"; +import { resolveSelectionEdit } from "@routes/lib/paging"; +import { Handler } from "@routes/types"; + +import { + BY_CHANNEL, + commitRoleEdit, + roleEditGuard, + sortRoleIds, +} from "../../_shared"; + +// edits the roles signed up to one voice channel: the panel's role select +// (diffed against the page) and the /signuprole adapter's `add` query param +export const rolesByChannelEditPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const guard = roleEditGuard(interaction, state.params, { + base: BY_CHANNEL, + noun: "channel", + }); + if (!("guild" in guard)) return guard; + const { guild, scope } = guard; + + const query = state.queryParams; + const current = sortRoleIds( + guild, + (await getVoiceChatSignups(scope)).roleIds, + ); + + const { addsRequested, removesRequested, alreadyPresent } = + resolveSelectionEdit({ + current, + values: state.values, + queryParams: query, + }); + + return commitRoleEdit({ + redirect: `${BY_CHANNEL}/${scope}`, + page: parseInt(query.get("page") ?? "0") || 0, + current, + addsRequested, + removesRequested, + alreadyPresent, + mutateAdd: (roleId) => addVoiceChatRole(scope, roleId), + mutateRemove: (roleId) => removeVoiceChatRole(scope, roleId), + itemMention: mentionRole, + sortItems: (roleIds) => sortRoleIds(guild, roleIds), + }); +}; diff --git a/src/routes/signups/roles/by-role/channels/post.ts b/src/routes/signups/roles/by-role/channels/post.ts new file mode 100644 index 0000000..fb643aa --- /dev/null +++ b/src/routes/signups/roles/by-role/channels/post.ts @@ -0,0 +1,59 @@ +import { + addVoiceChatRole, + getVoiceChatRoleSignups, + removeVoiceChatRole, +} from "@db/voice-chats"; +import { mentionChannel } from "@main/ring"; +import { resolveSelectionEdit } from "@routes/lib/paging"; +import { Handler } from "@routes/types"; + +import { + BY_ROLE, + commitRoleEdit, + roleEditGuard, + sortChannelIds, +} from "../../_shared"; +import { guildVoiceChannelIds } from "../../../_shared"; + +// edits the channels one role is signed up to: the panel's channel select +// (diffed against the page) and the /unsignuprole adapter's `remove` query param +export const rolesByRoleEditPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const guard = roleEditGuard(interaction, state.params, { + base: BY_ROLE, + noun: "role", + }); + if (!("guild" in guard)) return guard; + const { guild, scope } = guard; + + const query = state.queryParams; + const current = sortChannelIds( + guild, + (await getVoiceChatRoleSignups(guildVoiceChannelIds(guild))) + .filter((mapping) => mapping.roleId === scope) + .map((mapping) => mapping.channelId), + ); + + const { addsRequested, removesRequested, alreadyPresent } = + resolveSelectionEdit({ + current, + values: state.values, + queryParams: query, + }); + + return commitRoleEdit({ + redirect: `${BY_ROLE}/${scope}`, + page: parseInt(query.get("page") ?? "0") || 0, + current, + addsRequested, + removesRequested, + alreadyPresent, + mutateAdd: (channelId) => addVoiceChatRole(channelId, scope), + mutateRemove: (channelId) => removeVoiceChatRole(channelId, scope), + itemMention: mentionChannel, + sortItems: (channelIds) => sortChannelIds(guild, channelIds), + }); +}; diff --git a/src/routes/signups/roles/by-role/get.ts b/src/routes/signups/roles/by-role/get.ts new file mode 100644 index 0000000..8b7211f --- /dev/null +++ b/src/routes/signups/roles/by-role/get.ts @@ -0,0 +1,106 @@ +import { + RouteButtonBuilder, + RouteChannelSelectMenuBuilder, + RouteRoleSelectMenuBuilder, +} from "discord-embed-router"; +import { ActionRowBuilder, ButtonStyle, ChannelType } from "discord.js"; + +import { getVoiceChatRoleSignups } from "@db/voice-chats"; +import { mentionRole } from "@main/ring"; +import { + editSelectRow, + pagedControls, + showOptionsOf, +} from "@routes/lib/components"; +import { pagedCountLine, paginate } from "@routes/lib/paging"; +import { Handler } from "@routes/types"; + +import { + BY_ROLE, + LEAD, + roleFrame, + roleScopeOf, + sortChannelIds, +} from "../_shared"; +import { + canManageRoleSignups, + guildOnlyRender, + guildVoiceChannelIds, + noPermissionRender, +} from "../../_shared"; +import { rolesGet } from "../get"; + +export const rolesByRoleGet: Handler<"GET"> = async ( + router, + interaction, + state, +) => { + const guild = interaction.guild; + if (!guild) return guildOnlyRender(router); + if (!canManageRoleSignups(interaction)) return noPermissionRender(router); + + const scope = roleScopeOf(state.params); + if (!scope) return rolesGet(router, interaction, state); + + const channelIds = sortChannelIds( + guild, + (await getVoiceChatRoleSignups(guildVoiceChannelIds(guild))) + .filter((mapping) => mapping.roleId === scope) + .map((mapping) => mapping.channelId), + ); + const { pageItems, page, pageCount } = paginate( + channelIds, + state.queryParams.get("page"), + ); + + const scopeSelectRow = new ActionRowBuilder() + .addComponents( + new RouteRoleSelectMenuBuilder(router) + .setMinValues(0) + .setMaxValues(1) + .setPlaceholder( + "Viewing a role's voice channels (clear to pick another)", + ) + .setDefaultRoles(scope) + .setPattern(`${BY_ROLE}{/:roleId}`), + ) + .toJSON(); + + const editSelect = editSelectRow( + new RouteChannelSelectMenuBuilder(router) + .setChannelTypes(ChannelType.GuildVoice) + .setDefaultChannels(...pageItems), + { + noun: "voice channels", + pattern: `${BY_ROLE}/${scope}/channels`, + page, + timestamp: state.timestamp, + }, + ); + + return roleFrame({ + router, + interaction, + queryParams: state.queryParams, + body: + `${LEAD}\n\n**Viewing** ${mentionRole(scope)}\n` + + pagedCountLine("Signups", channelIds.length), + // the scope select leads as the page's context, then its edit list and + // pager; the switch and section bar follow in the frame + rows: [ + scopeSelectRow, + editSelect, + ...pagedControls(router, `${BY_ROLE}/${scope}`, { + page, + pageCount, + showOptions: showOptionsOf(state.queryParams), + options: [ + new RouteButtonBuilder(router) + .setLabel("Reset") + .setStyle(ButtonStyle.Secondary) + .setTo(`${BY_ROLE}/${scope}/reset`, { method: "MODAL" }), + ], + }), + ], + }); +}; diff --git a/src/routes/signups/roles/by-role/reset/modal.ts b/src/routes/signups/roles/by-role/reset/modal.ts new file mode 100644 index 0000000..e3efef8 --- /dev/null +++ b/src/routes/signups/roles/by-role/reset/modal.ts @@ -0,0 +1,15 @@ +import { confirmModal } from "@routes/lib/confirm"; +import { Handler } from "@routes/types"; + +import { BY_ROLE, roleScopeOf } from "../../_shared"; + +export const rolesByRoleResetModal: Handler<"MODAL"> = ( + router, + interaction, + state, +) => + confirmModal(router, { + to: `${BY_ROLE}/${roleScopeOf(state.params) ?? ""}/reset`, + title: "Reset this role's signups", + word: "RESET", + }); diff --git a/src/routes/signups/roles/by-role/reset/post.ts b/src/routes/signups/roles/by-role/reset/post.ts new file mode 100644 index 0000000..f032ade --- /dev/null +++ b/src/routes/signups/roles/by-role/reset/post.ts @@ -0,0 +1,51 @@ +import { getVoiceChatRoleSignups, removeVoiceChatRole } from "@db/voice-chats"; +import { mentionRole } from "@main/ring"; +import { confirmed } from "@routes/lib/confirm"; +import { flashRedirect } from "@routes/lib/flash"; +import { Handler } from "@routes/types"; + +import { BY_ROLE, roleEditGuard } from "../../_shared"; +import { guildVoiceChannelIds } from "../../../_shared"; + +// clears every voice channel one role is signed up for +export const rolesByRoleResetPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const guard = roleEditGuard(interaction, state.params, { + base: BY_ROLE, + noun: "role", + }); + if (!("guild" in guard)) return guard; + const { guild, scope } = guard; + + const panel = `${BY_ROLE}/${scope}`; + if (!confirmed(state.fields, "RESET")) + return flashRedirect( + panel, + "Confirmation text did not match, the signups were not cleared", + "warn", + ); + + const channelIds = ( + await getVoiceChatRoleSignups(guildVoiceChannelIds(guild)) + ) + .filter((mapping) => mapping.roleId === scope) + .map((mapping) => mapping.channelId); + if (channelIds.length === 0) + return flashRedirect( + panel, + `${mentionRole(scope)} is not signed up for any voice channels`, + "warn", + ); + + await Promise.all( + channelIds.map((channelId) => removeVoiceChatRole(channelId, scope)), + ); + return flashRedirect( + panel, + `Cleared the ${channelIds.length} voice channel${channelIds.length > 1 ? "s" : ""} ${mentionRole(scope)} was signed up for`, + "success", + ); +}; diff --git a/src/routes/signups/roles/get.ts b/src/routes/signups/roles/get.ts new file mode 100644 index 0000000..ec22df1 --- /dev/null +++ b/src/routes/signups/roles/get.ts @@ -0,0 +1,51 @@ +import { + RouteChannelSelectMenuBuilder, + RouteRoleSelectMenuBuilder, +} from "discord-embed-router"; +import { ActionRowBuilder, ChannelType } from "discord.js"; + +import { Handler } from "@routes/types"; + +import { + canManageRoleSignups, + guildOnlyRender, + noPermissionRender, +} from "../_shared"; +import { BY_CHANNEL, BY_ROLE, LEAD, roleFrame } from "./_shared"; + +export const rolesGet: Handler<"GET"> = async (router, interaction, state) => { + const guild = interaction.guild; + if (!guild) return guildOnlyRender(router); + if (!canManageRoleSignups(interaction)) return noPermissionRender(router); + + const channelSelectRow = new ActionRowBuilder() + .addComponents( + new RouteChannelSelectMenuBuilder(router) + .setChannelTypes(ChannelType.GuildVoice) + .setMinValues(0) + .setMaxValues(1) + .setPlaceholder("View a voice channel's roles") + .setPattern(`${BY_CHANNEL}{/:channelId}`), + ) + .toJSON(); + + const roleSelectRow = new ActionRowBuilder() + .addComponents( + new RouteRoleSelectMenuBuilder(router) + .setMinValues(0) + .setMaxValues(1) + .setPlaceholder("View a role's voice channels") + .setPattern(`${BY_ROLE}{/:roleId}`), + ) + .toJSON(); + + // the entry view with nothing picked: the first select the user touches + // sets the orientation; clearing that scope later returns here + return roleFrame({ + router, + interaction, + queryParams: state.queryParams, + body: `${LEAD}\n\nPick a voice channel or a role below to view and edit its signups.`, + rows: [channelSelectRow, roleSelectRow], + }); +}; diff --git a/src/routes/signups/signups.test.ts b/src/routes/signups/signups.test.ts new file mode 100644 index 0000000..bf2c84c --- /dev/null +++ b/src/routes/signups/signups.test.ts @@ -0,0 +1,204 @@ +import { Interaction, PermissionsBitField } from "discord.js"; +import { beforeEach, expect, test, vi } from "vitest"; + +import { + addVoiceChatRole, + getUserVoiceChatSignups, + getVoiceChatRoleSignups, + getVoiceChatSignups, + removeVoiceChatRole, + removeVoiceChatUser, +} from "@db/voice-chats"; + +import { signupsResetPost } from "./reset/post"; +import { rolesByChannelResetPost } from "./roles/by-channel/reset/post"; +import { rolesByChannelEditPost } from "./roles/by-channel/roles/post"; +import { rolesByRoleResetPost } from "./roles/by-role/reset/post"; + +vi.mock("@db/voice-chats", () => ({ + getUserVoiceChatSignups: vi.fn(), + getVoiceChatSignups: vi.fn(), + getVoiceChatRoleSignups: vi.fn(), + addVoiceChatUser: vi.fn(), + removeVoiceChatUser: vi.fn(), + addVoiceChatRole: vi.fn(), + removeVoiceChatRole: vi.fn(), +})); + +const makeInteraction = (managesRoles: boolean) => + ({ + user: { id: "caller" }, + memberPermissions: new PermissionsBitField( + managesRoles ? PermissionsBitField.Flags.ManageRoles : 0n, + ), + guild: { + roles: { cache: new Map() }, + channels: { + cache: new Map([["vc1", { id: "vc1", isVoiceBased: () => true }]]), + }, + }, + }) as unknown as Interaction; + +const editState = (opts: { + scope: string; + query?: string; + values?: string[]; +}) => + ({ + params: { scope: opts.scope }, + queryParams: new URLSearchParams(opts.query ?? ""), + values: opts.values, + timestamp: 0, + }) as unknown as Parameters[2]; + +const flashOf = (result: { queryParams?: unknown }) => + new URLSearchParams(result.queryParams as Record); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(addVoiceChatRole).mockResolvedValue(true); + vi.mocked(removeVoiceChatRole).mockResolvedValue(true); +}); + +test("editing a channel's roles adds the newly selected role only", async () => { + vi.mocked(getVoiceChatSignups).mockResolvedValue({ + userIds: [], + roleIds: ["r1"], + }); + + const result = await rolesByChannelEditPost( + undefined as never, + makeInteraction(true), + editState({ scope: "vc1", values: ["r1", "r2"] }), + ); + + expect(addVoiceChatRole).toHaveBeenCalledExactlyOnceWith("vc1", "r2"); + expect(removeVoiceChatRole).not.toHaveBeenCalled(); + expect(result.redirect).toBe("/signups/roles/by-channel/vc1"); + expect(flashOf(result).get("level")).toBe("success"); +}); + +test("deselecting a role removes it from the channel", async () => { + vi.mocked(getVoiceChatSignups).mockResolvedValue({ + userIds: [], + roleIds: ["r1", "r2"], + }); + + await rolesByChannelEditPost( + undefined as never, + makeInteraction(true), + editState({ scope: "vc1", values: ["r1"] }), + ); + + expect(removeVoiceChatRole).toHaveBeenCalledExactlyOnceWith("vc1", "r2"); + expect(addVoiceChatRole).not.toHaveBeenCalled(); +}); + +test("the signuprole add param signs a role up for the channel", async () => { + vi.mocked(getVoiceChatSignups).mockResolvedValue({ + userIds: [], + roleIds: [], + }); + + const result = await rolesByChannelEditPost( + undefined as never, + makeInteraction(true), + editState({ scope: "vc1", query: "add=r5" }), + ); + + expect(addVoiceChatRole).toHaveBeenCalledExactlyOnceWith("vc1", "r5"); + expect(result.redirect).toBe("/signups/roles/by-channel/vc1"); +}); + +test("a role edit without Manage Roles mutates nothing", async () => { + const result = await rolesByChannelEditPost( + undefined as never, + makeInteraction(false), + editState({ scope: "vc1", values: ["r1", "r2"] }), + ); + + expect(addVoiceChatRole).not.toHaveBeenCalled(); + expect(flashOf(result).get("level")).toBe("warn"); + expect(flashOf(result).get("flash")).toContain("Manage Roles"); +}); + +const resetState = (confirmation: string, scope?: string) => + ({ + params: scope === undefined ? {} : { scope }, + queryParams: new URLSearchParams(), + timestamp: 0, + fields: { getTextInputValue: () => confirmation }, + }) as unknown as Parameters[2]; + +test("a signups reset clears only this guild's signups", async () => { + vi.mocked(getUserVoiceChatSignups).mockResolvedValue(["vc1", "elsewhere"]); + vi.mocked(removeVoiceChatUser).mockResolvedValue(true); + + const result = await signupsResetPost( + undefined as never, + makeInteraction(false), + resetState("RESET"), + ); + + expect(removeVoiceChatUser).toHaveBeenCalledExactlyOnceWith("vc1", "caller"); + expect(flashOf(result).get("level")).toBe("success"); +}); + +test("a signups reset without matching confirmation mutates nothing", async () => { + vi.mocked(getUserVoiceChatSignups).mockResolvedValue(["vc1"]); + + const result = await signupsResetPost( + undefined as never, + makeInteraction(false), + resetState("nope"), + ); + + expect(removeVoiceChatUser).not.toHaveBeenCalled(); + expect(flashOf(result).get("level")).toBe("warn"); + expect(flashOf(result).get("flash")).toContain("did not match"); +}); + +test("a channel-roles reset clears every role pinged in the channel", async () => { + vi.mocked(getVoiceChatSignups).mockResolvedValue({ + userIds: [], + roleIds: ["r1", "r2"], + }); + + const result = await rolesByChannelResetPost( + undefined as never, + makeInteraction(true), + resetState("RESET", "vc1"), + ); + + expect(removeVoiceChatRole).toHaveBeenCalledWith("vc1", "r1"); + expect(removeVoiceChatRole).toHaveBeenCalledWith("vc1", "r2"); + expect(result.redirect).toBe("/signups/roles/by-channel/vc1"); +}); + +test("a role-signups reset without Manage Roles mutates nothing", async () => { + const result = await rolesByRoleResetPost( + undefined as never, + makeInteraction(false), + resetState("RESET", "role1"), + ); + + expect(removeVoiceChatRole).not.toHaveBeenCalled(); + expect(flashOf(result).get("flash")).toContain("Manage Roles"); +}); + +test("a role-channels reset clears every channel the role is signed up for", async () => { + vi.mocked(getVoiceChatRoleSignups).mockResolvedValue([ + { roleId: "role1", channelId: "vcA" }, + { roleId: "other", channelId: "vcC" }, + ]); + + const result = await rolesByRoleResetPost( + undefined as never, + makeInteraction(true), + resetState("RESET", "role1"), + ); + + expect(removeVoiceChatRole).toHaveBeenCalledExactlyOnceWith("vcA", "role1"); + expect(flashOf(result).get("level")).toBe("success"); + expect(result.redirect).toBe("/signups/roles/by-role/role1"); +}); diff --git a/src/routes/types.ts b/src/routes/types.ts new file mode 100644 index 0000000..89a287a --- /dev/null +++ b/src/routes/types.ts @@ -0,0 +1,33 @@ +import { + EmbedRouter, + Method, + RouteButtonBuilder, + RouteHandler, + RouteModalBuilder, +} from "discord-embed-router"; + +import { CommandName } from "@commands/commandNames"; + +export type Globals = { + commandIds: Map; +}; + +// sessions and locals are deliberately unused: panel state lives entirely in +// paths and query params, and route handlers import their db access directly +export type RingRouter = EmbedRouter; + +export type Handler = + RingRouter extends EmbedRouter + ? RouteHandler + : never; + +// nameable alias for helper return types (declaration emit can't name the +// library's internal Unused symbol that Session/Locals default to) +export type RingButton = + RingRouter extends EmbedRouter + ? RouteButtonBuilder + : never; +export type RingModal = + RingRouter extends EmbedRouter + ? RouteModalBuilder + : never; diff --git a/tsconfig.json b/tsconfig.json index 7ddbf69..c674877 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ // Visit https://aka.ms/tsconfig to read more about this file // bundling is done by tsup; tsc is only used for typechecking (no emit), // so there is no rootDir and scripts/ is typechecked alongside src/ - "include": ["src", "scripts"], + "include": ["src", "scripts", "vitest.config.mts"], "compilerOptions": { // File Layout "outDir": "./dist", @@ -10,7 +10,8 @@ "@config": ["./src/config"], "@commands/*": ["./src/commands/*"], "@db/*": ["./src/main/db/*"], - "@main/*": ["./src/main/*"] + "@main/*": ["./src/main/*"], + "@routes/*": ["./src/routes/*"] }, // Environment Settings diff --git a/vc.webp b/vc.webp new file mode 100644 index 0000000..5d24bce Binary files /dev/null and b/vc.webp differ diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 0000000..dd07028 --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,31 @@ +import { resolve } from "node:path"; + +import { defineConfig } from "vitest/config"; + +// mirrors the path aliases in tsconfig.json +const src = (subpath: string) => resolve(import.meta.dirname, "src", subpath); + +export default defineConfig({ + resolve: { + alias: [ + { find: /^@config$/, replacement: src("config.ts") }, + { find: /^@commands\//, replacement: src("commands") + "/" }, + { find: /^@db\//, replacement: src("main/db") + "/" }, + { find: /^@main\//, replacement: src("main") + "/" }, + { find: /^@routes\//, replacement: src("routes") + "/" }, + ], + }, + test: { + include: ["src/**/*.test.ts"], + // config.ts and the supabase client validate these at import, and test + // modules pull them in transitively (e.g. mocking a db module with its + // real implementation). Dummy values keep the suite self-contained so it + // never depends on a developer's .env; the db layer is mocked in tests + env: { + DISCORD_TOKEN: "test-token", + DISCORD_CLIENT_ID: "test-client-id", + SUPABASE_URL: "http://localhost:54321", + SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", + }, + }, +});