From a7a15325ff9f39a30da915f626648434e6a57a5c Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Fri, 19 Jun 2026 23:28:18 +0300 Subject: [PATCH 1/7] feat: add oxc --- .vscode/extensions.json | 3 + .vscode/settings.json | 23 +- biome.json | 38 --- oxfmt.config.ts | 13 + oxlint.config.ts | 16 ++ package.json | 11 +- pnpm-lock.yaml | 509 +++++++++++++++++++++++++++++++++++++++- tsconfig.json | 3 +- 8 files changed, 552 insertions(+), 64 deletions(-) create mode 100644 .vscode/extensions.json delete mode 100644 biome.json create mode 100644 oxfmt.config.ts create mode 100644 oxlint.config.ts diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..9d5eb3d --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["oxc.oxc-vscode"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 9affadd..5680403 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,16 +1,11 @@ { - "editor.defaultFormatter": "biomejs.biome", - "prettier.enable": false, - "oxc.enable": false, - "editor.formatOnSave": true, - "editor.codeActionsOnSave": { - "source.fixAll.biome": "explicit" - }, - "[typescript]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[json]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "typescript.tsdk": "node_modules/typescript/lib" + "editor.codeActionsOnSave": { + "source.fixAll.oxc": "always" + }, + "oxc.fmt.configPath": "oxfmt.config.ts", + "editor.defaultFormatter": "oxc.oxc-vscode", + "editor.formatOnSave": true, + "biome.enabled": false, + "prettier.enable": false, + "eslint.enable": false } diff --git a/biome.json b/biome.json deleted file mode 100644 index 4813a99..0000000 --- a/biome.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", - "linter": { - "enabled": true, - "rules": { - "recommended": true, - "style": { - "noNonNullAssertion": "off", - "useConst": "error", - "useTemplate": "error", - "useBlockStatements": "error" - }, - "suspicious": { - "noExplicitAny": "warn", - "noArrayIndexKey": "off" - }, - "correctness": { - "noUnusedVariables": "error" - } - } - }, - "formatter": { - "enabled": true, - "indentStyle": "space", - "indentWidth": 2, - "lineWidth": 100 - }, - "javascript": { - "formatter": { - "quoteStyle": "single", - "trailingCommas": "es5", - "semicolons": "always" - } - }, - "files": { - "includes": ["*.ts", "*.js", "*.json", "src/**/*", "test/**/*"] - } -} diff --git a/oxfmt.config.ts b/oxfmt.config.ts new file mode 100644 index 0000000..9cb6b2a --- /dev/null +++ b/oxfmt.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "oxfmt"; + +export default defineConfig({ + printWidth: 80, + useTabs: false, + tabWidth: 2, + semi: true, + singleQuote: true, + trailingComma: "es5", + bracketSpacing: true, + arrowParens: "always", + ignorePatterns: ["node_modules", "dist", "build", "coverage", ".git"], +}); diff --git a/oxlint.config.ts b/oxlint.config.ts new file mode 100644 index 0000000..ebc96d7 --- /dev/null +++ b/oxlint.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "oxlint"; + +export default defineConfig({ + options: { + typeAware: true, + typeCheck: true, + }, + plugins: ["eslint", "typescript"], + rules: { + curly: "warn", + "prefer-template": "warn", + "typescript/no-explicit-any": "error", + "prefer-const": "error", + }, + ignorePatterns: ["node_modules", "dist", "build", "coverage", ".git"], +}); diff --git a/package.json b/package.json index 8a1ace7..a6f91d7 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,10 @@ "docker:prod": "NODE_VERSION=$(cat .nvmrc | tr -d 'v') docker compose --profile prod up -d", "docker:stop": "docker compose down", "check": "biome check --write .", + "lint": "oxlint", + "lint:fix": "oxlint --fix", + "fmt": "oxfmt", + "fmt:check": "oxfmt --check", "typecheck": "tsc --noEmit", "test": "tsx --test '**/*.test.ts'", "test:ci": "NODE_ENV=test node --test dist/**/*.test.js", @@ -28,7 +32,6 @@ "dependencies": { "discord.js": "^14.26.4", "node-cron": "^4.2.1", - "typescript": "^5.9.3", "web-features": "^3.9.2" }, "devDependencies": { @@ -37,9 +40,13 @@ "@types/node-cron": "^3.0.11", "husky": "^9.1.7", "lint-staged": "^16.2.1", + "oxfmt": "^0.55.0", + "oxlint": "^1.70.0", + "oxlint-tsgolint": "^0.23.0", "tsc-alias": "^1.8.17", "tsup": "^8.5.0", - "tsx": "^4.20.6" + "tsx": "^4.20.6", + "typescript": "^6.0.3" }, "lint-staged": { "*.{ts,js}": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index faa547a..dba88d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,6 @@ importers: node-cron: specifier: ^4.2.1 version: 4.2.1 - typescript: - specifier: ^5.9.3 - version: 5.9.3 web-features: specifier: ^3.9.2 version: 3.9.2 @@ -36,15 +33,27 @@ importers: lint-staged: specifier: ^16.2.1 version: 16.2.4 + oxfmt: + specifier: ^0.55.0 + version: 0.55.0 + oxlint: + specifier: ^1.70.0 + version: 1.70.0(oxlint-tsgolint@0.23.0) + oxlint-tsgolint: + specifier: ^0.23.0 + version: 0.23.0 tsc-alias: specifier: ^1.8.17 version: 1.8.17 tsup: specifier: ^8.5.0 - version: 8.5.0(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) + version: 8.5.0(tsx@4.20.6)(typescript@6.0.3)(yaml@2.8.1) tsx: specifier: ^4.20.6 version: 4.20.6 + typescript: + specifier: ^6.0.3 + version: 6.0.3 packages: @@ -314,6 +323,264 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@oxfmt/binding-android-arm-eabi@0.55.0': + resolution: {integrity: sha512-+rFDOqQe5LOWgxrAJaZgLRudr6GQm0wGI6gtu7vVkrdLGjNMUSGbAlaCr8j7F2H2Er97vYQCU8WDb30onqMM1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.55.0': + resolution: {integrity: sha512-ctulLq8s3x8Zmvw6+iccB09TIKERAklRSmbJ10gk8mlAn05qZxoyo52dj3Hi9IJcmDSwF54fQaTVh2CbL6PInw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.55.0': + resolution: {integrity: sha512-xDQczLH9pw/RBk1h/GH0qcGMm8hQtmtVHBNLSH3lk1gEIR09hZ4L+mJQl4VqiVAvPK9VG9PYrWWuSQLt7xTbiA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.55.0': + resolution: {integrity: sha512-JaNoFCkF2CJdGgpPSMbuO9HVyXyoNGIhMHPvp6NYAjeVKw9XEYc0HcUWJLPQa3Q69WV5wMa9m5jPMJPtbLtcRg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.55.0': + resolution: {integrity: sha512-DNbszhpg6S2MIzax5azdHFTTBIVkR5xr8yyRZuA4yoDAwOkzIp3tmldgKZM2+VlT+hJIG0xUksA+elISzMEAfA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.55.0': + resolution: {integrity: sha512-2snoaoRfFFyGnbOcKUK36rREBYxe/Xgz3uHbiA5zbCB/s6R4DQj4mHqYAaWWhgizCUSDxV8cE9zAZ0XleNpKGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.55.0': + resolution: {integrity: sha512-q1aktHF/WRpSK81BX1dE/9vWrS2jGw1Nax2kb4DBLGAewubCLcoNyp4Zl/NSMgbv3vUS46Z33wIQkBVYOP3PYg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.55.0': + resolution: {integrity: sha512-VD0y36aENezl/3tsclA/4G53Cc7iV+7Uoh7gz4yvcOTaEYBtJpQsE6PKDGTtUtOvGS4kv51ybfXY/nWZejO5IA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxfmt/binding-linux-arm64-musl@0.55.0': + resolution: {integrity: sha512-r8xlKJFcsRmn0H5jZrdORae6RX9jDBrZVvOoxF+bCQtampQJClv80aZEHsv+NsLsp2KCE5ql79O7DpPVzYWpXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxfmt/binding-linux-ppc64-gnu@0.55.0': + resolution: {integrity: sha512-GRKv/HXHcwIVld/WU61rF0g0R16hl5EJ+ScKdpjevT57lnLnagj/U2YUbXf2mT+2Pg1uCzWC+mvGicPV3CDdLQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxfmt/binding-linux-riscv64-gnu@0.55.0': + resolution: {integrity: sha512-rdv57enTiPtpSYRMKfAiEbQb0Puw5t9N7isVinDoo5qeLDScro2gznmZqSgSWbVZRzLisTeCTW8Qwgw0bOHv3A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxfmt/binding-linux-riscv64-musl@0.55.0': + resolution: {integrity: sha512-7v1nNrlD43VY6+sYQ6efYyb3lE6QY182304PD/768ZxTjOmFd/3dQa3u/nGBUAXYdGSWOQc5N3PnS0QzUXyEIA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxfmt/binding-linux-s390x-gnu@0.55.0': + resolution: {integrity: sha512-f4lJLUSPOgScjFl9LiflKCTocyNRwE25JmTMbN4XQdDjoZzEHjqf3wA3VESF1/csg7i8m7+EQLbrZyYDqe10UQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxfmt/binding-linux-x64-gnu@0.55.0': + resolution: {integrity: sha512-MihqiPziJNoWy4MqNSV+jVA1g+07iQDjZiR0vaCaDoPgFEiJpCMsxamktzLV07cEeQsSJ04vQaU4CzCQwIvtDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxfmt/binding-linux-x64-musl@0.55.0': + resolution: {integrity: sha512-Yqghym7KYAVjP9MmSrNZiDeerMuoejNjo0r3ox5H3GDKk8eAfl8VyJm9i+pWCLDCTnAbcTUMMN2ZKjUYXH1v3g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxfmt/binding-openharmony-arm64@0.55.0': + resolution: {integrity: sha512-s5SDvVVSbyQl1V5UU3Yl12M+XLUQ3rl5SglNqgAA2K4PXUtQhyNSS00wivONPEnNo5W01rCou8WkDNyvI/RGHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.55.0': + resolution: {integrity: sha512-7p9FB5R32tw2KyyNX3wpQrR2WHwEHvMEiBlGXxeTCaRMCVNx3UtFMAUbaQ/pRNWIrEUZmYhJ6tcUH52uPTRYjQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.55.0': + resolution: {integrity: sha512-ZYqj3fDnOT1IaVGMP5kpmkQl4F3tQIm2ZyAxvqkJYmI0xgWWak4ss4XYwv3VDfM+TWXeC9K4uQ/wW5jm/5XABA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.55.0': + resolution: {integrity: sha512-eEYT5tivGnGbPHuOHuQpi6CGLObhh0re/5jcNQHihD2GRYkTM85dyi5a19zjP8Q00t1uqAx+/QGLUGdHeqzWyg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint-tsgolint/darwin-arm64@0.23.0': + resolution: {integrity: sha512-gOs9PVr2wEg4ox9z0aJo+RKhhImW86YL5N6yav8BK/rgPsIrwN/igSZ+pbRr723NFvUNKde9fgMhRA6JrXAOZw==} + cpu: [arm64] + os: [darwin] + + '@oxlint-tsgolint/darwin-x64@0.23.0': + resolution: {integrity: sha512-kjJ8B+7n4tB9VJdxS5A9GdJt6/bYpzbu4lXp2uO1S3sRmCB5gDEABlGoiePNApRWaW+xqL4b4xgiE727jSLhuA==} + cpu: [x64] + os: [darwin] + + '@oxlint-tsgolint/linux-arm64@0.23.0': + resolution: {integrity: sha512-6dCZuKNu135seMXilkRk9SpCx6i1XgmiipYGalLij5WVRX6ZYS8c4xI7preN/zv9fCXhsQclTIMDu2Y/cytTjw==} + cpu: [arm64] + os: [linux] + + '@oxlint-tsgolint/linux-x64@0.23.0': + resolution: {integrity: sha512-3bdilnyA7kmSTjK27rvjIjSxL5SIg3wt7vwNiRkouWB83ytssyKnuGvxSYJxgMEmFpSutzaBzcCUM2jDtPGcgA==} + cpu: [x64] + os: [linux] + + '@oxlint-tsgolint/win32-arm64@0.23.0': + resolution: {integrity: sha512-j+OEp44SVYiQ+ZD+uttsX7u6L9SvmbbQ77SO1pSFCcJlsVMeCk8qZsjhKfGKuT/jIA+ipOJMVs/+pqUfObBWNw==} + cpu: [arm64] + os: [win32] + + '@oxlint-tsgolint/win32-x64@0.23.0': + resolution: {integrity: sha512-5MyjFuqf+g8OUPJBSGWHJtmoWnzFJYyOg4To9WMQshZYEWig/vtu7JtJ03VWnzHv9LJkAUeApY0gVCOywFR/iQ==} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.70.0': + resolution: {integrity: sha512-zFh0P4cswmRvw6nkyb89dr18rRanuaCPAsEXsFDoQY8WdaquI8Pt4NWFjaMJg6L23cy5NeN8J9cBnREbWzZhaw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.70.0': + resolution: {integrity: sha512-qI8o4HZjeGiBrWv+pJv4lH0Yi2Gl/JSp/EumBUApezJprIKa5PS4nU0lQsQngtky8k+SplQIOjv6hwu0SSxeyg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.70.0': + resolution: {integrity: sha512-8KjgVVHI5F9nVwHCRwwA78Ty7zNKP4Wd9OeN5PSv3iu/F/u1RVXoOCgLhWqust6HmwQG6xc8c+RCyaWENy24+w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.70.0': + resolution: {integrity: sha512-WVydssv5PSUBXFJTdNBWlmGkbNmvPGaFt/2SUT/EZRB6bq6bEOHmMlbnupZD5jmlEvi9+mZJHi8TCw15lyfSfQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.70.0': + resolution: {integrity: sha512-hJucmUf8OlinHNb1R7fI4Fw6WsAstOz7i8nmkWQfiHoZXtbufNm+MxiDTIMk1ggh2Ro4vLzgQ+bKvRY54MZoRA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.70.0': + resolution: {integrity: sha512-1BnS7wbCYDSXwWzJJ+mc3NURoha6m6m6RT5c6vgAY3oz7C3OVXP+S0awo2mRq97arrJkVvO3qRQfyAHL+76xtQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.70.0': + resolution: {integrity: sha512-yKy/UdbR55+M2yEcuiV5DCNC/gdQAjr/GioUy50QwBzSrKm8ueWADqyRLS9Xk+qjNeCYGg6A8FvUBds56ttfqg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.70.0': + resolution: {integrity: sha512-0A5XJ4alvmqFUFP/4oYSyaO+qLto/HrKEWTSaegiVl+HOufFngK2BjYw9x4RbwBt/du5QG6l5q1zeWiJYYG5yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-arm64-musl@1.70.0': + resolution: {integrity: sha512-JiylyurlB0CLSedNtx1gzv3FvfWPF1h/2Y3BJszPLNt5XQFlBsH5ke0Jle3iJb3uqu5m2e7A/DwzpuCAHdiU+A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-ppc64-gnu@1.70.0': + resolution: {integrity: sha512-J8VPG7I3/HmgaU4u8pNU2kFx2+0U+vPLS1dXFxXOaR/2TQ0f8AC7DRz0SRGRI1bfphnX2hVYTTtLuhL4nYKL+Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxlint/binding-linux-riscv64-gnu@1.70.0': + resolution: {integrity: sha512-N2+4lV2KLN+oXTIIIwmWDhwkrnvqf5oX7Hw0zPjk+RuIVgiBQSOlJWF7uQoFx2siEYX0ZQ5cfSbEAHm+J3t7Wg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-riscv64-musl@1.70.0': + resolution: {integrity: sha512-1e2L7cFCvx9QDzq6NPP+0tABKb5z6nWHyddWTNKprEsjO9xNrAtPowuCGpjNXxkTdsMiZ4jc8YQ5SstZd4XK6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-s390x-gnu@1.70.0': + resolution: {integrity: sha512-Kwu/l/8GcYibCWA9m9N5pRXMIKVSsL/YbgpLzYkqDhWTiqdRfnNJ/+nqIKRKQiFbHWsdlHEhzMwruJK+qcEruA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxlint/binding-linux-x64-gnu@1.70.0': + resolution: {integrity: sha512-tap04CsHYOl0nSAQJfPNIuBxqEPB2HnhQqwaOXLg1jnp2XfRo8Fa814dA4QC4zpvTWXCjAAaCY1W5LOORkEQuQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-linux-x64-musl@1.70.0': + resolution: {integrity: sha512-hzJa/WgvtJpbBD9rgfy0qe+MjbxOXNUT0bfR1S6EQQzfTtBFA9xg5q8KSwRrQ2QfSS+TaP4j+4mVPQrfNc6UNg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-openharmony-arm64@1.70.0': + resolution: {integrity: sha512-xbsaNSNzVSnaJACCUYr1HQMyY/Q/Q1LkePmHG3UvZPvGCYGNxrsZp9OmtA6ick8xH47ltRRbRrPCM1YXYcyC+A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.70.0': + resolution: {integrity: sha512-icAEsUI7JbW1TMRdEXV83mVAInhRVQYuuAlPpxdGwJ95chNdnCzjloRW8GglT0WvzOEZSio6fnYSk2DJ2Hv7LQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.70.0': + resolution: {integrity: sha512-FHMSWbVsPVs/f+Jcl04ws4JJ2wUnauyTzlpxWRG/lSO/8GpX08Fo2gQZqdA6CrRFI+zvkxl+N/KwJGWfUwYVZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.70.0': + resolution: {integrity: sha512-ptOlKwCz7n4AKs5VweMqG6DAg677FmKOK+vBkkL9DMNgFATIQ+upqUYBTOEwRQyRAx1ncGlPlXleV2hIcm3z4g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -805,6 +1072,36 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + oxfmt@0.55.0: + resolution: {integrity: sha512-jSj2wCTakwgPMxkfiVZX0jf+nX+Nz6xlyAZjqNE0qXTFdCBPYlP6JAN+ODjmealw7DXBjOzYbdsqwBMAZnPZ6A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true + + oxlint-tsgolint@0.23.0: + resolution: {integrity: sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA==} + hasBin: true + + oxlint@1.70.0: + resolution: {integrity: sha512-D6JgHtzkhRwvEC+A0Nw5AEc5bk8x5i1pHzvZIEf/a0C4hOzmAACNGtkDGPyFaxxX3ZVGxCPeig3P3rMM8XU3/g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.22.1' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -985,6 +1282,10 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -1034,8 +1335,8 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} hasBin: true @@ -1291,6 +1592,138 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@oxfmt/binding-android-arm-eabi@0.55.0': + optional: true + + '@oxfmt/binding-android-arm64@0.55.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.55.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.55.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.55.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.55.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.55.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.55.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.55.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.55.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.55.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.55.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.55.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.55.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.55.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.55.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.55.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.55.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.55.0': + optional: true + + '@oxlint-tsgolint/darwin-arm64@0.23.0': + optional: true + + '@oxlint-tsgolint/darwin-x64@0.23.0': + optional: true + + '@oxlint-tsgolint/linux-arm64@0.23.0': + optional: true + + '@oxlint-tsgolint/linux-x64@0.23.0': + optional: true + + '@oxlint-tsgolint/win32-arm64@0.23.0': + optional: true + + '@oxlint-tsgolint/win32-x64@0.23.0': + optional: true + + '@oxlint/binding-android-arm-eabi@1.70.0': + optional: true + + '@oxlint/binding-android-arm64@1.70.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.70.0': + optional: true + + '@oxlint/binding-darwin-x64@1.70.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.70.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.70.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.70.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.70.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.70.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.70.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.70.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.70.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.70.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.70.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.70.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.70.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.70.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.70.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.70.0': + optional: true + '@pkgjs/parseargs@0.11.0': optional: true @@ -1735,6 +2168,62 @@ snapshots: dependencies: mimic-function: 5.0.1 + oxfmt@0.55.0: + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.55.0 + '@oxfmt/binding-android-arm64': 0.55.0 + '@oxfmt/binding-darwin-arm64': 0.55.0 + '@oxfmt/binding-darwin-x64': 0.55.0 + '@oxfmt/binding-freebsd-x64': 0.55.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.55.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.55.0 + '@oxfmt/binding-linux-arm64-gnu': 0.55.0 + '@oxfmt/binding-linux-arm64-musl': 0.55.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.55.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.55.0 + '@oxfmt/binding-linux-riscv64-musl': 0.55.0 + '@oxfmt/binding-linux-s390x-gnu': 0.55.0 + '@oxfmt/binding-linux-x64-gnu': 0.55.0 + '@oxfmt/binding-linux-x64-musl': 0.55.0 + '@oxfmt/binding-openharmony-arm64': 0.55.0 + '@oxfmt/binding-win32-arm64-msvc': 0.55.0 + '@oxfmt/binding-win32-ia32-msvc': 0.55.0 + '@oxfmt/binding-win32-x64-msvc': 0.55.0 + + oxlint-tsgolint@0.23.0: + optionalDependencies: + '@oxlint-tsgolint/darwin-arm64': 0.23.0 + '@oxlint-tsgolint/darwin-x64': 0.23.0 + '@oxlint-tsgolint/linux-arm64': 0.23.0 + '@oxlint-tsgolint/linux-x64': 0.23.0 + '@oxlint-tsgolint/win32-arm64': 0.23.0 + '@oxlint-tsgolint/win32-x64': 0.23.0 + + oxlint@1.70.0(oxlint-tsgolint@0.23.0): + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.70.0 + '@oxlint/binding-android-arm64': 1.70.0 + '@oxlint/binding-darwin-arm64': 1.70.0 + '@oxlint/binding-darwin-x64': 1.70.0 + '@oxlint/binding-freebsd-x64': 1.70.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.70.0 + '@oxlint/binding-linux-arm-musleabihf': 1.70.0 + '@oxlint/binding-linux-arm64-gnu': 1.70.0 + '@oxlint/binding-linux-arm64-musl': 1.70.0 + '@oxlint/binding-linux-ppc64-gnu': 1.70.0 + '@oxlint/binding-linux-riscv64-gnu': 1.70.0 + '@oxlint/binding-linux-riscv64-musl': 1.70.0 + '@oxlint/binding-linux-s390x-gnu': 1.70.0 + '@oxlint/binding-linux-x64-gnu': 1.70.0 + '@oxlint/binding-linux-x64-musl': 1.70.0 + '@oxlint/binding-openharmony-arm64': 1.70.0 + '@oxlint/binding-win32-arm64-msvc': 1.70.0 + '@oxlint/binding-win32-ia32-msvc': 1.70.0 + '@oxlint/binding-win32-x64-msvc': 1.70.0 + oxlint-tsgolint: 0.23.0 + package-json-from-dist@1.0.1: {} path-key@3.1.1: {} @@ -1909,6 +2398,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinypool@2.1.0: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -1935,7 +2426,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.0(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1): + tsup@8.5.0(tsx@4.20.6)(typescript@6.0.3)(yaml@2.8.1): dependencies: bundle-require: 5.1.0(esbuild@0.25.11) cac: 6.7.14 @@ -1955,7 +2446,7 @@ snapshots: tinyglobby: 0.2.15 tree-kill: 1.2.2 optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - jiti - supports-color @@ -1969,7 +2460,7 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - typescript@5.9.3: {} + typescript@6.0.3: {} ufo@1.6.1: {} diff --git a/tsconfig.json b/tsconfig.json index b7432b3..3a07130 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,8 @@ "module": "nodenext", "moduleResolution": "nodenext", "target": "esnext", - "outDir": "dist", + "rootDir": ".", + "outDir": "./dist", "skipLibCheck": true, "types": ["node"], "strict": true, From 235c435c2c3bb95c686bdc9e28f76c25d99bfcb0 Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Fri, 19 Jun 2026 23:28:45 +0300 Subject: [PATCH 2/7] fix: address linting rules --- src/features/docs/utils.test.ts | 104 +- src/features/docs/utils.ts | 194 ++-- src/features/just-ask/index.test.ts | 38 +- src/features/moderation/repel.ts | 1000 +++++++++--------- src/features/showcase/send-pinned-message.ts | 167 +-- src/sanity.test.ts | 8 +- src/scripts/deploy-commands.ts | 50 +- src/util/advent-scheduler.test.ts | 77 +- 8 files changed, 854 insertions(+), 784 deletions(-) diff --git a/src/features/docs/utils.test.ts b/src/features/docs/utils.test.ts index b10f6f3..20c7dc2 100644 --- a/src/features/docs/utils.test.ts +++ b/src/features/docs/utils.test.ts @@ -1,59 +1,59 @@ -import assert from 'node:assert'; -import { describe, it } from 'node:test'; -import { features } from 'web-features'; -import { getBaselineFeatures, NON_BASELINE_FEATURES } from './utils.js'; +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { features } from "web-features"; +import { getBaselineFeatures, NON_BASELINE_FEATURES } from "./utils.js"; -describe('getBaselineFeatures', () => { - it('should return the correct baseline features when provided with non-features key array', () => { - const originalFeatures = { - 'feature-1': { - name: 'Feature 1', - description: 'Description 1', - status: { support: {}, baseline: 'full' }, - }, - 'feature-2': { - name: 'Feature 2', - description: 'Description 2', - status: { support: {}, baseline: 'partial' }, - }, - 'numeric-seperators': { - name: 'Numeric Separators', - description: 'Description NS', - status: { support: {}, baseline: 'none' }, - }, - 'single-color-gradient': { - name: 'Single Color Gradient', - description: 'Description SCG', - status: { support: {}, baseline: 'none' }, - }, - }; +void describe("getBaselineFeatures", () => { + void it("should return the correct baseline features when provided with non-features key array", () => { + const originalFeatures = { + "feature-1": { + name: "Feature 1", + description: "Description 1", + status: { support: {}, baseline: "full" }, + }, + "feature-2": { + name: "Feature 2", + description: "Description 2", + status: { support: {}, baseline: "partial" }, + }, + "numeric-seperators": { + name: "Numeric Separators", + description: "Description NS", + status: { support: {}, baseline: "none" }, + }, + "single-color-gradient": { + name: "Single Color Gradient", + description: "Description SCG", + status: { support: {}, baseline: "none" }, + }, + }; - const expectedFeatures = { - 'feature-1': { - name: 'Feature 1', - description: 'Description 1', - status: { support: {}, baseline: 'full' }, - }, - 'feature-2': { - name: 'Feature 2', - description: 'Description 2', - status: { support: {}, baseline: 'partial' }, - }, - }; + const expectedFeatures = { + "feature-1": { + name: "Feature 1", + description: "Description 1", + status: { support: {}, baseline: "full" }, + }, + "feature-2": { + name: "Feature 2", + description: "Description 2", + status: { support: {}, baseline: "partial" }, + }, + }; - const result = getBaselineFeatures(originalFeatures, [ - 'numeric-seperators', - 'single-color-gradient', - ]); + const result = getBaselineFeatures(originalFeatures, [ + "numeric-seperators", + "single-color-gradient", + ]); - assert.deepStrictEqual(result, expectedFeatures); - }); + assert.deepStrictEqual(result, expectedFeatures); + }); - it('NON_BASELINE_FEATURES should contain the correct features to exclude', () => { - const expectedNonBaselineFeatures = Object.entries(features) - .filter(([, feature]) => feature.kind !== 'feature') - .map(([key]) => key); + void it("NON_BASELINE_FEATURES should contain the correct features to exclude", () => { + const expectedNonBaselineFeatures = Object.entries(features) + .filter(([, feature]) => feature.kind !== "feature") + .map(([key]) => key); - assert.deepStrictEqual(NON_BASELINE_FEATURES, expectedNonBaselineFeatures); - }); + assert.deepStrictEqual(NON_BASELINE_FEATURES, expectedNonBaselineFeatures); + }); }); diff --git a/src/features/docs/utils.ts b/src/features/docs/utils.ts index 8c00f22..9c0ca45 100644 --- a/src/features/docs/utils.ts +++ b/src/features/docs/utils.ts @@ -1,110 +1,118 @@ -import { type CommandInteraction, MessageFlags } from 'discord.js'; -import { logToChannel } from '@/util/channel-logging.js'; -import type { FeatureData } from './baseline.js'; -import type { ProviderConfig } from './types.js'; +import { type CommandInteraction, MessageFlags } from "discord.js"; +import { logToChannel } from "@/util/channel-logging.js"; +import type { FeatureData } from "./baseline.js"; +import type { ProviderConfig } from "./types.js"; -export const SEARCH_TERM = '%SEARCH%'; -export const TERM = '%TERM%'; +export const SEARCH_TERM = "%SEARCH%"; +export const TERM = "%TERM%"; // Utility functions export const getSearchUrl = (url: string, search: string) => { - return url.replace(SEARCH_TERM, encodeURIComponent(search)); + return url.replace(SEARCH_TERM, encodeURIComponent(search)); }; export const createBaseConfig = (options: { - color: number; - icon: string; - commandDescription: string; - directUrl?: string; -}): Pick => options; + color: number; + icon: string; + commandDescription: string; + directUrl?: string; +}): Pick< + ProviderConfig, + "color" | "icon" | "commandDescription" | "directUrl" +> => options; export const executeDocCommand = async ( - config: ProviderConfig, - interaction: CommandInteraction + config: ProviderConfig, + interaction: CommandInteraction, ): Promise => { - if (interaction.replied || interaction.deferred) { - return; - } - - try { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - } catch (error) { - console.error(`deferReply FAILED:`, error); - return; - } - - if (!interaction.isChatInputCommand()) { - return; - } - - const query = interaction.options.getString('query', true).trim(); - - try { - const items = await config.getFilteredData(query); - - if (items.length === 0) { - await interaction.editReply({ content: `No results found for "${query}"` }); - return; - } - - const collection = config.createCollection(items); - const { selectRow, buttonRow } = config.createActionBuilders(collection); - - const choiceInteraction = await interaction.editReply({ - content: config.getSelectionMessage(query), - components: [selectRow, buttonRow], - }); - - const collector = choiceInteraction.createMessageComponentCollector({ - filter: (componentInteraction) => componentInteraction.user.id === interaction.user.id, - }); - - collector.once('collect', async (componentInteraction) => { - if (componentInteraction.isStringSelectMenu()) { - const selectedSet = new Set(componentInteraction.values); - const selectedItems = collection.filter((_, key) => selectedSet.has(key)); - const selectedTitles = selectedItems.map(config.getDisplayTitle); - - await interaction.editReply({ - content: config.getDisplayMessage(selectedTitles), - components: [], - }); - - const embeds = config.createResultEmbeds(selectedItems); - - logToChannel({ - channel: interaction.channel, - content: { - type: 'embed', - embed: embeds, - content: interaction.options.getUser('user') - ? `<@${interaction.options.getUser('user')?.id}>` - : undefined, - }, - }); - } else if (componentInteraction.isButton()) { - console.log('Documentation command cancelled by user.'); - await interaction.deleteReply(); - } - }); - } catch (error) { - console.error(`executeDocCommand FAILED:`, error); - await interaction.editReply({ content: `Error: ${error}` }); - } + if (interaction.replied || interaction.deferred) { + return; + } + + try { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + } catch (error) { + console.error(`deferReply FAILED:`, error); + return; + } + + if (!interaction.isChatInputCommand()) { + return; + } + + const query = interaction.options.getString("query", true).trim(); + + try { + const items = await config.getFilteredData(query); + + if (items.length === 0) { + await interaction.editReply({ + content: `No results found for "${query}"`, + }); + return; + } + + const collection = config.createCollection(items); + const { selectRow, buttonRow } = config.createActionBuilders(collection); + + const choiceInteraction = await interaction.editReply({ + content: config.getSelectionMessage(query), + components: [selectRow, buttonRow], + }); + + const collector = choiceInteraction.createMessageComponentCollector({ + filter: (componentInteraction) => + componentInteraction.user.id === interaction.user.id, + }); + + collector.once("collect", async (componentInteraction) => { + if (componentInteraction.isStringSelectMenu()) { + const selectedSet = new Set(componentInteraction.values); + const selectedItems = collection.filter((_, key) => + selectedSet.has(key), + ); + const selectedTitles = selectedItems.map(config.getDisplayTitle); + + await interaction.editReply({ + content: config.getDisplayMessage(selectedTitles), + components: [], + }); + + const embeds = config.createResultEmbeds(selectedItems); + + void logToChannel({ + channel: interaction.channel, + content: { + type: "embed", + embed: embeds, + content: interaction.options.getUser("user") + ? `<@${interaction.options.getUser("user")?.id}>` + : undefined, + }, + }); + } else if (componentInteraction.isButton()) { + console.log("Documentation command cancelled by user."); + await interaction.deleteReply(); + } + }); + } catch (error) { + console.error(`executeDocCommand FAILED:`, error); + await interaction.editReply({ content: `Error: ${String(error)}` }); + } }; export const NON_BASELINE_FEATURES = [ - 'numeric-seperators', - 'open-closed', - 'single-color-gradients', + "numeric-seperators", + "open-closed", + "single-color-gradients", ]; export const getBaselineFeatures = ( - originalFeatures: Record, - nonFeatureKeys: string[] = NON_BASELINE_FEATURES + originalFeatures: Record, + nonFeatureKeys: string[] = NON_BASELINE_FEATURES, ): Record => { - const features = { ...originalFeatures }; - for (const nonFeature of nonFeatureKeys) { - delete features[nonFeature]; - } - return features as Record; + const features = { ...originalFeatures }; + for (const nonFeature of nonFeatureKeys) { + delete features[nonFeature]; + } + return features as Record; }; diff --git a/src/features/just-ask/index.test.ts b/src/features/just-ask/index.test.ts index 5a17ffd..a5387d2 100644 --- a/src/features/just-ask/index.test.ts +++ b/src/features/just-ask/index.test.ts @@ -1,22 +1,22 @@ -import assert from 'node:assert'; -import { describe, it } from 'node:test'; -import { isAskingToAsk } from './just-ask.js'; +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { isAskingToAsk } from "./just-ask.js"; -describe('justAsk Regex', () => { - const testCases = [ - { input: 'Anyone knows js?', expected: true }, - { input: 'Somebody can help with Python?', expected: true }, - { input: 'No one has experience with js?', expected: true }, - { input: 'Everybody tried React?', expected: true }, - { input: 'People familiar with Kubernetes?', expected: true }, +void describe("justAsk Regex", () => { + const testCases = [ + { input: "Anyone knows js?", expected: true }, + { input: "Somebody can help with Python?", expected: true }, + { input: "No one has experience with js?", expected: true }, + { input: "Everybody tried React?", expected: true }, + { input: "People familiar with Kubernetes?", expected: true }, - { input: 'I know js well.', expected: false }, - { input: 'This is a question without the pattern.', expected: false }, - ]; - testCases.forEach(({ input, expected }) => { - it(`should return ${expected} for input: "${input}"`, () => { - const result = isAskingToAsk(input); - assert.strictEqual(result, expected); - }); - }); + { input: "I know js well.", expected: false }, + { input: "This is a question without the pattern.", expected: false }, + ]; + testCases.forEach(({ input, expected }) => { + void it(`should return ${expected} for input: "${input}"`, () => { + const result = isAskingToAsk(input); + assert.strictEqual(result, expected); + }); + }); }); diff --git a/src/features/moderation/repel.ts b/src/features/moderation/repel.ts index 518d818..da3e1c4 100644 --- a/src/features/moderation/repel.ts +++ b/src/features/moderation/repel.ts @@ -1,530 +1,574 @@ import { - ApplicationCommandOptionType, - ChannelType, - type ChatInputCommandInteraction, - Colors, - ContainerBuilder, - EmbedBuilder, - type GuildMember, - type Message, - MessageFlags, - PermissionFlagsBits, - type Role, - type TextChannel, - TextDisplayBuilder, - type User, -} from 'discord.js'; -import { isUserInServer } from '@/util/member.js'; -import { createSlashCommand } from '../../common/commands/create-commands.js'; -import { HOUR, MINUTE, timeToString } from '../../constants/time.js'; -import { config } from '../../env.js'; -import { buildCommandString } from '../../util/build-command-string.js'; -import { getPublicChannels } from '../../util/channel.js'; -import { logToChannel } from '../../util/channel-logging.js'; + ApplicationCommandOptionType, + ChannelType, + type ChatInputCommandInteraction, + Colors, + ContainerBuilder, + EmbedBuilder, + type GuildMember, + type Message, + MessageFlags, + PermissionFlagsBits, + type Role, + type TextChannel, + TextDisplayBuilder, + type User, +} from "discord.js"; +import { isUserInServer } from "@/util/member.js"; +import { createSlashCommand } from "../../common/commands/create-commands.js"; +import { HOUR, MINUTE, timeToString } from "../../constants/time.js"; +import { config } from "../../env.js"; +import { buildCommandString } from "../../util/build-command-string.js"; +import { getPublicChannels } from "../../util/channel.js"; +import { logToChannel } from "../../util/channel-logging.js"; const DEFAULT_LOOK_BACK_MS = 10 * MINUTE; const DEFAULT_TIMEOUT_DURATION_MS = 1 * HOUR; const isUserTimedOut = (target: GuildMember) => { - return target.communicationDisabledUntilTimestamp - ? target.communicationDisabledUntilTimestamp > Date.now() - : false; + return target.communicationDisabledUntilTimestamp + ? target.communicationDisabledUntilTimestamp > Date.now() + : false; }; const checkCanRepel = ({ - commandUser, - repelRole, + commandUser, + repelRole, }: { - commandUser: GuildMember; - repelRole: Role; + commandUser: GuildMember; + repelRole: Role; }): { - ok: boolean; - message?: string; + ok: boolean; + message?: string; } => { - const hasPermission = - commandUser.permissions.has(PermissionFlagsBits.ModerateMembers) || - commandUser.roles.cache.has(repelRole.id); - if (!hasPermission) { - return { ok: false, message: 'You do not have permission to use this command.' }; - } - return { ok: true }; + const hasPermission = + commandUser.permissions.has(PermissionFlagsBits.ModerateMembers) || + commandUser.roles.cache.has(repelRole.id); + if (!hasPermission) { + return { + ok: false, + message: "You do not have permission to use this command.", + }; + } + return { ok: true }; }; const checkCanRepelTarget = ({ - target, - commandUser, - botMember, + target, + commandUser, + botMember, }: { - target: User | GuildMember; - commandUser: GuildMember; - botMember: GuildMember; + target: User | GuildMember; + commandUser: GuildMember; + botMember: GuildMember; }): { - ok: boolean; - message?: string; + ok: boolean; + message?: string; } => { - if (!isUserInServer(target)) { - return { ok: true }; - } - - if (target.user.bot) { - return { ok: false, message: 'You cannot repel a bot.' }; - } - - if (target.id === commandUser.id) { - return { ok: false, message: 'You cannot repel yourself.' }; - } - - const isTargetServerOwner = target.id === target.guild.ownerId; - if (isTargetServerOwner) { - return { ok: false, message: 'You cannot repel the server owner.' }; - } - - if (target.roles.highest.position >= commandUser.roles.highest.position) { - return { ok: false, message: 'You cannot repel this user due to role hierarchy.' }; - } - - if (target.roles.highest.position >= botMember.roles.highest.position) { - return { ok: false, message: 'I cannot repel this user due to role hierarchy.' }; - } - - return { ok: true }; + if (!isUserInServer(target)) { + return { ok: true }; + } + + if (target.user.bot) { + return { ok: false, message: "You cannot repel a bot." }; + } + + if (target.id === commandUser.id) { + return { ok: false, message: "You cannot repel yourself." }; + } + + const isTargetServerOwner = target.id === target.guild.ownerId; + if (isTargetServerOwner) { + return { ok: false, message: "You cannot repel the server owner." }; + } + + if (target.roles.highest.position >= commandUser.roles.highest.position) { + return { + ok: false, + message: "You cannot repel this user due to role hierarchy.", + }; + } + + if (target.roles.highest.position >= botMember.roles.highest.position) { + return { + ok: false, + message: "I cannot repel this user due to role hierarchy.", + }; + } + + return { ok: true }; }; const sendReasonToTarget = async ({ - target, - reason, - guildName, - timeoutDuration, + target, + reason, + guildName, + timeoutDuration, }: { - target: GuildMember | User; - reason: string; - guildName: string; - timeoutDuration?: number; + target: GuildMember | User; + reason: string; + guildName: string; + timeoutDuration?: number; }): Promise => { - const parts: string[] = []; - parts.push(`You have been repelled from **${guildName}**.`); - if (timeoutDuration && timeoutDuration > 0) { - parts.push(`You have been timed out for ${timeToString(timeoutDuration)}.`); - } - parts.push(`**Reason:** ${reason}`); - const messageContent = parts.join('\n'); - - const containerComponent = new ContainerBuilder() - .setAccentColor(Colors.DarkRed) - .addTextDisplayComponents(new TextDisplayBuilder().setContent(messageContent)); - try { - await target.send({ - flags: MessageFlags.IsComponentsV2, - components: [containerComponent], - }); - return true; - } catch { - return false; - } + const parts: string[] = []; + parts.push(`You have been repelled from **${guildName}**.`); + if (timeoutDuration && timeoutDuration > 0) { + parts.push(`You have been timed out for ${timeToString(timeoutDuration)}.`); + } + parts.push(`**Reason:** ${reason}`); + const messageContent = parts.join("\n"); + + const containerComponent = new ContainerBuilder() + .setAccentColor(Colors.DarkRed) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent(messageContent), + ); + try { + await target.send({ + flags: MessageFlags.IsComponentsV2, + components: [containerComponent], + }); + return true; + } catch { + return false; + } }; const getTargetFromInteraction = async ( - interaction: ChatInputCommandInteraction + interaction: ChatInputCommandInteraction, ): Promise => { - const targetFromOption = interaction.options.getUser(RepelOptions.TARGET, true); - let target: User | GuildMember | null = null; - if (!interaction.inGuild() || interaction.guild === null) { - return targetFromOption; - } - try { - target = await interaction.guild.members.fetch(targetFromOption.id); - } catch (error) { - console.error('Error fetching target as guild member:', error); - target = targetFromOption; - } - return target; + const targetFromOption = interaction.options.getUser( + RepelOptions.TARGET, + true, + ); + let target: User | GuildMember | null = null; + if (!interaction.inGuild() || interaction.guild === null) { + return targetFromOption; + } + try { + target = await interaction.guild.members.fetch(targetFromOption.id); + } catch (error) { + console.error("Error fetching target as guild member:", error); + target = targetFromOption; + } + return target; }; const handleTimeout = async ({ - target, - durationInMilliseconds, + target, + durationInMilliseconds, }: { - target: GuildMember | User; - durationInMilliseconds: number; + target: GuildMember | User; + durationInMilliseconds: number; }): Promise => { - if (durationInMilliseconds === 0 || !isUserInServer(target) || isUserTimedOut(target)) { - return 0; - } - try { - await target.timeout(durationInMilliseconds, 'Repel command executed'); - return durationInMilliseconds; - } catch (error) { - console.error('Error applying timeout to user:', error); - return 0; - } + if ( + durationInMilliseconds === 0 || + !isUserInServer(target) || + isUserTimedOut(target) + ) { + return 0; + } + try { + await target.timeout(durationInMilliseconds, "Repel command executed"); + return durationInMilliseconds; + } catch (error) { + console.error("Error applying timeout to user:", error); + return 0; + } }; const getTextChannels = (interaction: ChatInputCommandInteraction) => { - if (!interaction.inGuild() || !interaction.guild) { - console.error('Interaction is not in a guild'); - return []; - } - const channels = Array.from(getPublicChannels(interaction.guild).values()); - return [ - interaction.channel as TextChannel, - ...channels.filter((channel) => channel.id !== interaction.channelId), - ]; + if (!interaction.inGuild() || !interaction.guild) { + console.error("Interaction is not in a guild"); + return []; + } + const channels = Array.from(getPublicChannels(interaction.guild).values()); + return [ + interaction.channel as TextChannel, + ...channels.filter((channel) => channel.id !== interaction.channelId), + ]; }; const handleDeleteMessages = async ({ - target, - channels, - lookBack, + target, + channels, + lookBack, }: { - target: GuildMember | User; - channels: TextChannel[]; - lookBack: number; + target: GuildMember | User; + channels: TextChannel[]; + lookBack: number; }) => { - let deleted = 0; - const failedChannels: string[] = []; - - // Collect all target messages from all channels and find the latest timestamp - const channelMessages: Array<{ - channel: TextChannel; - targetMessages: Map; - }> = []; - let latestMessageTimestamp = 0; - - for (const channel of channels) { - const targetMessages = new Map(); - - for (const [id, message] of channel.messages.cache) { - if (message.author && message.author.id === target.id && message.deletable) { - targetMessages.set(id, message); - latestMessageTimestamp = Math.max(latestMessageTimestamp, message.createdTimestamp); - } - } - - if (targetMessages.size > 0) { - channelMessages.push({ channel, targetMessages }); - } - } - - // If no messages found from the target user, return early - if (latestMessageTimestamp === 0) { - return { deleted: 0, failedChannels: [] }; - } - - // Delete messages within the lookback window from the latest message - await Promise.allSettled( - channelMessages.map(async ({ channel, targetMessages }) => { - try { - const messagesToDelete: string[] = []; - - for (const [id, message] of targetMessages) { - if (latestMessageTimestamp - message.createdTimestamp < lookBack) { - messagesToDelete.push(id); - } - } - - if (messagesToDelete.length === 0) { - return; - } - await channel.bulkDelete(messagesToDelete, true); - deleted += messagesToDelete.length; - } catch (error) { - console.error(`Error deleting messages in channel ${channel.name}:`, error); - failedChannels.push(channel.id); - throw error; - } - }) - ); - if (failedChannels.length > 0) { - console.error(`Failed to delete messages in ${failedChannels.length} channel(s).`); - } - return { deleted, failedChannels }; + let deleted = 0; + const failedChannels: string[] = []; + + // Collect all target messages from all channels and find the latest timestamp + const channelMessages: Array<{ + channel: TextChannel; + targetMessages: Map; + }> = []; + let latestMessageTimestamp = 0; + + for (const channel of channels) { + const targetMessages = new Map(); + + for (const [id, message] of channel.messages.cache) { + if ( + message.author && + message.author.id === target.id && + message.deletable + ) { + targetMessages.set(id, message); + latestMessageTimestamp = Math.max( + latestMessageTimestamp, + message.createdTimestamp, + ); + } + } + + if (targetMessages.size > 0) { + channelMessages.push({ channel, targetMessages }); + } + } + + // If no messages found from the target user, return early + if (latestMessageTimestamp === 0) { + return { deleted: 0, failedChannels: [] }; + } + + // Delete messages within the lookback window from the latest message + await Promise.allSettled( + channelMessages.map(async ({ channel, targetMessages }) => { + try { + const messagesToDelete: string[] = []; + + for (const [id, message] of targetMessages) { + if (latestMessageTimestamp - message.createdTimestamp < lookBack) { + messagesToDelete.push(id); + } + } + + if (messagesToDelete.length === 0) { + return; + } + await channel.bulkDelete(messagesToDelete, true); + deleted += messagesToDelete.length; + } catch (error) { + console.error( + `Error deleting messages in channel ${channel.name}:`, + error, + ); + failedChannels.push(channel.id); + throw error; + } + }), + ); + if (failedChannels.length > 0) { + console.error( + `Failed to delete messages in ${failedChannels.length} channel(s).`, + ); + } + return { deleted, failedChannels }; }; const logRepelAction = async ({ - interaction, - member, - target, - duration, - deleteCount, - reason, - failedChannels, - dm, + interaction, + member, + target, + duration, + deleteCount, + reason, + failedChannels, + dm, }: { - interaction: ChatInputCommandInteraction; - member: GuildMember; - target: User | GuildMember; - reason: string; - duration?: number; - deleteCount: number; - failedChannels: string[]; - dm: { - sent: boolean; - shouldSend: boolean; - }; + interaction: ChatInputCommandInteraction; + member: GuildMember; + target: User | GuildMember; + reason: string; + duration?: number; + deleteCount: number; + failedChannels: string[]; + dm: { + sent: boolean; + shouldSend: boolean; + }; }) => { - const channelInfo = - interaction.channel?.type === ChannelType.GuildVoice - ? `**${interaction.channel.name}** voice chat` - : `<#${interaction.channelId}>`; - const memberAuthor = { - name: member.user.tag, - iconURL: member.user.displayAvatarURL(), - }; - const targetAuthor = { - name: isUserInServer(target) - ? `${target.user.tag} | Repel | ${target.user.username}` - : `${target.tag} | Repel | ${target.username}`, - iconURL: isUserInServer(target) ? target.user.displayAvatarURL() : target.displayAvatarURL(), - }; - - const commandEmbed = new EmbedBuilder() - .setAuthor(memberAuthor) - .setDescription(`Used \`repel\` command in ${channelInfo}.\n${buildCommandString(interaction)}`) - .setColor('Green') - .setTimestamp(); - const resultEmbed = new EmbedBuilder() - .setAuthor(targetAuthor) - .addFields( - { - name: 'Target', - value: `<@${target.id}>`, - inline: true, - }, - { - name: 'Moderator', - value: `<@${member.id}>`, - inline: true, - }, - { - name: 'Reason', - value: reason, - inline: true, - }, - { - name: 'Deleted Messages', - value: deleteCount.toString(), - inline: true, - }, - { - name: 'Timeout Duration', - value: duration ? `${timeToString(duration)}` : 'No Timeout', - inline: true, - } - ) - .setColor('Orange') - .setTimestamp(); - - if (dm.shouldSend) { - resultEmbed.addFields({ - name: 'DM Sent', - value: dm.sent ? 'Yes' : 'No', - inline: true, - }); - } - - const failedChannelsEmbed = - failedChannels.length > 0 - ? new EmbedBuilder() - .setTitle('Failed to delete messages in the following channels:') - .setDescription(failedChannels.map((id) => `<#${id}>`).join('\n')) - .setColor('Red') - .setTimestamp() - : null; - - const modMessage = interaction.options.getString(RepelOptions.MESSAGE_FOR_MODS) ?? false; - const mentionText = modMessage - ? `${config.roleIds.moderators.map((id) => `<@&${id}>`)} - ${modMessage}` - : undefined; - const channel = interaction.client.channels.cache.get(config.channelIds.repelLogs) as TextChannel; - - const embed = - failedChannelsEmbed !== null - ? [commandEmbed, resultEmbed, failedChannelsEmbed] - : [commandEmbed, resultEmbed]; - - await logToChannel({ - channel, - content: { - type: 'embed', - embed, - content: mentionText, - }, - }); + const channelInfo = + interaction.channel?.type === ChannelType.GuildVoice + ? `**${interaction.channel.name}** voice chat` + : `<#${interaction.channelId}>`; + const memberAuthor = { + name: member.user.tag, + iconURL: member.user.displayAvatarURL(), + }; + const targetAuthor = { + name: isUserInServer(target) + ? `${target.user.tag} | Repel | ${target.user.username}` + : `${target.tag} | Repel | ${target.username}`, + iconURL: isUserInServer(target) + ? target.user.displayAvatarURL() + : target.displayAvatarURL(), + }; + + const commandEmbed = new EmbedBuilder() + .setAuthor(memberAuthor) + .setDescription( + `Used \`repel\` command in ${channelInfo}.\n${buildCommandString(interaction)}`, + ) + .setColor("Green") + .setTimestamp(); + const resultEmbed = new EmbedBuilder() + .setAuthor(targetAuthor) + .addFields( + { + name: "Target", + value: `<@${target.id}>`, + inline: true, + }, + { + name: "Moderator", + value: `<@${member.id}>`, + inline: true, + }, + { + name: "Reason", + value: reason, + inline: true, + }, + { + name: "Deleted Messages", + value: deleteCount.toString(), + inline: true, + }, + { + name: "Timeout Duration", + value: duration ? `${timeToString(duration)}` : "No Timeout", + inline: true, + }, + ) + .setColor("Orange") + .setTimestamp(); + + if (dm.shouldSend) { + resultEmbed.addFields({ + name: "DM Sent", + value: dm.sent ? "Yes" : "No", + inline: true, + }); + } + + const failedChannelsEmbed = + failedChannels.length > 0 + ? new EmbedBuilder() + .setTitle("Failed to delete messages in the following channels:") + .setDescription(failedChannels.map((id) => `<#${id}>`).join("\n")) + .setColor("Red") + .setTimestamp() + : null; + + const modMessage = + interaction.options.getString(RepelOptions.MESSAGE_FOR_MODS) ?? false; + const mentionText = modMessage + ? `${config.roleIds.moderators.map((id) => `<@&${id}>`).join(" ")} - ${modMessage}` + : undefined; + const channel = interaction.client.channels.cache.get( + config.channelIds.repelLogs, + ) as TextChannel; + + const embed = + failedChannelsEmbed !== null + ? [commandEmbed, resultEmbed, failedChannelsEmbed] + : [commandEmbed, resultEmbed]; + + await logToChannel({ + channel, + content: { + type: "embed", + embed, + content: mentionText, + }, + }); }; const RepelOptions = { - TARGET: 'target', - REASON: 'reason', - LOOK_BACK: 'look_back', - TIMEOUT_DURATION: 'timeout_duration', - MESSAGE_FOR_MODS: 'message_for_mods', - DM_USER: 'dm_user', + TARGET: "target", + REASON: "reason", + LOOK_BACK: "look_back", + TIMEOUT_DURATION: "timeout_duration", + MESSAGE_FOR_MODS: "message_for_mods", + DM_USER: "dm_user", } as const; export const repelCommand = createSlashCommand({ - data: { - name: 'repel', - description: 'Remove recent messages and timeout a user', - options: [ - { - name: RepelOptions.TARGET, - required: true, - type: ApplicationCommandOptionType.User, - description: 'The user to timeout and remove messages from', - }, - { - name: RepelOptions.REASON, - required: true, - type: ApplicationCommandOptionType.String, - description: 'Reason for the timeout and message removal', - }, - { - name: RepelOptions.LOOK_BACK, - required: false, - type: ApplicationCommandOptionType.Integer, - description: `How far back to look for messages to delete in minutes (default: ${timeToString( - DEFAULT_LOOK_BACK_MS - )})`, - choices: [ - { - name: '10 minutes (Default)', - value: 10 * MINUTE, - }, - { - name: '30 minutes', - value: 30 * MINUTE, - }, - { - name: '1 hour', - value: 1 * HOUR, - }, - { - name: '3 hours', - value: 3 * HOUR, - }, - ], - }, - { - name: RepelOptions.TIMEOUT_DURATION, - required: false, - type: ApplicationCommandOptionType.Integer, - description: `Duration of the timeout in hours (default: ${timeToString(DEFAULT_TIMEOUT_DURATION_MS)})`, - min_value: 0, - max_value: 24, - }, - { - name: RepelOptions.MESSAGE_FOR_MODS, - required: false, - type: ApplicationCommandOptionType.String, - description: 'Optional message to include for moderators in the log', - }, - { - name: RepelOptions.DM_USER, - required: false, - type: ApplicationCommandOptionType.Boolean, - description: 'Whether to DM the user about the repel action (Defaults to true)', - }, - ], - }, - execute: async (interaction) => { - if (!interaction.inGuild() || !interaction.guild) { - await interaction.reply({ - content: 'This command can only be used in a server.', - flags: MessageFlags.Ephemeral, - }); - return; - } - - if (!interaction.isChatInputCommand()) { - return; - } - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - - const repelRole = interaction.guild.roles.cache.get(config.roleIds.repel); - if (!repelRole) { - await interaction.editReply({ - content: '❌ Repel role is not configured correctly. Please contact an administrator.', - }); - return; - } - - const target = await getTargetFromInteraction(interaction); - - const commandUser = interaction.member as GuildMember; - - const canRepel = checkCanRepel({ commandUser, repelRole }); - if (!canRepel.ok) { - await interaction.editReply({ content: `❌ ${canRepel.message}` }); - return; - } - - const botMember = interaction.guild.members.me; - if (!botMember) { - await interaction.editReply({ - content: '❌ Unable to verify my permissions in the server.', - }); - return; - } - - const canRepelTarget = checkCanRepelTarget({ - target, - commandUser, - botMember, - }); - if (!canRepelTarget.ok) { - await interaction.editReply({ content: `❌ ${canRepelTarget.message}` }); - return; - } - - try { - const reason = interaction.options.getString(RepelOptions.REASON, true); - const lookBack = interaction.options.getInteger(RepelOptions.LOOK_BACK); - const timeoutHours = interaction.options.getInteger(RepelOptions.TIMEOUT_DURATION); - - const timeout = await handleTimeout({ - target: target, - durationInMilliseconds: - timeoutHours !== null ? timeoutHours * HOUR : DEFAULT_TIMEOUT_DURATION_MS, - }); - - const channels = getTextChannels(interaction); - - const { deleted, failedChannels } = await handleDeleteMessages({ - channels, - target: target, - lookBack: lookBack ?? DEFAULT_LOOK_BACK_MS, - }); - - const shouldDMUser = interaction.options.getBoolean(RepelOptions.DM_USER) ?? true; - let dmSent = false; - if (shouldDMUser) { - dmSent = await sendReasonToTarget({ - target, - reason, - guildName: interaction.guild.name, - timeoutDuration: timeout, - }); - } - - logRepelAction({ - interaction, - member: commandUser, - target, - reason, - duration: timeout, - deleteCount: deleted, - failedChannels, - dm: { - sent: dmSent, - shouldSend: shouldDMUser, - }, - }); - - await interaction.editReply({ - content: `Deleted ${deleted} message(s).`, - }); - } catch (error) { - console.error('Error executing repel command:', error); - } - }, + data: { + name: "repel", + description: "Remove recent messages and timeout a user", + options: [ + { + name: RepelOptions.TARGET, + required: true, + type: ApplicationCommandOptionType.User, + description: "The user to timeout and remove messages from", + }, + { + name: RepelOptions.REASON, + required: true, + type: ApplicationCommandOptionType.String, + description: "Reason for the timeout and message removal", + }, + { + name: RepelOptions.LOOK_BACK, + required: false, + type: ApplicationCommandOptionType.Integer, + description: `How far back to look for messages to delete in minutes (default: ${timeToString( + DEFAULT_LOOK_BACK_MS, + )})`, + choices: [ + { + name: "10 minutes (Default)", + value: 10 * MINUTE, + }, + { + name: "30 minutes", + value: 30 * MINUTE, + }, + { + name: "1 hour", + value: 1 * HOUR, + }, + { + name: "3 hours", + value: 3 * HOUR, + }, + ], + }, + { + name: RepelOptions.TIMEOUT_DURATION, + required: false, + type: ApplicationCommandOptionType.Integer, + description: `Duration of the timeout in hours (default: ${timeToString(DEFAULT_TIMEOUT_DURATION_MS)})`, + min_value: 0, + max_value: 24, + }, + { + name: RepelOptions.MESSAGE_FOR_MODS, + required: false, + type: ApplicationCommandOptionType.String, + description: "Optional message to include for moderators in the log", + }, + { + name: RepelOptions.DM_USER, + required: false, + type: ApplicationCommandOptionType.Boolean, + description: + "Whether to DM the user about the repel action (Defaults to true)", + }, + ], + }, + execute: async (interaction) => { + if (!interaction.inGuild() || !interaction.guild) { + await interaction.reply({ + content: "This command can only be used in a server.", + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (!interaction.isChatInputCommand()) { + return; + } + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const repelRole = interaction.guild.roles.cache.get(config.roleIds.repel); + if (!repelRole) { + await interaction.editReply({ + content: + "❌ Repel role is not configured correctly. Please contact an administrator.", + }); + return; + } + + const target = await getTargetFromInteraction(interaction); + + const commandUser = interaction.member as GuildMember; + + const canRepel = checkCanRepel({ commandUser, repelRole }); + if (!canRepel.ok) { + await interaction.editReply({ content: `❌ ${canRepel.message}` }); + return; + } + + const botMember = interaction.guild.members.me; + if (!botMember) { + await interaction.editReply({ + content: "❌ Unable to verify my permissions in the server.", + }); + return; + } + + const canRepelTarget = checkCanRepelTarget({ + target, + commandUser, + botMember, + }); + if (!canRepelTarget.ok) { + await interaction.editReply({ content: `❌ ${canRepelTarget.message}` }); + return; + } + + try { + const reason = interaction.options.getString(RepelOptions.REASON, true); + const lookBack = interaction.options.getInteger(RepelOptions.LOOK_BACK); + const timeoutHours = interaction.options.getInteger( + RepelOptions.TIMEOUT_DURATION, + ); + + const timeout = await handleTimeout({ + target: target, + durationInMilliseconds: + timeoutHours !== null + ? timeoutHours * HOUR + : DEFAULT_TIMEOUT_DURATION_MS, + }); + + const channels = getTextChannels(interaction); + + const { deleted, failedChannels } = await handleDeleteMessages({ + channels, + target: target, + lookBack: lookBack ?? DEFAULT_LOOK_BACK_MS, + }); + + const shouldDMUser = + interaction.options.getBoolean(RepelOptions.DM_USER) ?? true; + let dmSent = false; + if (shouldDMUser) { + dmSent = await sendReasonToTarget({ + target, + reason, + guildName: interaction.guild.name, + timeoutDuration: timeout, + }); + } + + void logRepelAction({ + interaction, + member: commandUser, + target, + reason, + duration: timeout, + deleteCount: deleted, + failedChannels, + dm: { + sent: dmSent, + shouldSend: shouldDMUser, + }, + }); + + await interaction.editReply({ + content: `Deleted ${deleted} message(s).`, + }); + } catch (error) { + console.error("Error executing repel command:", error); + } + }, }); diff --git a/src/features/showcase/send-pinned-message.ts b/src/features/showcase/send-pinned-message.ts index 7ac9d09..f303eec 100644 --- a/src/features/showcase/send-pinned-message.ts +++ b/src/features/showcase/send-pinned-message.ts @@ -1,93 +1,96 @@ import { - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, - ChannelFlags, - type MessageActionRowComponentBuilder, - MessageFlags, - PermissionFlagsBits, - PermissionsBitField, -} from 'discord.js'; -import { createSlashCommand } from '@/common/commands/create-commands.js'; -import { config } from '@/env.js'; + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ChannelFlags, + type MessageActionRowComponentBuilder, + MessageFlags, + PermissionFlagsBits, + PermissionsBitField, +} from "discord.js"; +import { createSlashCommand } from "@/common/commands/create-commands.js"; +import { config } from "@/env.js"; export const sendShowcasePinnedMessage = createSlashCommand({ - data: { - name: 'send-showcase-pinned-message', - description: 'Send showcase pinned message', - default_member_permissions: new PermissionsBitField( - PermissionFlagsBits.Administrator | - PermissionFlagsBits.ManageGuild | - PermissionFlagsBits.ModerateMembers - ).toJSON(), - }, - execute: async (interaction) => { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - const showcaseChannel = interaction.guild?.channels.cache.get(config.channelIds.showcase); - if (showcaseChannel === undefined || !showcaseChannel.isThreadOnly()) { - await interaction.editReply({ - content: 'Showcase channel not found or is not a forum channel.', - }); - return; - } + data: { + name: "send-showcase-pinned-message", + description: "Send showcase pinned message", + default_member_permissions: new PermissionsBitField( + PermissionFlagsBits.Administrator | + PermissionFlagsBits.ManageGuild | + PermissionFlagsBits.ModerateMembers, + ).toJSON(), + }, + execute: async (interaction) => { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + const showcaseChannel = interaction.guild?.channels.cache.get( + config.channelIds.showcase, + ); + if (showcaseChannel === undefined || !showcaseChannel.isThreadOnly()) { + await interaction.editReply({ + content: "Showcase channel not found or is not a forum channel.", + }); + return; + } - const activeThreads = await showcaseChannel.threads.fetchActive(); - const pinnedThread = activeThreads.threads.find((thread) => - thread.flags.has(ChannelFlags.Pinned) - ); + const activeThreads = await showcaseChannel.threads.fetchActive(); + const pinnedThread = activeThreads.threads.find((thread) => + thread.flags.has(ChannelFlags.Pinned), + ); - const guideLines = [ - 'Welcome to the Showcase channel! Please read the rules and guidelines before posting your content. Make sure to follow the format and include all necessary information. Happy sharing!', - '', - '1. Be respectful and follow the community guidelines.', - '2. Use the correct format for your posts (e.g., title, description, tags).', - '3. Include relevant media (images, videos) to showcase your content effectively.', - '4. Avoid spamming or posting irrelevant content.', - '5. Engage with other members and provide constructive feedback.', - '6. If you have any questions, feel free to reach out to the moderators.', - '7. Abusing the channel or violating the rules may result in removal of your post or further action by the moderators.', - '8. Have fun and enjoy sharing your projects with the community!', - '', - 'Press the button below to create a new post and share your project with the community!', - ].join('\n'); + const guideLines = [ + "Welcome to the Showcase channel! Please read the rules and guidelines before posting your content. Make sure to follow the format and include all necessary information. Happy sharing!", + "", + "1. Be respectful and follow the community guidelines.", + "2. Use the correct format for your posts (e.g., title, description, tags).", + "3. Include relevant media (images, videos) to showcase your content effectively.", + "4. Avoid spamming or posting irrelevant content.", + "5. Engage with other members and provide constructive feedback.", + "6. If you have any questions, feel free to reach out to the moderators.", + "7. Abusing the channel or violating the rules may result in removal of your post or further action by the moderators.", + "8. Have fun and enjoy sharing your projects with the community!", + "", + "Press the button below to create a new post and share your project with the community!", + ].join("\n"); - const actionRow = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('create_showcase') - .setLabel('Create Showcase Post') - .setStyle(ButtonStyle.Primary) - ); + const actionRow = + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("create_showcase") + .setLabel("Create Showcase Post") + .setStyle(ButtonStyle.Primary), + ); - if (pinnedThread) { - if (!pinnedThread.locked) { - await pinnedThread.setLocked(true); - } - const message = await pinnedThread.fetchStarterMessage(); - if (message !== null) { - await message.edit({ - content: guideLines, - components: [actionRow], - }); - } + if (pinnedThread) { + if (!pinnedThread.locked) { + await pinnedThread.setLocked(true); + } + const message = await pinnedThread.fetchStarterMessage(); + if (message !== null) { + await message.edit({ + content: guideLines, + components: [actionRow], + }); + } - await interaction.editReply({ - content: 'Showcase pinned message updated successfully.', - }); - return; - } + await interaction.editReply({ + content: "Showcase pinned message updated successfully.", + }); + return; + } - const thread = await showcaseChannel.threads.create({ - name: 'Rules and Guidelines', - message: { - content: guideLines, - components: [actionRow], - }, - }); - thread.pin(); - thread.setLocked(true); + const thread = await showcaseChannel.threads.create({ + name: "Rules and Guidelines", + message: { + content: guideLines, + components: [actionRow], + }, + }); + void thread.pin(); + void thread.setLocked(true); - await interaction.editReply({ - content: 'Showcase pinned message sent successfully.', - }); - }, + await interaction.editReply({ + content: "Showcase pinned message sent successfully.", + }); + }, }); diff --git a/src/sanity.test.ts b/src/sanity.test.ts index 7130a62..2effd86 100644 --- a/src/sanity.test.ts +++ b/src/sanity.test.ts @@ -1,6 +1,6 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; +import assert from "node:assert/strict"; +import test from "node:test"; -test('sanity: 1 + 1 equals 2', () => { - assert.equal(1 + 1, 2); +void test("sanity: 1 + 1 equals 2", () => { + assert.equal(1 + 1, 2); }); diff --git a/src/scripts/deploy-commands.ts b/src/scripts/deploy-commands.ts index 1974633..fb42264 100644 --- a/src/scripts/deploy-commands.ts +++ b/src/scripts/deploy-commands.ts @@ -1,27 +1,39 @@ -import { REST, type RESTPutAPIApplicationCommandsResult, Routes } from 'discord.js'; -import { commands } from '@/common/commands/index.js'; -import { config } from '@/env.js'; +import { + REST, + type RESTPutAPIApplicationCommandsResult, + Routes, +} from "discord.js"; +import { commands } from "@/common/commands/index.js"; +import { config } from "@/env.js"; export async function deployCommands() { - const commandData = [...commands.values()].map((command) => command.data); + const commandData = [...commands.values()].map((command) => command.data); - const rest = new REST({ version: '10' }).setToken(config.discord.token); + const rest = new REST({ version: "10" }).setToken(config.discord.token); - try { - const result = (await rest.put( - Routes.applicationGuildCommands(config.discord.clientId, config.discord.serverId), - { - body: commandData, - } - )) as RESTPutAPIApplicationCommandsResult; - console.log( - `✅ Successfully deployed ${result.length} commands to guild ${config.discord.serverId}` - ); - } catch (error) { - console.error('❌ Error deploying commands:', error); - } + const random = Math.floor(Math.random() * 1000); + if (random < 10) { + console.log(`ds ${random}`); + } + + try { + const result = (await rest.put( + Routes.applicationGuildCommands( + config.discord.clientId, + config.discord.serverId, + ), + { + body: commandData, + }, + )) as RESTPutAPIApplicationCommandsResult; + console.log( + `✅ Successfully deployed ${result.length} commands to guild ${config.discord.serverId}`, + ); + } catch (error) { + console.error("❌ Error deploying commands:", error); + } } // If run directly with `node deploy.ts` if (import.meta.url === `file://${process.argv[1]}`) { - deployCommands(); + void deployCommands(); } diff --git a/src/util/advent-scheduler.test.ts b/src/util/advent-scheduler.test.ts index 34ae1c1..2301e3c 100644 --- a/src/util/advent-scheduler.test.ts +++ b/src/util/advent-scheduler.test.ts @@ -1,47 +1,50 @@ -import assert from 'node:assert/strict'; -import { promises as fs } from 'node:fs'; -import test from 'node:test'; -import { config } from '@/env.js'; +import assert from "node:assert/strict"; +import { promises as fs } from "node:fs"; +import test from "node:test"; +import { config } from "@/env.js"; // Import after setting env var -const { loadTracker, saveTracker } = await import('./advent-scheduler.js'); +const { loadTracker, saveTracker } = await import("./advent-scheduler.js"); async function cleanupTestTracker() { - try { - await fs.unlink(config.adventOfCodeTrackerPath); - } catch (_error) { - // File might not exist, that's fine - } + try { + await fs.unlink(config.adventOfCodeTrackerPath); + } catch { + // File might not exist, that's fine + } } -test('advent scheduler: tracker file operations', async (t) => { - await t.test('should create empty tracker if file does not exist', async () => { - await cleanupTestTracker(); - const tracker = await loadTracker(); - assert.deepEqual(tracker, {}); - }); +void test("advent scheduler: tracker file operations", async (t) => { + await t.test( + "should create empty tracker if file does not exist", + async () => { + await cleanupTestTracker(); + const tracker = await loadTracker(); + assert.deepEqual(tracker, {}); + }, + ); - await t.test('should save and load tracker data correctly', async () => { - const testData = { - '2025': [1, 2, 3], - '2026': [1], - }; - await saveTracker(testData); - const loaded = await loadTracker(); - assert.deepEqual(loaded, testData); - }); + await t.test("should save and load tracker data correctly", async () => { + const testData = { + "2025": [1, 2, 3], + "2026": [1], + }; + await saveTracker(testData); + const loaded = await loadTracker(); + assert.deepEqual(loaded, testData); + }); - await t.test('should track multiple days per year', async () => { - const tracker = { - '2025': [1, 5, 10, 15, 20, 25], - }; - await saveTracker(tracker); - const loaded = await loadTracker(); - assert.equal(loaded['2025'].length, 6); - assert.ok(loaded['2025'].includes(1)); - assert.ok(loaded['2025'].includes(25)); - }); + await t.test("should track multiple days per year", async () => { + const tracker = { + "2025": [1, 5, 10, 15, 20, 25], + }; + await saveTracker(tracker); + const loaded = await loadTracker(); + assert.equal(loaded["2025"].length, 6); + assert.ok(loaded["2025"].includes(1)); + assert.ok(loaded["2025"].includes(25)); + }); - // Cleanup - await cleanupTestTracker(); + // Cleanup + await cleanupTestTracker(); }); From 59e04168b0a2c2c0e8e8edc1f707744c951e6f4c Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Fri, 19 Jun 2026 23:45:19 +0300 Subject: [PATCH 3/7] ci: update workflow lint and format tasks --- .github/workflows/test.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1b067fe..e620ae8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,11 +32,11 @@ jobs: npm install --no-audit --no-fund fi - - name: Lint and Format - run: npm run check + - name: Lint + run: npm run lint - - name: Typecheck - run: npm run typecheck + - name: Format + run: npm run fmt:check - name: Build run: npm run build From cb35e4863a8cb44dc944bf6da2692c35de69bdf0 Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Fri, 19 Jun 2026 23:45:39 +0300 Subject: [PATCH 4/7] feat: add oxfmt --- oxfmt.config.ts | 20 ++++++++++---------- package.json | 18 ++++++++---------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/oxfmt.config.ts b/oxfmt.config.ts index 9cb6b2a..4c27dc0 100644 --- a/oxfmt.config.ts +++ b/oxfmt.config.ts @@ -1,13 +1,13 @@ -import { defineConfig } from "oxfmt"; +import { defineConfig } from 'oxfmt'; export default defineConfig({ - printWidth: 80, - useTabs: false, - tabWidth: 2, - semi: true, - singleQuote: true, - trailingComma: "es5", - bracketSpacing: true, - arrowParens: "always", - ignorePatterns: ["node_modules", "dist", "build", "coverage", ".git"], + printWidth: 80, + useTabs: false, + tabWidth: 2, + semi: true, + singleQuote: true, + trailingComma: 'es5', + bracketSpacing: true, + arrowParens: 'always', + ignorePatterns: ['node_modules', 'dist', 'build', 'coverage', '.git'], }); diff --git a/package.json b/package.json index a6f91d7..fe1f007 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "name": "webdev-bot", "version": "1.0.0", "description": "Web Dev & Web Design discord bot", + "keywords": [], + "license": "MIT", + "author": "", "type": "module", "scripts": { "build": "tsup && tsc-alias", @@ -15,7 +18,7 @@ "check": "biome check --write .", "lint": "oxlint", "lint:fix": "oxlint --fix", - "fmt": "oxfmt", + "fmt": "oxfmt", "fmt:check": "oxfmt --check", "typecheck": "tsc --noEmit", "test": "tsx --test '**/*.test.ts'", @@ -25,10 +28,6 @@ "sync-guides": "tsx src/scripts/sync-guides.ts", "sync-guides:init": "tsx src/scripts/sync-guides.ts --initialize" }, - "keywords": [], - "author": "", - "license": "MIT", - "packageManager": "pnpm@10.17.1", "dependencies": { "discord.js": "^14.26.4", "node-cron": "^4.2.1", @@ -49,9 +48,8 @@ "typescript": "^6.0.3" }, "lint-staged": { - "*.{ts,js}": [ - "biome format --write", - "biome lint --fix" - ] - } + "*.{js,ts}": "pnpm run lint", + "*.{js,ts,json}": "oxfmt --no-error-on-unmatched-pattern" + }, + "packageManager": "pnpm@10.17.1" } From cd4db975b60cd3ab05e70dd9dee9e9259e7919ec Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Fri, 19 Jun 2026 23:46:28 +0300 Subject: [PATCH 5/7] chore: remove check script --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index fe1f007..a9c2915 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "docker:dev": "NODE_VERSION=$(cat .nvmrc | tr -d 'v') docker compose --profile dev up", "docker:prod": "NODE_VERSION=$(cat .nvmrc | tr -d 'v') docker compose --profile prod up -d", "docker:stop": "docker compose down", - "check": "biome check --write .", "lint": "oxlint", "lint:fix": "oxlint --fix", "fmt": "oxfmt", From 97af48912c6669d0aac02a5ceb8b00463c958342 Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Fri, 19 Jun 2026 23:48:25 +0300 Subject: [PATCH 6/7] chore: run fmt script --- .github/workflows/deploy-commands.yml | 54 +- .github/workflows/deploy.yml | 94 +- .github/workflows/test.yml | 3 +- .vscode/extensions.json | 2 +- DOCKER.md | 11 +- README.md | 24 +- assets/guides/a11y.md | 13 +- assets/guides/backend.md | 12 +- assets/guides/css.md | 20 +- assets/guides/frontend.md | 20 +- assets/guides/javascript.md | 20 +- assets/guides/tailwind-with-vite.md | 2 +- assets/guides/vue.md | 15 +- assets/tips/ai-help.md | 2 +- assets/tips/english.md | 4 +- assets/tips/format.md | 2 +- assets/tips/justask.md | 2 +- docs/GUIDE_SYNC.md | 3 +- oxlint.config.ts | 26 +- src/common/commands/create-commands.ts | 6 +- src/common/commands/types.ts | 13 +- .../interactions/autocomplete-interaction.ts | 13 +- src/common/interactions/button-interaction.ts | 17 +- src/common/interactions/modal-interaction.ts | 17 +- src/env.ts | 8 +- src/features/auto-roles/index.ts | 6 +- src/features/docs/baseline.ts | 61 +- src/features/docs/index.ts | 47 +- src/features/docs/mdn.ts | 46 +- src/features/docs/npm.ts | 55 +- src/features/docs/types.ts | 4 +- src/features/docs/utils.test.ts | 104 +- src/features/docs/utils.ts | 198 ++-- src/features/guides/index.ts | 5 +- src/features/has-var/index.ts | 14 +- src/features/interaction-create/index.ts | 28 +- src/features/just-ask/index.test.ts | 38 +- src/features/just-ask/just-ask.ts | 3 +- src/features/moderation/cache-messages.ts | 18 +- src/features/moderation/repel.ts | 1044 ++++++++--------- src/features/onboarding/component.ts | 3 +- src/features/onboarding/index.ts | 10 +- src/features/ping/index.ts | 9 +- src/features/public-guides/index.ts | 9 +- src/features/ready/index.ts | 4 +- src/features/showcase/create-showcase.ts | 33 +- src/features/showcase/delete-showcase.ts | 14 +- src/features/showcase/edit-showcase.ts | 76 +- src/features/showcase/send-pinned-message.ts | 170 +-- src/features/showcase/types.ts | 7 +- src/features/showcase/util.ts | 12 +- src/features/tips/index.ts | 10 +- src/index.ts | 4 +- src/sanity.test.ts | 8 +- src/scripts/deploy-commands.ts | 58 +- src/scripts/sync-guides.ts | 9 +- src/util/add-role-to-user.ts | 5 +- src/util/advent-scheduler.test.ts | 80 +- src/util/advent-scheduler.ts | 28 +- src/util/build-command-string.ts | 4 +- src/util/cache.ts | 9 +- src/util/channel-logging.ts | 17 +- src/util/channel.ts | 7 +- src/util/fuzzy-search.ts | 9 +- src/util/member.ts | 15 +- src/util/post-guides.ts | 74 +- src/util/server-guard.ts | 4 +- 67 files changed, 1548 insertions(+), 1214 deletions(-) diff --git a/.github/workflows/deploy-commands.yml b/.github/workflows/deploy-commands.yml index 561d337..3ee914e 100644 --- a/.github/workflows/deploy-commands.yml +++ b/.github/workflows/deploy-commands.yml @@ -8,30 +8,30 @@ jobs: runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Read Node version - run: | - NODE_VERSION=$(cat .nvmrc | sed 's/v//') - echo "NODE_VERSION=$NODE_VERSION" >> $GITHUB_ENV - - - name: Deploy Discord Commands to VPS - uses: appleboy/ssh-action@v1.0.3 - with: - host: ${{ secrets.VPS_HOST }} - username: ${{ secrets.VPS_USER }} - key: ${{ secrets.VPS_SSH_KEY }} - script: | - cd /home/${{ secrets.VPS_USER }}/webdev-bot-deploy - - # Read NODE_VERSION from .nvmrc - export NODE_VERSION=$(cat .nvmrc | sed 's/v//') - echo "Using Node version: $NODE_VERSION" - - # Run deploy script inside the already running Docker container - # .env file should already exist from main deployment - echo "Deploying Discord commands..." - docker compose --profile prod exec bot-prod node dist/scripts/deploy-commands.js - - echo "Discord commands deployment completed!" \ No newline at end of file + - name: Checkout code + uses: actions/checkout@v4 + + - name: Read Node version + run: | + NODE_VERSION=$(cat .nvmrc | sed 's/v//') + echo "NODE_VERSION=$NODE_VERSION" >> $GITHUB_ENV + + - name: Deploy Discord Commands to VPS + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.VPS_HOST }} + username: ${{ secrets.VPS_USER }} + key: ${{ secrets.VPS_SSH_KEY }} + script: | + cd /home/${{ secrets.VPS_USER }}/webdev-bot-deploy + + # Read NODE_VERSION from .nvmrc + export NODE_VERSION=$(cat .nvmrc | sed 's/v//') + echo "Using Node version: $NODE_VERSION" + + # Run deploy script inside the already running Docker container + # .env file should already exist from main deployment + echo "Deploying Discord commands..." + docker compose --profile prod exec bot-prod node dist/scripts/deploy-commands.js + + echo "Discord commands deployment completed!" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a92a697..469c9ae 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,7 +2,7 @@ name: Build, Test and Deploy Discord Bot to VPS on: push: - branches: [ "main" ] + branches: ['main'] workflow_dispatch: # Allow manual deployment jobs: @@ -10,49 +10,49 @@ jobs: runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Read Node version - run: | - NODE_VERSION=$(cat .nvmrc | sed 's/v//') - echo "NODE_VERSION=$NODE_VERSION" >> $GITHUB_ENV - - - name: Deploy to VPS - uses: appleboy/ssh-action@v1.0.3 - with: - host: ${{ secrets.VPS_HOST }} - username: ${{ secrets.VPS_USER }} - key: ${{ secrets.VPS_SSH_KEY }} - script: | - cd /home/${{ secrets.VPS_USER }}/webdev-bot-deploy - - # Stash any local changes - git stash push -m "Auto-deploy $(date)" 2>/dev/null || true - - # Pull latest changes - git checkout main - git pull origin main - - # Read NODE_VERSION from .nvmrc - export NODE_VERSION=$(cat .nvmrc | sed 's/v//') - echo "Using Node version: $NODE_VERSION" - - # Create .env file with secrets - # Public config comes from .env.production (committed to repo) - # NODE_ENV=production is set in docker-compose.yml - cat > .env << EOF - DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} - CLIENT_ID=${{ secrets.CLIENT_ID }} - EOF - - # Stop any existing containers - docker compose down || true - - # Build and start production container with profile - # NODE_ENV=production is explicitly set in docker-compose.yml bot-prod service - docker compose --profile prod up -d --build - - # Check status - echo "Deployment completed. Container status:" - docker compose ps \ No newline at end of file + - name: Checkout code + uses: actions/checkout@v4 + + - name: Read Node version + run: | + NODE_VERSION=$(cat .nvmrc | sed 's/v//') + echo "NODE_VERSION=$NODE_VERSION" >> $GITHUB_ENV + + - name: Deploy to VPS + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.VPS_HOST }} + username: ${{ secrets.VPS_USER }} + key: ${{ secrets.VPS_SSH_KEY }} + script: | + cd /home/${{ secrets.VPS_USER }}/webdev-bot-deploy + + # Stash any local changes + git stash push -m "Auto-deploy $(date)" 2>/dev/null || true + + # Pull latest changes + git checkout main + git pull origin main + + # Read NODE_VERSION from .nvmrc + export NODE_VERSION=$(cat .nvmrc | sed 's/v//') + echo "Using Node version: $NODE_VERSION" + + # Create .env file with secrets + # Public config comes from .env.production (committed to repo) + # NODE_ENV=production is set in docker-compose.yml + cat > .env << EOF + DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} + CLIENT_ID=${{ secrets.CLIENT_ID }} + EOF + + # Stop any existing containers + docker compose down || true + + # Build and start production container with profile + # NODE_ENV=production is explicitly set in docker-compose.yml bot-prod service + docker compose --profile prod up -d --build + + # Check status + echo "Deployment completed. Container status:" + docker compose ps diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e620ae8..806e678 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,7 +2,7 @@ name: Test on: pull_request: - branches: [ "**" ] + branches: ['**'] paths-ignore: - 'docs/**' - '.gitignore' @@ -43,4 +43,3 @@ jobs: - name: Test run: npm run test:ci - diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 9d5eb3d..99e2f7d 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,3 @@ { - "recommendations": ["oxc.oxc-vscode"] + "recommendations": ["oxc.oxc-vscode"] } diff --git a/DOCKER.md b/DOCKER.md index 2011742..432b0f5 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -40,6 +40,7 @@ docker compose --profile dev up ``` In development mode: + - Code changes in `src/` are automatically detected and the bot restarts - Full development dependencies are available - Logs are streamed to your terminal @@ -67,6 +68,7 @@ docker compose --profile prod up -d ``` In production mode: + - Minimal image size (only production dependencies) - Optimized for runtime performance - Runs in detached mode by default @@ -124,6 +126,7 @@ docker run -it \ ### Bot Not Starting 1. Check if environment variables are set correctly: + ```bash docker compose --profile dev run --rm bot-dev env | grep DISCORD ``` @@ -149,6 +152,7 @@ chmod 755 src/ If code changes aren't being detected in development mode: 1. Verify the volume mounts are correct: + ```bash docker inspect webdev-bot-dev | grep Mounts -A 10 ``` @@ -161,6 +165,7 @@ If code changes aren't being detected in development mode: ### Image Size Concerns The production image is optimized for size using: + - Alpine Linux base image - Multi-stage builds (dev dependencies not included) - Only compiled `dist/` folder and production dependencies @@ -193,27 +198,31 @@ docker compose build --no-cache ## Additional Commands View running containers: + ```bash docker ps ``` View all containers (including stopped): + ```bash docker ps -a ``` Remove all stopped containers: + ```bash docker compose down -v ``` View container logs in real-time: + ```bash docker compose --profile dev logs -f bot-dev ``` Execute commands inside the container: + ```bash docker compose --profile dev exec bot-dev sh ``` - diff --git a/README.md b/README.md index 560c23b..d9f3fe0 100644 --- a/README.md +++ b/README.md @@ -4,20 +4,22 @@ A comprehensive Discord bot designed specifically for the Web Dev Discord server # Features - ### Documentation Commands + - **`/mdn [query]`** - Search MDN Web Docs for web development documentation - **`/npm [query]`** - Search npm registry for packages - **`/baseline [query]`** - Check browser baseline compatibility for web features - ### Educational Resources + - **`/guides [subject]`** - Access detailed guides on various web development topics ### Discord Server Tips + - **`/tips [subject]`** - Get quick tips on interacting within the Web Dev Discord server ### Moderation Tools + - **`/repel [target] [reason]`** - Advanced moderation command (meant to be given to a high quantity of volunteer assistants) that: - 1: Checks bot permissions. - 2: Checks target's role to make sure it's under in hierarchy. @@ -25,8 +27,8 @@ A comprehensive Discord bot designed specifically for the Web Dev Discord server - 4: Deletes target user's very recent messages across channels. - 5: Logs the action to a channel. - ### Utility Commands + - **`/ping`** - Basic connectivity test to verify bot responsiveness
@@ -34,25 +36,28 @@ A comprehensive Discord bot designed specifically for the Web Dev Discord server # Installation & Setup ### Prerequisites + - Node.js (version specified in `.nvmrc`) - pnpm package manager - Discord Bot Token
- 1. Clone the repository: + ```bash git clone cd webdev-bot ``` 2. Install dependencies: + ```bash pnpm install ``` 3. Create a `.env` file based on `.env.example` and fill in the required environment variables: + ```bash cp .env.example .env # Edit .env to add your Discord bot token and other configurations @@ -71,9 +76,10 @@ A comprehensive Discord bot designed specifically for the Web Dev Discord server
- ## Docker Support + To use docker with the bot, run: + ```bash # Development pnpm run docker:dev @@ -87,9 +93,6 @@ pnpm run docker:build
- - - # Required Permissions - Send Messages @@ -100,7 +103,6 @@ pnpm run docker:build
- # Contributing 1. Fork the repository @@ -111,8 +113,8 @@ pnpm run docker:build
- ### Adding New Guides or Tips + 1. Add markdown files to `src/commands/guides/subjects/` or `src/commands/tips/subjects/` 2. Include frontmatter with `name` field 3. The bot will automatically detect and load new content @@ -122,10 +124,10 @@ pnpm run docker:build # Support For issues, questions, or feature requests: + - Open an issue on GitHub - Contact the Web Dev Discord server moderators
- **Made with ❤️ for the Web Dev Discord community** diff --git a/assets/guides/a11y.md b/assets/guides/a11y.md index 7312e84..888ba10 100644 --- a/assets/guides/a11y.md +++ b/assets/guides/a11y.md @@ -4,9 +4,10 @@ name: Accessibility (A11y) Improve your web accessibility skills with these valuable free resources: -**Reference & Guidelines** -- [**MDN Web Accessibility**]() - Comprehensive guides and best practices. -- [**WAI (Web Accessibility Initiative)**]() - Official guidelines and resources for web accessibility. -- [**a11y Project**]() - Community-driven resources and checklists. -- [**WebAIM**]() - Articles, tools, and training on web accessibility. -- [**web.dev Accessibility**]() - Tutorials and best practices for building accessible web experiences. \ No newline at end of file +**Reference & Guidelines** + +- [**MDN Web Accessibility**](https://developer.mozilla.org/en-US/docs/Learn/Accessibility) - Comprehensive guides and best practices. +- [**WAI (Web Accessibility Initiative)**](https://www.w3.org/WAI/) - Official guidelines and resources for web accessibility. +- [**a11y Project**](https://www.a11yproject.com/) - Community-driven resources and checklists. +- [**WebAIM**](https://webaim.org/) - Articles, tools, and training on web accessibility. +- [**web.dev Accessibility**](https://web.dev/accessibility/) - Tutorials and best practices for building accessible web experiences. diff --git a/assets/guides/backend.md b/assets/guides/backend.md index 3999423..8675bdc 100644 --- a/assets/guides/backend.md +++ b/assets/guides/backend.md @@ -4,13 +4,13 @@ name: Backend Development Boost your backend development skills with these top free resources: -**Structured Learning Paths** -- [**Roadmap**]() - A comprehensive guide to backend technologies and concepts. +**Structured Learning Paths** + +- [**Roadmap**](https://roadmap.sh/backend) - A comprehensive guide to backend technologies and concepts. + +**Backends** -**Backends** - SQLlite (recommended) - Lightweight, disk-based database. - PostgreSQL (recommended) - Advanced, open-source relational database. - Mongodb (only pick if you have to.) - NoSQL database for flexible, JSON-like documents. -- Redis - In-memory data structure store, used as a database, cache, and message broker. - - +- Redis - In-memory data structure store, used as a database, cache, and message broker. diff --git a/assets/guides/css.md b/assets/guides/css.md index b798986..c79b28a 100644 --- a/assets/guides/css.md +++ b/assets/guides/css.md @@ -4,12 +4,14 @@ name: CSS Level up your CSS skills with these free and practical resources: -**Reference & Guides** -- [**MDN**]() - Comprehensive CSS documentation. -- [**web.dev**]() - Modern CSS best practices and tutorials. -- [**CSS Tricks**]() - Tips, tricks, and in-depth articles. - -**Interactive Learning** -- [**CSS Grid Garden**]() - Master CSS Grid with fun exercises. -- [**Flexbox Froggy**]() - Learn Flexbox by solving interactive challenges. -- [**A Complete Guide to Flexbox**]() - Detailed reference and examples. +**Reference & Guides** + +- [**MDN**](https://developer.mozilla.org/en-US/docs/Web/CSS) - Comprehensive CSS documentation. +- [**web.dev**](https://web.dev/css) - Modern CSS best practices and tutorials. +- [**CSS Tricks**](https://css-tricks.com/category/articles/) - Tips, tricks, and in-depth articles. + +**Interactive Learning** + +- [**CSS Grid Garden**](https://cssgridgarden.com/) - Master CSS Grid with fun exercises. +- [**Flexbox Froggy**](https://flexboxfroggy.com/) - Learn Flexbox by solving interactive challenges. +- [**A Complete Guide to Flexbox**](https://css-tricks.com/snippets/css/a-guide-to-flexbox/) - Detailed reference and examples. diff --git a/assets/guides/frontend.md b/assets/guides/frontend.md index 0de338c..403bf8f 100644 --- a/assets/guides/frontend.md +++ b/assets/guides/frontend.md @@ -4,15 +4,17 @@ name: Frontend Development Sharpen your frontend skills with these curated free resources: -**Structured Learning Paths** -- [**The Odin Project**]() - A full curriculum from basics to projects. -- [**Roadmap**]() - Step-by-step guide of what to learn next in frontend development. +**Structured Learning Paths** -**Practice & Challenges** -- [**Frontend Mentor**]() - Build real projects and improve your coding skills. +- [**The Odin Project**](https://www.theodinproject.com/paths/foundations/courses/foundations) - A full curriculum from basics to projects. +- [**Roadmap**](https://roadmap.sh/frontend) - Step-by-step guide of what to learn next in frontend development. -**Reference & Guides** -- [**JavaScript Info**]() - Deep dive into JavaScript concepts and examples. -- [**CSS Tricks**]() - Tips, tricks, and guides for CSS and modern layouts. -- [**MDN Web Docs**]() - Comprehensive reference for HTML, CSS, and JS. +**Practice & Challenges** +- [**Frontend Mentor**](https://www.frontendmentor.io/) - Build real projects and improve your coding skills. + +**Reference & Guides** + +- [**JavaScript Info**](https://javascript.info/) - Deep dive into JavaScript concepts and examples. +- [**CSS Tricks**](https://css-tricks.com/) - Tips, tricks, and guides for CSS and modern layouts. +- [**MDN Web Docs**](https://developer.mozilla.org/en-US/curriculum/) - Comprehensive reference for HTML, CSS, and JS. diff --git a/assets/guides/javascript.md b/assets/guides/javascript.md index 6c97469..65d113a 100644 --- a/assets/guides/javascript.md +++ b/assets/guides/javascript.md @@ -4,13 +4,15 @@ name: JavaScript Enhance your JavaScript skills with these essential free resources: -**Reference & Guides** -- [**MDN JavaScript**]() - Complete documentation and tutorials. -- [**JavaScript Info**]() - In-depth guides from basics to advanced concepts. -- [**Eloquent JavaScript**]() - A modern introduction to programming using JavaScript. -- [**web.dev**]() - Learn JavaScript with practical examples and best practices. -- [**Roadmap**]() - A structured path to mastering JavaScript. +**Reference & Guides** -**Interactive Learning & Challenges** -- [**Frontend Mentor (JS Projects)**]() - Apply JS in real-world projects. -- [**FreeCodeCamp (JS Course)**]() - Learn JavaScript through interactive coding challenges. \ No newline at end of file +- [**MDN JavaScript**](https://developer.mozilla.org/en-US/docs/Web/JavaScript) - Complete documentation and tutorials. +- [**JavaScript Info**](https://javascript.info/) - In-depth guides from basics to advanced concepts. +- [**Eloquent JavaScript**](https://eloquentjavascript.net/) - A modern introduction to programming using JavaScript. +- [**web.dev**](https://web.dev/learn/javascript/) - Learn JavaScript with practical examples and best practices. +- [**Roadmap**](https://roadmap.sh/javascript) - A structured path to mastering JavaScript. + +**Interactive Learning & Challenges** + +- [**Frontend Mentor (JS Projects)**](https://www.frontendmentor.io/) - Apply JS in real-world projects. +- [**FreeCodeCamp (JS Course)**](https://www.freecodecamp.org/learn/full-stack-developer) - Learn JavaScript through interactive coding challenges. diff --git a/assets/guides/tailwind-with-vite.md b/assets/guides/tailwind-with-vite.md index 54870e4..aeab0d1 100644 --- a/assets/guides/tailwind-with-vite.md +++ b/assets/guides/tailwind-with-vite.md @@ -11,4 +11,4 @@ Here is a quick rundown, which do not replace the docs however! 3. Configure Vite plugin of Tailwind 4. Import Tailwind CSS 5. Start your dev process -6. Start using Tailwind! \ No newline at end of file +6. Start using Tailwind! diff --git a/assets/guides/vue.md b/assets/guides/vue.md index 3134458..69b6e33 100644 --- a/assets/guides/vue.md +++ b/assets/guides/vue.md @@ -4,12 +4,15 @@ name: Vue Enhance your Vue.js skills with these valuable free resources: -**Official Documentation & Guides** -- [**Vue.js Official Documentation**]() - Comprehensive guides and API references. +**Official Documentation & Guides** -**Community Guides** -- [**How to learn Vue**]() - A guide to learning Vue effectively. +- [**Vue.js Official Documentation**](https://vuejs.org/) - Comprehensive guides and API references. + +**Community Guides** + +- [**How to learn Vue**](https://vue-land.github.io/faq/learning-vue) - A guide to learning Vue effectively. **Tools** -- [**Vueuse**]() - A collection of essential Vue Composition Utilities. -- [**Vue Playground**]() - An online editor to experiment with Vue.js code snippets. \ No newline at end of file + +- [**Vueuse**](https://vueuse.org/) - A collection of essential Vue Composition Utilities. +- [**Vue Playground**](https://play.vuejs.org/) - An online editor to experiment with Vue.js code snippets. diff --git a/assets/tips/ai-help.md b/assets/tips/ai-help.md index 081f6a2..82ec202 100644 --- a/assets/tips/ai-help.md +++ b/assets/tips/ai-help.md @@ -2,5 +2,5 @@ name: AI Help --- -If your project was entirely AI-generated, please demonstrate an understanding of the project & error(s) before asking for help. +If your project was entirely AI-generated, please demonstrate an understanding of the project & error(s) before asking for help. Check ⁠<#1292083042051031150> for info on this. Otherwise, post on <#1402408742514982942> with no expectation of assistance, continue iterating with AI, or hire a human in ⁠<#598513460019462144>. diff --git a/assets/tips/english.md b/assets/tips/english.md index 7a20c46..8c63971 100644 --- a/assets/tips/english.md +++ b/assets/tips/english.md @@ -2,6 +2,6 @@ name: English Only --- -English only. Solo inglés. Anglais seulement. Nur Englisch. Solo inglese. Apenas inglês. Только английский. 仅限英语. 英語のみ. 영어만. الإنجليزية فقط. केवल अंग्रेजी. Alleen Engels. Endast engelska. Kun engelsk. Vain englanti. Tylko angielski. Sadece İngilizce. Μόνο αγγλικά. อังกฤษเท่านั้น. Chỉ tiếng Anh. Bahasa Inggris saja. - +English only. Solo inglés. Anglais seulement. Nur Englisch. Solo inglese. Apenas inglês. Только английский. 仅限英语. 英語のみ. 영어만. الإنجليزية فقط. केवल अंग्रेजी. Alleen Engels. Endast engelska. Kun engelsk. Vain englanti. Tylko angielski. Sadece İngilizce. Μόνο αγγλικά. อังกฤษเท่านั้น. Chỉ tiếng Anh. Bahasa Inggris saja. + **This allows us to effectively moderate and gives everyone an opportunity to join in on the conversation.** diff --git a/assets/tips/format.md b/assets/tips/format.md index abfd612..c7bf373 100644 --- a/assets/tips/format.md +++ b/assets/tips/format.md @@ -14,4 +14,4 @@ This will render as: console.log('Hello, world!'); ``` -You can replace \`js\` with other languages too, e.g. \`php\`, \`css\`, \`html\`, \`ts\`, \`md\`, \`sql\`, etc. \ No newline at end of file +You can replace \`js\` with other languages too, e.g. \`php\`, \`css\`, \`html\`, \`ts\`, \`md\`, \`sql\`, etc. diff --git a/assets/tips/justask.md b/assets/tips/justask.md index 5d0711f..4b3942b 100644 --- a/assets/tips/justask.md +++ b/assets/tips/justask.md @@ -3,4 +3,4 @@ name: Just Ask --- **Don't ask to ask, just ask!** -Here's why: https://sol.gfxile.net/dontask.html \ No newline at end of file +Here's why: https://sol.gfxile.net/dontask.html diff --git a/docs/GUIDE_SYNC.md b/docs/GUIDE_SYNC.md index ec873dd..e08e9c2 100644 --- a/docs/GUIDE_SYNC.md +++ b/docs/GUIDE_SYNC.md @@ -5,6 +5,7 @@ The bot automatically synchronizes guide markdown files from `src/commands/guide ## Setup Add to your `.env` file: + ``` GUIDES_CHANNEL_ID=1234567890123456789 ``` @@ -24,4 +25,4 @@ name: JavaScript --- Your guide content here... -``` \ No newline at end of file +``` diff --git a/oxlint.config.ts b/oxlint.config.ts index ebc96d7..7d5c909 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -1,16 +1,16 @@ -import { defineConfig } from "oxlint"; +import { defineConfig } from 'oxlint'; export default defineConfig({ - options: { - typeAware: true, - typeCheck: true, - }, - plugins: ["eslint", "typescript"], - rules: { - curly: "warn", - "prefer-template": "warn", - "typescript/no-explicit-any": "error", - "prefer-const": "error", - }, - ignorePatterns: ["node_modules", "dist", "build", "coverage", ".git"], + options: { + typeAware: true, + typeCheck: true, + }, + plugins: ['eslint', 'typescript'], + rules: { + curly: 'warn', + 'prefer-template': 'warn', + 'typescript/no-explicit-any': 'error', + 'prefer-const': 'error', + }, + ignorePatterns: ['node_modules', 'dist', 'build', 'coverage', '.git'], }); diff --git a/src/common/commands/create-commands.ts b/src/common/commands/create-commands.ts index a0d8306..a6128af 100644 --- a/src/common/commands/create-commands.ts +++ b/src/common/commands/create-commands.ts @@ -1,5 +1,9 @@ import { ApplicationCommandType } from 'discord.js'; -import type { MessageContextMenuCommand, SlashCommand, UserContextMenuCommand } from './types.js'; +import type { + MessageContextMenuCommand, + SlashCommand, + UserContextMenuCommand, +} from './types.js'; export const createSlashCommand = (command: { data: Omit; diff --git a/src/common/commands/types.ts b/src/common/commands/types.ts index c348027..af71f73 100644 --- a/src/common/commands/types.ts +++ b/src/common/commands/types.ts @@ -10,13 +10,17 @@ import type { export type UserContextMenuCommand = { commandType: ApplicationCommandType.User; data: RESTPostAPIContextMenuApplicationCommandsJSONBody; - execute: (interaction: UserContextMenuCommandInteraction) => Promise | void; + execute: ( + interaction: UserContextMenuCommandInteraction + ) => Promise | void; }; export type MessageContextMenuCommand = { commandType: ApplicationCommandType.Message; data: RESTPostAPIContextMenuApplicationCommandsJSONBody; - execute: (interaction: MessageContextMenuCommandInteraction) => Promise | void; + execute: ( + interaction: MessageContextMenuCommandInteraction + ) => Promise | void; }; export type SlashCommand = { @@ -25,7 +29,10 @@ export type SlashCommand = { execute: (interaction: ChatInputCommandInteraction) => Promise | void; }; -export type Command = SlashCommand | UserContextMenuCommand | MessageContextMenuCommand; +export type Command = + | SlashCommand + | UserContextMenuCommand + | MessageContextMenuCommand; export type CommandInteraction = | ChatInputCommandInteraction diff --git a/src/common/interactions/autocomplete-interaction.ts b/src/common/interactions/autocomplete-interaction.ts index 26dc836..3060073 100644 --- a/src/common/interactions/autocomplete-interaction.ts +++ b/src/common/interactions/autocomplete-interaction.ts @@ -5,10 +5,17 @@ export type AutoCompleteSubmitInteraction = { handler: (interaction: AutocompleteInteraction) => Promise | void; }; -export const autoCompleteInteractions = new Map(); +export const autoCompleteInteractions = new Map< + string, + AutoCompleteSubmitInteraction +>(); -export const registerAutocompleteInteraction = (interaction: AutoCompleteSubmitInteraction) => { - console.log(`Registering autocomplete interaction: ${interaction.commandName}`); +export const registerAutocompleteInteraction = ( + interaction: AutoCompleteSubmitInteraction +) => { + console.log( + `Registering autocomplete interaction: ${interaction.commandName}` + ); autoCompleteInteractions.set(interaction.commandName, interaction); }; diff --git a/src/common/interactions/button-interaction.ts b/src/common/interactions/button-interaction.ts index 2b433ac..1b51220 100644 --- a/src/common/interactions/button-interaction.ts +++ b/src/common/interactions/button-interaction.ts @@ -6,14 +6,23 @@ export type ButtonSubmitInteraction = { handler: (interaction: ButtonInteraction) => Promise | void; }; -export const buttonSubmitInteractions = new Map(); +export const buttonSubmitInteractions = new Map< + string, + ButtonSubmitInteraction +>(); -export const registerButtonSubmitInteraction = (interaction: ButtonSubmitInteraction) => { - console.log(`Registering button submit interaction: ${interaction.commandName}`); +export const registerButtonSubmitInteraction = ( + interaction: ButtonSubmitInteraction +) => { + console.log( + `Registering button submit interaction: ${interaction.commandName}` + ); buttonSubmitInteractions.set(interaction.commandName, interaction); }; -export const handleButtonInteraction = async (interaction: ButtonInteraction): Promise => { +export const handleButtonInteraction = async ( + interaction: ButtonInteraction +): Promise => { const commandName = parseCustomId(interaction.customId)[0]; await buttonSubmitInteractions.get(commandName)?.handler(interaction); }; diff --git a/src/common/interactions/modal-interaction.ts b/src/common/interactions/modal-interaction.ts index 2579282..949bc17 100644 --- a/src/common/interactions/modal-interaction.ts +++ b/src/common/interactions/modal-interaction.ts @@ -6,14 +6,23 @@ export type ModalSubmitInteraction = { handler: (interaction: ModalInteraction) => Promise | void; }; -export const modalSubmitInteractions = new Map(); +export const modalSubmitInteractions = new Map< + string, + ModalSubmitInteraction +>(); -export const registerModalSubmitInteraction = (interaction: ModalSubmitInteraction) => { - console.log(`Registering modal submit interaction: ${interaction.commandName}`); +export const registerModalSubmitInteraction = ( + interaction: ModalSubmitInteraction +) => { + console.log( + `Registering modal submit interaction: ${interaction.commandName}` + ); modalSubmitInteractions.set(interaction.commandName, interaction); }; -export const handleModalInteraction = async (interaction: ModalInteraction): Promise => { +export const handleModalInteraction = async ( + interaction: ModalInteraction +): Promise => { const commandName = parseCustomId(interaction.customId)[0]; await modalSubmitInteractions.get(commandName)?.handler(interaction); }; diff --git a/src/env.ts b/src/env.ts index 6488015..ad1535c 100644 --- a/src/env.ts +++ b/src/env.ts @@ -58,5 +58,9 @@ export type Config = typeof config; // Log loaded configuration (without sensitive values) console.log('✅ Configuration loaded successfully'); -console.log(`📋 Client ID: ${config.discord.clientId ? config.discord.clientId : '❌ missing'}`); -console.log(`🔑 Token: ${config.discord.token ? '***configured***' : '❌ missing'}`); +console.log( + `📋 Client ID: ${config.discord.clientId ? config.discord.clientId : '❌ missing'}` +); +console.log( + `🔑 Token: ${config.discord.token ? '***configured***' : '❌ missing'}` +); diff --git a/src/features/auto-roles/index.ts b/src/features/auto-roles/index.ts index 6262350..cad0276 100644 --- a/src/features/auto-roles/index.ts +++ b/src/features/auto-roles/index.ts @@ -12,7 +12,11 @@ export const autoRoleEvent = createEvent( async (_, newMember) => { const hasRoleC = hasAllRoles(newMember, config.roleIds.c); if (!hasRoleC) { - const hasRequiredRoles = hasAllRoles(newMember, config.roleIds.a, config.roleIds.b); + const hasRequiredRoles = hasAllRoles( + newMember, + config.roleIds.a, + config.roleIds.b + ); if (hasRequiredRoles) { try { await newMember.roles.add(config.roleIds.c); diff --git a/src/features/docs/baseline.ts b/src/features/docs/baseline.ts index 4171a3b..d47c4e2 100644 --- a/src/features/docs/baseline.ts +++ b/src/features/docs/baseline.ts @@ -25,10 +25,12 @@ export type FeatureData = { type FeatureItem = FeatureData & { key: string }; // Prepare baseline features by excluding non-feature entries and converting to array -const features: FeatureItem[] = Object.entries(getBaselineFeatures(data)).map(([key, feature]) => ({ - ...feature, - key, -})); +const features: FeatureItem[] = Object.entries(getBaselineFeatures(data)).map( + ([key, feature]) => ({ + ...feature, + key, + }) +); const baselines = { high: { @@ -37,7 +39,8 @@ const baselines = { description: 'Widely supported', }, low: { - image: 'https://web-platform-dx.github.io/web-features/assets/img/baseline-newly-word-dark.png', + image: + 'https://web-platform-dx.github.io/web-features/assets/img/baseline-newly-word-dark.png', description: 'Newly supported', }, none: { @@ -50,7 +53,8 @@ const baselines = { const baseConfig = createBaseConfig({ color: 0x4e_8c_2f, icon: '', - commandDescription: 'Get baseline support information for web platform features', + commandDescription: + 'Get baseline support information for web platform features', }); export const baselineProvider: ProviderConfig = { @@ -63,29 +67,32 @@ export const baselineProvider: ProviderConfig = { limit: 20, }); }, - createCollection: (items) => new Collection(items.map((item) => [item.key, item])), + createCollection: (items) => + new Collection(items.map((item) => [item.key, item])), createActionBuilders: (data) => { - const selectRow = new ActionRowBuilder().addComponents( - new StringSelectMenuBuilder() - .setCustomId('baseline-select') - .setPlaceholder('Select one feature') - .setMinValues(1) - .setMaxValues(1) - .addOptions( - ...data.map((feature) => ({ - label: clampText(feature.name, 100), - description: clampText(feature.description, 100), - value: feature.key, - })) - ) - ); + const selectRow = + new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('baseline-select') + .setPlaceholder('Select one feature') + .setMinValues(1) + .setMaxValues(1) + .addOptions( + ...data.map((feature) => ({ + label: clampText(feature.name, 100), + description: clampText(feature.description, 100), + value: feature.key, + })) + ) + ); - const buttonRow = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setLabel('Cancel') - .setStyle(ButtonStyle.Danger) - .setCustomId('baseline-cancel') - ); + const buttonRow = + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setLabel('Cancel') + .setStyle(ButtonStyle.Danger) + .setCustomId('baseline-cancel') + ); return { selectRow, buttonRow }; }, diff --git a/src/features/docs/index.ts b/src/features/docs/index.ts index c5f08bf..6c4f851 100644 --- a/src/features/docs/index.ts +++ b/src/features/docs/index.ts @@ -12,27 +12,28 @@ const docProviders = { baseline: baselineProvider, }; -export const docsCommands = Object.entries(docProviders).map(([providerKey, providerConfig]) => - createSlashCommand({ - data: { - name: providerKey, - description: providerConfig.commandDescription, - options: [ - { - name: 'query', - type: ApplicationCommandOptionType.String, - description: 'The search query', - required: true, - }, - { - name: 'user', - type: ApplicationCommandOptionType.User, - description: 'The user to mention in the response', - required: false, - }, - ], - }, - execute: async (interaction) => - executeDocCommand(providerConfig as ProviderConfig, interaction), - }) +export const docsCommands = Object.entries(docProviders).map( + ([providerKey, providerConfig]) => + createSlashCommand({ + data: { + name: providerKey, + description: providerConfig.commandDescription, + options: [ + { + name: 'query', + type: ApplicationCommandOptionType.String, + description: 'The search query', + required: true, + }, + { + name: 'user', + type: ApplicationCommandOptionType.User, + description: 'The user to mention in the response', + required: false, + }, + ], + }, + execute: async (interaction) => + executeDocCommand(providerConfig as ProviderConfig, interaction), + }) ); diff --git a/src/features/docs/mdn.ts b/src/features/docs/mdn.ts index e43d9a9..77f24bd 100644 --- a/src/features/docs/mdn.ts +++ b/src/features/docs/mdn.ts @@ -40,32 +40,40 @@ export const mdnProvider: ProviderConfig = { ); if (!response.ok) { - throw new Error(`Error fetching MDN data: ${response.status} ${response.statusText}`); + throw new Error( + `Error fetching MDN data: ${response.status} ${response.statusText}` + ); } const data = (await response.json()) as SearchResult; return data.documents; }, - createCollection: (items) => new Collection(items.map((item) => [item.slug, item])), + createCollection: (items) => + new Collection(items.map((item) => [item.slug, item])), createActionBuilders: (data) => { - const selectRow = new ActionRowBuilder().addComponents( - new StringSelectMenuBuilder() - .setCustomId('mdn-select') - .setPlaceholder('Select 1 to 5 results') - .setMinValues(1) - .setMaxValues(Math.min(5, data.size)) - .addOptions( - ...data.map((doc) => ({ - label: clampText(doc.title, 100), - description: clampText(doc.summary, 100), - value: doc.slug, - })) - ) - ); + const selectRow = + new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('mdn-select') + .setPlaceholder('Select 1 to 5 results') + .setMinValues(1) + .setMaxValues(Math.min(5, data.size)) + .addOptions( + ...data.map((doc) => ({ + label: clampText(doc.title, 100), + description: clampText(doc.summary, 100), + value: doc.slug, + })) + ) + ); - const buttonRow = new ActionRowBuilder().addComponents( - new ButtonBuilder().setLabel('Cancel').setStyle(ButtonStyle.Danger).setCustomId('mdn-cancel') - ); + const buttonRow = + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setLabel('Cancel') + .setStyle(ButtonStyle.Danger) + .setCustomId('mdn-cancel') + ); return { selectRow, buttonRow }; }, diff --git a/src/features/docs/npm.ts b/src/features/docs/npm.ts index 4428ac5..989dde9 100644 --- a/src/features/docs/npm.ts +++ b/src/features/docs/npm.ts @@ -42,36 +42,47 @@ export const npmProvider: ProviderConfig = { ...baseConfig, getFilteredData: async (query: string) => { const response = await fetch( - getSearchUrl(`https://registry.npmjs.org/-/v1/search?text=${SEARCH_TERM}&size=10`, query) + getSearchUrl( + `https://registry.npmjs.org/-/v1/search?text=${SEARCH_TERM}&size=10`, + query + ) ); if (!response.ok) { - throw new Error(`Error fetching NPM data: ${response.status} ${response.statusText}`); + throw new Error( + `Error fetching NPM data: ${response.status} ${response.statusText}` + ); } const data = (await response.json()) as SearchResult; return data.objects.map((obj) => obj.package); }, - createCollection: (items) => new Collection(items.map((item) => [item.name, item])), + createCollection: (items) => + new Collection(items.map((item) => [item.name, item])), createActionBuilders: (data) => { - const selectRow = new ActionRowBuilder().addComponents( - new StringSelectMenuBuilder() - .setCustomId('npm-select') - .setPlaceholder('Select a package') - .setMinValues(1) - .setMaxValues(1) - .addOptions( - ...data.map((pkg) => - new StringSelectMenuOptionBuilder() - .setLabel(pkg.name) - .setDescription(clampText(pkg.description, 100)) - .setValue(pkg.name) + const selectRow = + new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('npm-select') + .setPlaceholder('Select a package') + .setMinValues(1) + .setMaxValues(1) + .addOptions( + ...data.map((pkg) => + new StringSelectMenuOptionBuilder() + .setLabel(pkg.name) + .setDescription(clampText(pkg.description, 100)) + .setValue(pkg.name) + ) ) - ) - ); - const buttonRow = new ActionRowBuilder().addComponents( - new ButtonBuilder().setCustomId('npm-cancel').setLabel('Cancel').setStyle(ButtonStyle.Danger) - ); + ); + const buttonRow = + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('npm-cancel') + .setLabel('Cancel') + .setStyle(ButtonStyle.Danger) + ); return { selectRow, buttonRow }; }, createResultEmbeds: (selectedItems) => @@ -90,7 +101,9 @@ export const npmProvider: ProviderConfig = { value, })) ) - .setFooter({ text: `Version ${pkg.version} | License: ${pkg.license ?? 'N/A'}` }) + .setFooter({ + text: `Version ${pkg.version} | License: ${pkg.license ?? 'N/A'}`, + }) .setTimestamp() ), getDisplayTitle: (item) => item.name, diff --git a/src/features/docs/types.ts b/src/features/docs/types.ts index bccbb24..8dd2544 100644 --- a/src/features/docs/types.ts +++ b/src/features/docs/types.ts @@ -23,7 +23,9 @@ export type ProviderConfig = { createActionBuilders: (data: Collection) => ActionBuilders; // Create result embeds to show after selection - createResultEmbeds: (data: Collection) => EmbedBuilder | EmbedBuilder[]; + createResultEmbeds: ( + data: Collection + ) => EmbedBuilder | EmbedBuilder[]; // Get display title for an item getDisplayTitle: (item: Item) => string; diff --git a/src/features/docs/utils.test.ts b/src/features/docs/utils.test.ts index 20c7dc2..a2439da 100644 --- a/src/features/docs/utils.test.ts +++ b/src/features/docs/utils.test.ts @@ -1,59 +1,59 @@ -import assert from "node:assert"; -import { describe, it } from "node:test"; -import { features } from "web-features"; -import { getBaselineFeatures, NON_BASELINE_FEATURES } from "./utils.js"; +import assert from 'node:assert'; +import { describe, it } from 'node:test'; +import { features } from 'web-features'; +import { getBaselineFeatures, NON_BASELINE_FEATURES } from './utils.js'; -void describe("getBaselineFeatures", () => { - void it("should return the correct baseline features when provided with non-features key array", () => { - const originalFeatures = { - "feature-1": { - name: "Feature 1", - description: "Description 1", - status: { support: {}, baseline: "full" }, - }, - "feature-2": { - name: "Feature 2", - description: "Description 2", - status: { support: {}, baseline: "partial" }, - }, - "numeric-seperators": { - name: "Numeric Separators", - description: "Description NS", - status: { support: {}, baseline: "none" }, - }, - "single-color-gradient": { - name: "Single Color Gradient", - description: "Description SCG", - status: { support: {}, baseline: "none" }, - }, - }; +void describe('getBaselineFeatures', () => { + void it('should return the correct baseline features when provided with non-features key array', () => { + const originalFeatures = { + 'feature-1': { + name: 'Feature 1', + description: 'Description 1', + status: { support: {}, baseline: 'full' }, + }, + 'feature-2': { + name: 'Feature 2', + description: 'Description 2', + status: { support: {}, baseline: 'partial' }, + }, + 'numeric-seperators': { + name: 'Numeric Separators', + description: 'Description NS', + status: { support: {}, baseline: 'none' }, + }, + 'single-color-gradient': { + name: 'Single Color Gradient', + description: 'Description SCG', + status: { support: {}, baseline: 'none' }, + }, + }; - const expectedFeatures = { - "feature-1": { - name: "Feature 1", - description: "Description 1", - status: { support: {}, baseline: "full" }, - }, - "feature-2": { - name: "Feature 2", - description: "Description 2", - status: { support: {}, baseline: "partial" }, - }, - }; + const expectedFeatures = { + 'feature-1': { + name: 'Feature 1', + description: 'Description 1', + status: { support: {}, baseline: 'full' }, + }, + 'feature-2': { + name: 'Feature 2', + description: 'Description 2', + status: { support: {}, baseline: 'partial' }, + }, + }; - const result = getBaselineFeatures(originalFeatures, [ - "numeric-seperators", - "single-color-gradient", - ]); + const result = getBaselineFeatures(originalFeatures, [ + 'numeric-seperators', + 'single-color-gradient', + ]); - assert.deepStrictEqual(result, expectedFeatures); - }); + assert.deepStrictEqual(result, expectedFeatures); + }); - void it("NON_BASELINE_FEATURES should contain the correct features to exclude", () => { - const expectedNonBaselineFeatures = Object.entries(features) - .filter(([, feature]) => feature.kind !== "feature") - .map(([key]) => key); + void it('NON_BASELINE_FEATURES should contain the correct features to exclude', () => { + const expectedNonBaselineFeatures = Object.entries(features) + .filter(([, feature]) => feature.kind !== 'feature') + .map(([key]) => key); - assert.deepStrictEqual(NON_BASELINE_FEATURES, expectedNonBaselineFeatures); - }); + assert.deepStrictEqual(NON_BASELINE_FEATURES, expectedNonBaselineFeatures); + }); }); diff --git a/src/features/docs/utils.ts b/src/features/docs/utils.ts index 9c0ca45..e728055 100644 --- a/src/features/docs/utils.ts +++ b/src/features/docs/utils.ts @@ -1,118 +1,118 @@ -import { type CommandInteraction, MessageFlags } from "discord.js"; -import { logToChannel } from "@/util/channel-logging.js"; -import type { FeatureData } from "./baseline.js"; -import type { ProviderConfig } from "./types.js"; +import { type CommandInteraction, MessageFlags } from 'discord.js'; +import { logToChannel } from '@/util/channel-logging.js'; +import type { FeatureData } from './baseline.js'; +import type { ProviderConfig } from './types.js'; -export const SEARCH_TERM = "%SEARCH%"; -export const TERM = "%TERM%"; +export const SEARCH_TERM = '%SEARCH%'; +export const TERM = '%TERM%'; // Utility functions export const getSearchUrl = (url: string, search: string) => { - return url.replace(SEARCH_TERM, encodeURIComponent(search)); + return url.replace(SEARCH_TERM, encodeURIComponent(search)); }; export const createBaseConfig = (options: { - color: number; - icon: string; - commandDescription: string; - directUrl?: string; + color: number; + icon: string; + commandDescription: string; + directUrl?: string; }): Pick< - ProviderConfig, - "color" | "icon" | "commandDescription" | "directUrl" + ProviderConfig, + 'color' | 'icon' | 'commandDescription' | 'directUrl' > => options; export const executeDocCommand = async ( - config: ProviderConfig, - interaction: CommandInteraction, + config: ProviderConfig, + interaction: CommandInteraction ): Promise => { - if (interaction.replied || interaction.deferred) { - return; - } - - try { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - } catch (error) { - console.error(`deferReply FAILED:`, error); - return; - } - - if (!interaction.isChatInputCommand()) { - return; - } - - const query = interaction.options.getString("query", true).trim(); - - try { - const items = await config.getFilteredData(query); - - if (items.length === 0) { - await interaction.editReply({ - content: `No results found for "${query}"`, - }); - return; - } - - const collection = config.createCollection(items); - const { selectRow, buttonRow } = config.createActionBuilders(collection); - - const choiceInteraction = await interaction.editReply({ - content: config.getSelectionMessage(query), - components: [selectRow, buttonRow], - }); - - const collector = choiceInteraction.createMessageComponentCollector({ - filter: (componentInteraction) => - componentInteraction.user.id === interaction.user.id, - }); - - collector.once("collect", async (componentInteraction) => { - if (componentInteraction.isStringSelectMenu()) { - const selectedSet = new Set(componentInteraction.values); - const selectedItems = collection.filter((_, key) => - selectedSet.has(key), - ); - const selectedTitles = selectedItems.map(config.getDisplayTitle); - - await interaction.editReply({ - content: config.getDisplayMessage(selectedTitles), - components: [], - }); - - const embeds = config.createResultEmbeds(selectedItems); - - void logToChannel({ - channel: interaction.channel, - content: { - type: "embed", - embed: embeds, - content: interaction.options.getUser("user") - ? `<@${interaction.options.getUser("user")?.id}>` - : undefined, - }, - }); - } else if (componentInteraction.isButton()) { - console.log("Documentation command cancelled by user."); - await interaction.deleteReply(); - } - }); - } catch (error) { - console.error(`executeDocCommand FAILED:`, error); - await interaction.editReply({ content: `Error: ${String(error)}` }); - } + if (interaction.replied || interaction.deferred) { + return; + } + + try { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + } catch (error) { + console.error(`deferReply FAILED:`, error); + return; + } + + if (!interaction.isChatInputCommand()) { + return; + } + + const query = interaction.options.getString('query', true).trim(); + + try { + const items = await config.getFilteredData(query); + + if (items.length === 0) { + await interaction.editReply({ + content: `No results found for "${query}"`, + }); + return; + } + + const collection = config.createCollection(items); + const { selectRow, buttonRow } = config.createActionBuilders(collection); + + const choiceInteraction = await interaction.editReply({ + content: config.getSelectionMessage(query), + components: [selectRow, buttonRow], + }); + + const collector = choiceInteraction.createMessageComponentCollector({ + filter: (componentInteraction) => + componentInteraction.user.id === interaction.user.id, + }); + + collector.once('collect', async (componentInteraction) => { + if (componentInteraction.isStringSelectMenu()) { + const selectedSet = new Set(componentInteraction.values); + const selectedItems = collection.filter((_, key) => + selectedSet.has(key) + ); + const selectedTitles = selectedItems.map(config.getDisplayTitle); + + await interaction.editReply({ + content: config.getDisplayMessage(selectedTitles), + components: [], + }); + + const embeds = config.createResultEmbeds(selectedItems); + + void logToChannel({ + channel: interaction.channel, + content: { + type: 'embed', + embed: embeds, + content: interaction.options.getUser('user') + ? `<@${interaction.options.getUser('user')?.id}>` + : undefined, + }, + }); + } else if (componentInteraction.isButton()) { + console.log('Documentation command cancelled by user.'); + await interaction.deleteReply(); + } + }); + } catch (error) { + console.error(`executeDocCommand FAILED:`, error); + await interaction.editReply({ content: `Error: ${String(error)}` }); + } }; export const NON_BASELINE_FEATURES = [ - "numeric-seperators", - "open-closed", - "single-color-gradients", + 'numeric-seperators', + 'open-closed', + 'single-color-gradients', ]; export const getBaselineFeatures = ( - originalFeatures: Record, - nonFeatureKeys: string[] = NON_BASELINE_FEATURES, + originalFeatures: Record, + nonFeatureKeys: string[] = NON_BASELINE_FEATURES ): Record => { - const features = { ...originalFeatures }; - for (const nonFeature of nonFeatureKeys) { - delete features[nonFeature]; - } - return features as Record; + const features = { ...originalFeatures }; + for (const nonFeature of nonFeatureKeys) { + delete features[nonFeature]; + } + return features as Record; }; diff --git a/src/features/guides/index.ts b/src/features/guides/index.ts index 299d461..11f4a8d 100644 --- a/src/features/guides/index.ts +++ b/src/features/guides/index.ts @@ -70,7 +70,10 @@ export const guidesCommand = createSlashCommand({ }, }); - await interaction.reply({ content: 'Guide sent!', flags: MessageFlags.Ephemeral }); + await interaction.reply({ + content: 'Guide sent!', + flags: MessageFlags.Ephemeral, + }); return; }, diff --git a/src/features/has-var/index.ts b/src/features/has-var/index.ts index e2a177c..cbe5f32 100644 --- a/src/features/has-var/index.ts +++ b/src/features/has-var/index.ts @@ -8,12 +8,19 @@ import { rateLimit } from '../../util/rate-limit.js'; const { canRun, reset } = rateLimit(5 * MINUTE); const hasVarDeclaration = (code: string, language: string): boolean => { - if (!['js', 'javascript', 'ts', 'typescript'].includes(language.toLowerCase())) { + if ( + !['js', 'javascript', 'ts', 'typescript'].includes(language.toLowerCase()) + ) { return false; } try { - const sourceFile = ts.createSourceFile('temp.ts', code, ts.ScriptTarget.Latest, true); + const sourceFile = ts.createSourceFile( + 'temp.ts', + code, + ts.ScriptTarget.Latest, + true + ); let foundVar = false; @@ -21,7 +28,8 @@ const hasVarDeclaration = (code: string, language: string): boolean => { if (ts.isVariableStatement(node)) { const declList = node.declarationList; const isVar = - (declList.flags & ts.NodeFlags.Let) === 0 && (declList.flags & ts.NodeFlags.Const) === 0; + (declList.flags & ts.NodeFlags.Let) === 0 && + (declList.flags & ts.NodeFlags.Const) === 0; if (isVar) { foundVar = true; } diff --git a/src/features/interaction-create/index.ts b/src/features/interaction-create/index.ts index 0e7ed42..69ade02 100644 --- a/src/features/interaction-create/index.ts +++ b/src/features/interaction-create/index.ts @@ -12,7 +12,9 @@ export const interactionCreateEvent = createEvent( }, async (interaction) => { if (!interaction.guildId || !isAllowedServer(interaction.guildId)) { - console.log(`⚠️ Command blocked from unauthorized server: ${interaction.guildId}`); + console.log( + `⚠️ Command blocked from unauthorized server: ${interaction.guildId}` + ); if (interaction.isRepliable()) { await interaction.reply({ content: '❌ This bot is not authorized to operate in this server.', @@ -22,19 +24,25 @@ export const interactionCreateEvent = createEvent( return; } if (interaction.isButton()) { - console.log(`Received button interaction with custom ID: ${interaction.customId}`); + console.log( + `Received button interaction with custom ID: ${interaction.customId}` + ); await handleButtonInteraction(interaction); return; } if (interaction.isModalSubmit()) { - console.log(`Received modal submit interaction with custom ID: ${interaction.customId}`); + console.log( + `Received modal submit interaction with custom ID: ${interaction.customId}` + ); await handleModalInteraction(interaction); return; } if (interaction.isAutocomplete()) { - console.log(`Received autocomplete interaction with custom ID: ${interaction.commandName}`); + console.log( + `Received autocomplete interaction with custom ID: ${interaction.commandName}` + ); await handleAutoCompleteInteraction(interaction); return; } @@ -55,17 +63,23 @@ export const interactionCreateEvent = createEvent( if (command.commandType === ApplicationCommandType.ChatInput) { if (!interaction.isChatInputCommand()) { - throw new Error('Command type mismatch: expected ChatInput interaction'); + throw new Error( + 'Command type mismatch: expected ChatInput interaction' + ); } await command.execute(interaction); } else if (command.commandType === ApplicationCommandType.Message) { if (!interaction.isMessageContextMenuCommand()) { - throw new Error('Command type mismatch: expected Message context menu interaction'); + throw new Error( + 'Command type mismatch: expected Message context menu interaction' + ); } await command.execute(interaction); } else if (command.commandType === ApplicationCommandType.User) { if (!interaction.isUserContextMenuCommand()) { - throw new Error('Command type mismatch: expected User context menu interaction'); + throw new Error( + 'Command type mismatch: expected User context menu interaction' + ); } await command.execute(interaction); } diff --git a/src/features/just-ask/index.test.ts b/src/features/just-ask/index.test.ts index a5387d2..57a09aa 100644 --- a/src/features/just-ask/index.test.ts +++ b/src/features/just-ask/index.test.ts @@ -1,22 +1,22 @@ -import assert from "node:assert"; -import { describe, it } from "node:test"; -import { isAskingToAsk } from "./just-ask.js"; +import assert from 'node:assert'; +import { describe, it } from 'node:test'; +import { isAskingToAsk } from './just-ask.js'; -void describe("justAsk Regex", () => { - const testCases = [ - { input: "Anyone knows js?", expected: true }, - { input: "Somebody can help with Python?", expected: true }, - { input: "No one has experience with js?", expected: true }, - { input: "Everybody tried React?", expected: true }, - { input: "People familiar with Kubernetes?", expected: true }, +void describe('justAsk Regex', () => { + const testCases = [ + { input: 'Anyone knows js?', expected: true }, + { input: 'Somebody can help with Python?', expected: true }, + { input: 'No one has experience with js?', expected: true }, + { input: 'Everybody tried React?', expected: true }, + { input: 'People familiar with Kubernetes?', expected: true }, - { input: "I know js well.", expected: false }, - { input: "This is a question without the pattern.", expected: false }, - ]; - testCases.forEach(({ input, expected }) => { - void it(`should return ${expected} for input: "${input}"`, () => { - const result = isAskingToAsk(input); - assert.strictEqual(result, expected); - }); - }); + { input: 'I know js well.', expected: false }, + { input: 'This is a question without the pattern.', expected: false }, + ]; + testCases.forEach(({ input, expected }) => { + void it(`should return ${expected} for input: "${input}"`, () => { + const result = isAskingToAsk(input); + assert.strictEqual(result, expected); + }); + }); }); diff --git a/src/features/just-ask/just-ask.ts b/src/features/just-ask/just-ask.ts index e36b3c6..fb6070a 100644 --- a/src/features/just-ask/just-ask.ts +++ b/src/features/just-ask/just-ask.ts @@ -48,7 +48,8 @@ const shouldCheck = (message: Message) => { // check roles/permissions if ( message.member !== null && - (message.member.roles.highest.comparePositionTo(config.roleIds.repel) >= 0 || + (message.member.roles.highest.comparePositionTo(config.roleIds.repel) >= + 0 || message.member.permissions.has(PermissionFlagsBits.ModerateMembers)) ) { return false; diff --git a/src/features/moderation/cache-messages.ts b/src/features/moderation/cache-messages.ts index 90dbd69..3fd1072 100644 --- a/src/features/moderation/cache-messages.ts +++ b/src/features/moderation/cache-messages.ts @@ -1,4 +1,8 @@ -import { ApplicationCommandOptionType, PermissionFlagsBits, PermissionsBitField } from 'discord.js'; +import { + ApplicationCommandOptionType, + PermissionFlagsBits, + PermissionsBitField, +} from 'discord.js'; import { createSlashCommand } from '../../common/commands/create-commands.js'; import { fetchAndCachePublicChannelsMessages } from '../../util/cache.js'; @@ -25,15 +29,21 @@ export default createSlashCommand({ return; } - if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageMessages)) { - await interaction.editReply('You do not have permission to use this command.'); + if ( + !interaction.memberPermissions?.has(PermissionFlagsBits.ManageMessages) + ) { + await interaction.editReply( + 'You do not have permission to use this command.' + ); return; } const guild = interaction.guild; const force = interaction.options.getBoolean('force') ?? false; - await interaction.editReply('Caching messages in all public text channels...'); + await interaction.editReply( + 'Caching messages in all public text channels...' + ); const { cachedChannels, totalChannels, failedChannels } = await fetchAndCachePublicChannelsMessages(guild, force); diff --git a/src/features/moderation/repel.ts b/src/features/moderation/repel.ts index da3e1c4..b6826c8 100644 --- a/src/features/moderation/repel.ts +++ b/src/features/moderation/repel.ts @@ -1,574 +1,574 @@ import { - ApplicationCommandOptionType, - ChannelType, - type ChatInputCommandInteraction, - Colors, - ContainerBuilder, - EmbedBuilder, - type GuildMember, - type Message, - MessageFlags, - PermissionFlagsBits, - type Role, - type TextChannel, - TextDisplayBuilder, - type User, -} from "discord.js"; -import { isUserInServer } from "@/util/member.js"; -import { createSlashCommand } from "../../common/commands/create-commands.js"; -import { HOUR, MINUTE, timeToString } from "../../constants/time.js"; -import { config } from "../../env.js"; -import { buildCommandString } from "../../util/build-command-string.js"; -import { getPublicChannels } from "../../util/channel.js"; -import { logToChannel } from "../../util/channel-logging.js"; + ApplicationCommandOptionType, + ChannelType, + type ChatInputCommandInteraction, + Colors, + ContainerBuilder, + EmbedBuilder, + type GuildMember, + type Message, + MessageFlags, + PermissionFlagsBits, + type Role, + type TextChannel, + TextDisplayBuilder, + type User, +} from 'discord.js'; +import { isUserInServer } from '@/util/member.js'; +import { createSlashCommand } from '../../common/commands/create-commands.js'; +import { HOUR, MINUTE, timeToString } from '../../constants/time.js'; +import { config } from '../../env.js'; +import { buildCommandString } from '../../util/build-command-string.js'; +import { getPublicChannels } from '../../util/channel.js'; +import { logToChannel } from '../../util/channel-logging.js'; const DEFAULT_LOOK_BACK_MS = 10 * MINUTE; const DEFAULT_TIMEOUT_DURATION_MS = 1 * HOUR; const isUserTimedOut = (target: GuildMember) => { - return target.communicationDisabledUntilTimestamp - ? target.communicationDisabledUntilTimestamp > Date.now() - : false; + return target.communicationDisabledUntilTimestamp + ? target.communicationDisabledUntilTimestamp > Date.now() + : false; }; const checkCanRepel = ({ - commandUser, - repelRole, + commandUser, + repelRole, }: { - commandUser: GuildMember; - repelRole: Role; + commandUser: GuildMember; + repelRole: Role; }): { - ok: boolean; - message?: string; + ok: boolean; + message?: string; } => { - const hasPermission = - commandUser.permissions.has(PermissionFlagsBits.ModerateMembers) || - commandUser.roles.cache.has(repelRole.id); - if (!hasPermission) { - return { - ok: false, - message: "You do not have permission to use this command.", - }; - } - return { ok: true }; + const hasPermission = + commandUser.permissions.has(PermissionFlagsBits.ModerateMembers) || + commandUser.roles.cache.has(repelRole.id); + if (!hasPermission) { + return { + ok: false, + message: 'You do not have permission to use this command.', + }; + } + return { ok: true }; }; const checkCanRepelTarget = ({ - target, - commandUser, - botMember, + target, + commandUser, + botMember, }: { - target: User | GuildMember; - commandUser: GuildMember; - botMember: GuildMember; + target: User | GuildMember; + commandUser: GuildMember; + botMember: GuildMember; }): { - ok: boolean; - message?: string; + ok: boolean; + message?: string; } => { - if (!isUserInServer(target)) { - return { ok: true }; - } - - if (target.user.bot) { - return { ok: false, message: "You cannot repel a bot." }; - } - - if (target.id === commandUser.id) { - return { ok: false, message: "You cannot repel yourself." }; - } - - const isTargetServerOwner = target.id === target.guild.ownerId; - if (isTargetServerOwner) { - return { ok: false, message: "You cannot repel the server owner." }; - } - - if (target.roles.highest.position >= commandUser.roles.highest.position) { - return { - ok: false, - message: "You cannot repel this user due to role hierarchy.", - }; - } - - if (target.roles.highest.position >= botMember.roles.highest.position) { - return { - ok: false, - message: "I cannot repel this user due to role hierarchy.", - }; - } - - return { ok: true }; + if (!isUserInServer(target)) { + return { ok: true }; + } + + if (target.user.bot) { + return { ok: false, message: 'You cannot repel a bot.' }; + } + + if (target.id === commandUser.id) { + return { ok: false, message: 'You cannot repel yourself.' }; + } + + const isTargetServerOwner = target.id === target.guild.ownerId; + if (isTargetServerOwner) { + return { ok: false, message: 'You cannot repel the server owner.' }; + } + + if (target.roles.highest.position >= commandUser.roles.highest.position) { + return { + ok: false, + message: 'You cannot repel this user due to role hierarchy.', + }; + } + + if (target.roles.highest.position >= botMember.roles.highest.position) { + return { + ok: false, + message: 'I cannot repel this user due to role hierarchy.', + }; + } + + return { ok: true }; }; const sendReasonToTarget = async ({ - target, - reason, - guildName, - timeoutDuration, + target, + reason, + guildName, + timeoutDuration, }: { - target: GuildMember | User; - reason: string; - guildName: string; - timeoutDuration?: number; + target: GuildMember | User; + reason: string; + guildName: string; + timeoutDuration?: number; }): Promise => { - const parts: string[] = []; - parts.push(`You have been repelled from **${guildName}**.`); - if (timeoutDuration && timeoutDuration > 0) { - parts.push(`You have been timed out for ${timeToString(timeoutDuration)}.`); - } - parts.push(`**Reason:** ${reason}`); - const messageContent = parts.join("\n"); - - const containerComponent = new ContainerBuilder() - .setAccentColor(Colors.DarkRed) - .addTextDisplayComponents( - new TextDisplayBuilder().setContent(messageContent), - ); - try { - await target.send({ - flags: MessageFlags.IsComponentsV2, - components: [containerComponent], - }); - return true; - } catch { - return false; - } + const parts: string[] = []; + parts.push(`You have been repelled from **${guildName}**.`); + if (timeoutDuration && timeoutDuration > 0) { + parts.push(`You have been timed out for ${timeToString(timeoutDuration)}.`); + } + parts.push(`**Reason:** ${reason}`); + const messageContent = parts.join('\n'); + + const containerComponent = new ContainerBuilder() + .setAccentColor(Colors.DarkRed) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent(messageContent) + ); + try { + await target.send({ + flags: MessageFlags.IsComponentsV2, + components: [containerComponent], + }); + return true; + } catch { + return false; + } }; const getTargetFromInteraction = async ( - interaction: ChatInputCommandInteraction, + interaction: ChatInputCommandInteraction ): Promise => { - const targetFromOption = interaction.options.getUser( - RepelOptions.TARGET, - true, - ); - let target: User | GuildMember | null = null; - if (!interaction.inGuild() || interaction.guild === null) { - return targetFromOption; - } - try { - target = await interaction.guild.members.fetch(targetFromOption.id); - } catch (error) { - console.error("Error fetching target as guild member:", error); - target = targetFromOption; - } - return target; + const targetFromOption = interaction.options.getUser( + RepelOptions.TARGET, + true + ); + let target: User | GuildMember | null = null; + if (!interaction.inGuild() || interaction.guild === null) { + return targetFromOption; + } + try { + target = await interaction.guild.members.fetch(targetFromOption.id); + } catch (error) { + console.error('Error fetching target as guild member:', error); + target = targetFromOption; + } + return target; }; const handleTimeout = async ({ - target, - durationInMilliseconds, + target, + durationInMilliseconds, }: { - target: GuildMember | User; - durationInMilliseconds: number; + target: GuildMember | User; + durationInMilliseconds: number; }): Promise => { - if ( - durationInMilliseconds === 0 || - !isUserInServer(target) || - isUserTimedOut(target) - ) { - return 0; - } - try { - await target.timeout(durationInMilliseconds, "Repel command executed"); - return durationInMilliseconds; - } catch (error) { - console.error("Error applying timeout to user:", error); - return 0; - } + if ( + durationInMilliseconds === 0 || + !isUserInServer(target) || + isUserTimedOut(target) + ) { + return 0; + } + try { + await target.timeout(durationInMilliseconds, 'Repel command executed'); + return durationInMilliseconds; + } catch (error) { + console.error('Error applying timeout to user:', error); + return 0; + } }; const getTextChannels = (interaction: ChatInputCommandInteraction) => { - if (!interaction.inGuild() || !interaction.guild) { - console.error("Interaction is not in a guild"); - return []; - } - const channels = Array.from(getPublicChannels(interaction.guild).values()); - return [ - interaction.channel as TextChannel, - ...channels.filter((channel) => channel.id !== interaction.channelId), - ]; + if (!interaction.inGuild() || !interaction.guild) { + console.error('Interaction is not in a guild'); + return []; + } + const channels = Array.from(getPublicChannels(interaction.guild).values()); + return [ + interaction.channel as TextChannel, + ...channels.filter((channel) => channel.id !== interaction.channelId), + ]; }; const handleDeleteMessages = async ({ - target, - channels, - lookBack, + target, + channels, + lookBack, }: { - target: GuildMember | User; - channels: TextChannel[]; - lookBack: number; + target: GuildMember | User; + channels: TextChannel[]; + lookBack: number; }) => { - let deleted = 0; - const failedChannels: string[] = []; - - // Collect all target messages from all channels and find the latest timestamp - const channelMessages: Array<{ - channel: TextChannel; - targetMessages: Map; - }> = []; - let latestMessageTimestamp = 0; - - for (const channel of channels) { - const targetMessages = new Map(); - - for (const [id, message] of channel.messages.cache) { - if ( - message.author && - message.author.id === target.id && - message.deletable - ) { - targetMessages.set(id, message); - latestMessageTimestamp = Math.max( - latestMessageTimestamp, - message.createdTimestamp, - ); - } - } - - if (targetMessages.size > 0) { - channelMessages.push({ channel, targetMessages }); - } - } - - // If no messages found from the target user, return early - if (latestMessageTimestamp === 0) { - return { deleted: 0, failedChannels: [] }; - } - - // Delete messages within the lookback window from the latest message - await Promise.allSettled( - channelMessages.map(async ({ channel, targetMessages }) => { - try { - const messagesToDelete: string[] = []; - - for (const [id, message] of targetMessages) { - if (latestMessageTimestamp - message.createdTimestamp < lookBack) { - messagesToDelete.push(id); - } - } - - if (messagesToDelete.length === 0) { - return; - } - await channel.bulkDelete(messagesToDelete, true); - deleted += messagesToDelete.length; - } catch (error) { - console.error( - `Error deleting messages in channel ${channel.name}:`, - error, - ); - failedChannels.push(channel.id); - throw error; - } - }), - ); - if (failedChannels.length > 0) { - console.error( - `Failed to delete messages in ${failedChannels.length} channel(s).`, - ); - } - return { deleted, failedChannels }; + let deleted = 0; + const failedChannels: string[] = []; + + // Collect all target messages from all channels and find the latest timestamp + const channelMessages: Array<{ + channel: TextChannel; + targetMessages: Map; + }> = []; + let latestMessageTimestamp = 0; + + for (const channel of channels) { + const targetMessages = new Map(); + + for (const [id, message] of channel.messages.cache) { + if ( + message.author && + message.author.id === target.id && + message.deletable + ) { + targetMessages.set(id, message); + latestMessageTimestamp = Math.max( + latestMessageTimestamp, + message.createdTimestamp + ); + } + } + + if (targetMessages.size > 0) { + channelMessages.push({ channel, targetMessages }); + } + } + + // If no messages found from the target user, return early + if (latestMessageTimestamp === 0) { + return { deleted: 0, failedChannels: [] }; + } + + // Delete messages within the lookback window from the latest message + await Promise.allSettled( + channelMessages.map(async ({ channel, targetMessages }) => { + try { + const messagesToDelete: string[] = []; + + for (const [id, message] of targetMessages) { + if (latestMessageTimestamp - message.createdTimestamp < lookBack) { + messagesToDelete.push(id); + } + } + + if (messagesToDelete.length === 0) { + return; + } + await channel.bulkDelete(messagesToDelete, true); + deleted += messagesToDelete.length; + } catch (error) { + console.error( + `Error deleting messages in channel ${channel.name}:`, + error + ); + failedChannels.push(channel.id); + throw error; + } + }) + ); + if (failedChannels.length > 0) { + console.error( + `Failed to delete messages in ${failedChannels.length} channel(s).` + ); + } + return { deleted, failedChannels }; }; const logRepelAction = async ({ - interaction, - member, - target, - duration, - deleteCount, - reason, - failedChannels, - dm, + interaction, + member, + target, + duration, + deleteCount, + reason, + failedChannels, + dm, }: { - interaction: ChatInputCommandInteraction; - member: GuildMember; - target: User | GuildMember; - reason: string; - duration?: number; - deleteCount: number; - failedChannels: string[]; - dm: { - sent: boolean; - shouldSend: boolean; - }; + interaction: ChatInputCommandInteraction; + member: GuildMember; + target: User | GuildMember; + reason: string; + duration?: number; + deleteCount: number; + failedChannels: string[]; + dm: { + sent: boolean; + shouldSend: boolean; + }; }) => { - const channelInfo = - interaction.channel?.type === ChannelType.GuildVoice - ? `**${interaction.channel.name}** voice chat` - : `<#${interaction.channelId}>`; - const memberAuthor = { - name: member.user.tag, - iconURL: member.user.displayAvatarURL(), - }; - const targetAuthor = { - name: isUserInServer(target) - ? `${target.user.tag} | Repel | ${target.user.username}` - : `${target.tag} | Repel | ${target.username}`, - iconURL: isUserInServer(target) - ? target.user.displayAvatarURL() - : target.displayAvatarURL(), - }; - - const commandEmbed = new EmbedBuilder() - .setAuthor(memberAuthor) - .setDescription( - `Used \`repel\` command in ${channelInfo}.\n${buildCommandString(interaction)}`, - ) - .setColor("Green") - .setTimestamp(); - const resultEmbed = new EmbedBuilder() - .setAuthor(targetAuthor) - .addFields( - { - name: "Target", - value: `<@${target.id}>`, - inline: true, - }, - { - name: "Moderator", - value: `<@${member.id}>`, - inline: true, - }, - { - name: "Reason", - value: reason, - inline: true, - }, - { - name: "Deleted Messages", - value: deleteCount.toString(), - inline: true, - }, - { - name: "Timeout Duration", - value: duration ? `${timeToString(duration)}` : "No Timeout", - inline: true, - }, - ) - .setColor("Orange") - .setTimestamp(); - - if (dm.shouldSend) { - resultEmbed.addFields({ - name: "DM Sent", - value: dm.sent ? "Yes" : "No", - inline: true, - }); - } - - const failedChannelsEmbed = - failedChannels.length > 0 - ? new EmbedBuilder() - .setTitle("Failed to delete messages in the following channels:") - .setDescription(failedChannels.map((id) => `<#${id}>`).join("\n")) - .setColor("Red") - .setTimestamp() - : null; - - const modMessage = - interaction.options.getString(RepelOptions.MESSAGE_FOR_MODS) ?? false; - const mentionText = modMessage - ? `${config.roleIds.moderators.map((id) => `<@&${id}>`).join(" ")} - ${modMessage}` - : undefined; - const channel = interaction.client.channels.cache.get( - config.channelIds.repelLogs, - ) as TextChannel; - - const embed = - failedChannelsEmbed !== null - ? [commandEmbed, resultEmbed, failedChannelsEmbed] - : [commandEmbed, resultEmbed]; - - await logToChannel({ - channel, - content: { - type: "embed", - embed, - content: mentionText, - }, - }); + const channelInfo = + interaction.channel?.type === ChannelType.GuildVoice + ? `**${interaction.channel.name}** voice chat` + : `<#${interaction.channelId}>`; + const memberAuthor = { + name: member.user.tag, + iconURL: member.user.displayAvatarURL(), + }; + const targetAuthor = { + name: isUserInServer(target) + ? `${target.user.tag} | Repel | ${target.user.username}` + : `${target.tag} | Repel | ${target.username}`, + iconURL: isUserInServer(target) + ? target.user.displayAvatarURL() + : target.displayAvatarURL(), + }; + + const commandEmbed = new EmbedBuilder() + .setAuthor(memberAuthor) + .setDescription( + `Used \`repel\` command in ${channelInfo}.\n${buildCommandString(interaction)}` + ) + .setColor('Green') + .setTimestamp(); + const resultEmbed = new EmbedBuilder() + .setAuthor(targetAuthor) + .addFields( + { + name: 'Target', + value: `<@${target.id}>`, + inline: true, + }, + { + name: 'Moderator', + value: `<@${member.id}>`, + inline: true, + }, + { + name: 'Reason', + value: reason, + inline: true, + }, + { + name: 'Deleted Messages', + value: deleteCount.toString(), + inline: true, + }, + { + name: 'Timeout Duration', + value: duration ? `${timeToString(duration)}` : 'No Timeout', + inline: true, + } + ) + .setColor('Orange') + .setTimestamp(); + + if (dm.shouldSend) { + resultEmbed.addFields({ + name: 'DM Sent', + value: dm.sent ? 'Yes' : 'No', + inline: true, + }); + } + + const failedChannelsEmbed = + failedChannels.length > 0 + ? new EmbedBuilder() + .setTitle('Failed to delete messages in the following channels:') + .setDescription(failedChannels.map((id) => `<#${id}>`).join('\n')) + .setColor('Red') + .setTimestamp() + : null; + + const modMessage = + interaction.options.getString(RepelOptions.MESSAGE_FOR_MODS) ?? false; + const mentionText = modMessage + ? `${config.roleIds.moderators.map((id) => `<@&${id}>`).join(' ')} - ${modMessage}` + : undefined; + const channel = interaction.client.channels.cache.get( + config.channelIds.repelLogs + ) as TextChannel; + + const embed = + failedChannelsEmbed !== null + ? [commandEmbed, resultEmbed, failedChannelsEmbed] + : [commandEmbed, resultEmbed]; + + await logToChannel({ + channel, + content: { + type: 'embed', + embed, + content: mentionText, + }, + }); }; const RepelOptions = { - TARGET: "target", - REASON: "reason", - LOOK_BACK: "look_back", - TIMEOUT_DURATION: "timeout_duration", - MESSAGE_FOR_MODS: "message_for_mods", - DM_USER: "dm_user", + TARGET: 'target', + REASON: 'reason', + LOOK_BACK: 'look_back', + TIMEOUT_DURATION: 'timeout_duration', + MESSAGE_FOR_MODS: 'message_for_mods', + DM_USER: 'dm_user', } as const; export const repelCommand = createSlashCommand({ - data: { - name: "repel", - description: "Remove recent messages and timeout a user", - options: [ - { - name: RepelOptions.TARGET, - required: true, - type: ApplicationCommandOptionType.User, - description: "The user to timeout and remove messages from", - }, - { - name: RepelOptions.REASON, - required: true, - type: ApplicationCommandOptionType.String, - description: "Reason for the timeout and message removal", - }, - { - name: RepelOptions.LOOK_BACK, - required: false, - type: ApplicationCommandOptionType.Integer, - description: `How far back to look for messages to delete in minutes (default: ${timeToString( - DEFAULT_LOOK_BACK_MS, - )})`, - choices: [ - { - name: "10 minutes (Default)", - value: 10 * MINUTE, - }, - { - name: "30 minutes", - value: 30 * MINUTE, - }, - { - name: "1 hour", - value: 1 * HOUR, - }, - { - name: "3 hours", - value: 3 * HOUR, - }, - ], - }, - { - name: RepelOptions.TIMEOUT_DURATION, - required: false, - type: ApplicationCommandOptionType.Integer, - description: `Duration of the timeout in hours (default: ${timeToString(DEFAULT_TIMEOUT_DURATION_MS)})`, - min_value: 0, - max_value: 24, - }, - { - name: RepelOptions.MESSAGE_FOR_MODS, - required: false, - type: ApplicationCommandOptionType.String, - description: "Optional message to include for moderators in the log", - }, - { - name: RepelOptions.DM_USER, - required: false, - type: ApplicationCommandOptionType.Boolean, - description: - "Whether to DM the user about the repel action (Defaults to true)", - }, - ], - }, - execute: async (interaction) => { - if (!interaction.inGuild() || !interaction.guild) { - await interaction.reply({ - content: "This command can only be used in a server.", - flags: MessageFlags.Ephemeral, - }); - return; - } - - if (!interaction.isChatInputCommand()) { - return; - } - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - - const repelRole = interaction.guild.roles.cache.get(config.roleIds.repel); - if (!repelRole) { - await interaction.editReply({ - content: - "❌ Repel role is not configured correctly. Please contact an administrator.", - }); - return; - } - - const target = await getTargetFromInteraction(interaction); - - const commandUser = interaction.member as GuildMember; - - const canRepel = checkCanRepel({ commandUser, repelRole }); - if (!canRepel.ok) { - await interaction.editReply({ content: `❌ ${canRepel.message}` }); - return; - } - - const botMember = interaction.guild.members.me; - if (!botMember) { - await interaction.editReply({ - content: "❌ Unable to verify my permissions in the server.", - }); - return; - } - - const canRepelTarget = checkCanRepelTarget({ - target, - commandUser, - botMember, - }); - if (!canRepelTarget.ok) { - await interaction.editReply({ content: `❌ ${canRepelTarget.message}` }); - return; - } - - try { - const reason = interaction.options.getString(RepelOptions.REASON, true); - const lookBack = interaction.options.getInteger(RepelOptions.LOOK_BACK); - const timeoutHours = interaction.options.getInteger( - RepelOptions.TIMEOUT_DURATION, - ); - - const timeout = await handleTimeout({ - target: target, - durationInMilliseconds: - timeoutHours !== null - ? timeoutHours * HOUR - : DEFAULT_TIMEOUT_DURATION_MS, - }); - - const channels = getTextChannels(interaction); - - const { deleted, failedChannels } = await handleDeleteMessages({ - channels, - target: target, - lookBack: lookBack ?? DEFAULT_LOOK_BACK_MS, - }); - - const shouldDMUser = - interaction.options.getBoolean(RepelOptions.DM_USER) ?? true; - let dmSent = false; - if (shouldDMUser) { - dmSent = await sendReasonToTarget({ - target, - reason, - guildName: interaction.guild.name, - timeoutDuration: timeout, - }); - } - - void logRepelAction({ - interaction, - member: commandUser, - target, - reason, - duration: timeout, - deleteCount: deleted, - failedChannels, - dm: { - sent: dmSent, - shouldSend: shouldDMUser, - }, - }); - - await interaction.editReply({ - content: `Deleted ${deleted} message(s).`, - }); - } catch (error) { - console.error("Error executing repel command:", error); - } - }, + data: { + name: 'repel', + description: 'Remove recent messages and timeout a user', + options: [ + { + name: RepelOptions.TARGET, + required: true, + type: ApplicationCommandOptionType.User, + description: 'The user to timeout and remove messages from', + }, + { + name: RepelOptions.REASON, + required: true, + type: ApplicationCommandOptionType.String, + description: 'Reason for the timeout and message removal', + }, + { + name: RepelOptions.LOOK_BACK, + required: false, + type: ApplicationCommandOptionType.Integer, + description: `How far back to look for messages to delete in minutes (default: ${timeToString( + DEFAULT_LOOK_BACK_MS + )})`, + choices: [ + { + name: '10 minutes (Default)', + value: 10 * MINUTE, + }, + { + name: '30 minutes', + value: 30 * MINUTE, + }, + { + name: '1 hour', + value: 1 * HOUR, + }, + { + name: '3 hours', + value: 3 * HOUR, + }, + ], + }, + { + name: RepelOptions.TIMEOUT_DURATION, + required: false, + type: ApplicationCommandOptionType.Integer, + description: `Duration of the timeout in hours (default: ${timeToString(DEFAULT_TIMEOUT_DURATION_MS)})`, + min_value: 0, + max_value: 24, + }, + { + name: RepelOptions.MESSAGE_FOR_MODS, + required: false, + type: ApplicationCommandOptionType.String, + description: 'Optional message to include for moderators in the log', + }, + { + name: RepelOptions.DM_USER, + required: false, + type: ApplicationCommandOptionType.Boolean, + description: + 'Whether to DM the user about the repel action (Defaults to true)', + }, + ], + }, + execute: async (interaction) => { + if (!interaction.inGuild() || !interaction.guild) { + await interaction.reply({ + content: 'This command can only be used in a server.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (!interaction.isChatInputCommand()) { + return; + } + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const repelRole = interaction.guild.roles.cache.get(config.roleIds.repel); + if (!repelRole) { + await interaction.editReply({ + content: + '❌ Repel role is not configured correctly. Please contact an administrator.', + }); + return; + } + + const target = await getTargetFromInteraction(interaction); + + const commandUser = interaction.member as GuildMember; + + const canRepel = checkCanRepel({ commandUser, repelRole }); + if (!canRepel.ok) { + await interaction.editReply({ content: `❌ ${canRepel.message}` }); + return; + } + + const botMember = interaction.guild.members.me; + if (!botMember) { + await interaction.editReply({ + content: '❌ Unable to verify my permissions in the server.', + }); + return; + } + + const canRepelTarget = checkCanRepelTarget({ + target, + commandUser, + botMember, + }); + if (!canRepelTarget.ok) { + await interaction.editReply({ content: `❌ ${canRepelTarget.message}` }); + return; + } + + try { + const reason = interaction.options.getString(RepelOptions.REASON, true); + const lookBack = interaction.options.getInteger(RepelOptions.LOOK_BACK); + const timeoutHours = interaction.options.getInteger( + RepelOptions.TIMEOUT_DURATION + ); + + const timeout = await handleTimeout({ + target: target, + durationInMilliseconds: + timeoutHours !== null + ? timeoutHours * HOUR + : DEFAULT_TIMEOUT_DURATION_MS, + }); + + const channels = getTextChannels(interaction); + + const { deleted, failedChannels } = await handleDeleteMessages({ + channels, + target: target, + lookBack: lookBack ?? DEFAULT_LOOK_BACK_MS, + }); + + const shouldDMUser = + interaction.options.getBoolean(RepelOptions.DM_USER) ?? true; + let dmSent = false; + if (shouldDMUser) { + dmSent = await sendReasonToTarget({ + target, + reason, + guildName: interaction.guild.name, + timeoutDuration: timeout, + }); + } + + void logRepelAction({ + interaction, + member: commandUser, + target, + reason, + duration: timeout, + deleteCount: deleted, + failedChannels, + dm: { + sent: dmSent, + shouldSend: shouldDMUser, + }, + }); + + await interaction.editReply({ + content: `Deleted ${deleted} message(s).`, + }); + } catch (error) { + console.error('Error executing repel command:', error); + } + }, }); diff --git a/src/features/onboarding/component.ts b/src/features/onboarding/component.ts index 4245246..af2cc5c 100644 --- a/src/features/onboarding/component.ts +++ b/src/features/onboarding/component.ts @@ -9,7 +9,8 @@ import { // Exported at the bottom after all child components are added const containerComponent = new ContainerBuilder(); -const actionRowComponent = new ActionRowBuilder(); +const actionRowComponent = + new ActionRowBuilder(); const buttonComponent = new ButtonBuilder() .setCustomId('onboarding_add_role') diff --git a/src/features/onboarding/index.ts b/src/features/onboarding/index.ts index d810d16..5170693 100644 --- a/src/features/onboarding/index.ts +++ b/src/features/onboarding/index.ts @@ -27,8 +27,10 @@ export const onboardingCommand = createSlashCommand({ }); return; } - // @ts-expect-error: This command isn't used and shouldn't affect anything, onboarding channels are TBD - const onboardingChannel = guild.channels.cache.get(config.onboarding.channelId); + const onboardingChannel = guild.channels.cache.get( + // @ts-expect-error: This command isn't used and shouldn't affect anything, onboarding channels are TBD + config.onboarding.channelId + ); if (!onboardingChannel || !onboardingChannel.isSendable()) { await interaction.reply({ content: @@ -48,7 +50,9 @@ export const onboardingCommand = createSlashCommand({ collector.on('collect', async (componentInteraction) => { if (componentInteraction.customId === 'onboarding_add_role') { try { - const member = await guild.members.fetch(componentInteraction.user.id); + const member = await guild.members.fetch( + componentInteraction.user.id + ); await addRoleToUser(member, onboardingRole); await componentInteraction.reply({ diff --git a/src/features/ping/index.ts b/src/features/ping/index.ts index 8bc3576..58e7b8f 100644 --- a/src/features/ping/index.ts +++ b/src/features/ping/index.ts @@ -6,7 +6,10 @@ export const pingCommand = createSlashCommand({ description: 'Replies with Pong!', }, execute: async (interaction) => { - const sent = await interaction.reply({ content: 'pinging...', withResponse: true }); + const sent = await interaction.reply({ + content: 'pinging...', + withResponse: true, + }); const message = sent.resource?.message; if (!message) { await interaction.editReply('Failed to send ping message.'); @@ -14,6 +17,8 @@ export const pingCommand = createSlashCommand({ } const roundTrip = message.createdTimestamp - interaction.createdTimestamp; const apiLatency = interaction.client.ws.ping; - await interaction.editReply(`latency: ${apiLatency}ms | Roundtrip: ${roundTrip}ms`); + await interaction.editReply( + `latency: ${apiLatency}ms | Roundtrip: ${roundTrip}ms` + ); }, }); diff --git a/src/features/public-guides/index.ts b/src/features/public-guides/index.ts index 7b2b596..f56607c 100644 --- a/src/features/public-guides/index.ts +++ b/src/features/public-guides/index.ts @@ -6,7 +6,10 @@ import { } from 'discord.js'; import { createSlashCommand } from '@/common/commands/create-commands.js'; import { config } from '@/env.js'; -import { initializeGuidesChannel, syncGuidesToChannel } from '@/util/post-guides.js'; +import { + initializeGuidesChannel, + syncGuidesToChannel, +} from '@/util/post-guides.js'; export const publicGuidesCommand = createSlashCommand({ data: { @@ -42,7 +45,9 @@ export const publicGuidesCommand = createSlashCommand({ } else { await syncGuidesToChannel(interaction.client, channelId); } - await interaction.editReply('✅ Guides have been synchronized successfully.'); + await interaction.editReply( + '✅ Guides have been synchronized successfully.' + ); } catch (error) { console.error('Error synchronizing guides:', error); await interaction.editReply( diff --git a/src/features/ready/index.ts b/src/features/ready/index.ts index f0ae255..a95c651 100644 --- a/src/features/ready/index.ts +++ b/src/features/ready/index.ts @@ -28,7 +28,9 @@ export const readyEvent = createEvent( // Sync guides to channel try { - console.log(`🔄 Starting guide sync to channel ${config.channelIds.guides}...`); + console.log( + `🔄 Starting guide sync to channel ${config.channelIds.guides}...` + ); await syncGuidesToChannel(client, config.channelIds.guides); } catch (error) { if (error && typeof error === 'object' && 'code' in error) { diff --git a/src/features/showcase/create-showcase.ts b/src/features/showcase/create-showcase.ts index bd61d8f..43182e9 100644 --- a/src/features/showcase/create-showcase.ts +++ b/src/features/showcase/create-showcase.ts @@ -26,12 +26,17 @@ import { deleteShowcase } from './delete-showcase.js'; import { editShowcaseInteraction } from './edit-showcase.js'; import { buildShowcaseModal, getShowcaseLogChannel } from './util.js'; -export const showModal = async (interaction: ButtonInteraction | ChatInputCommandInteraction) => { +export const showModal = async ( + interaction: ButtonInteraction | ChatInputCommandInteraction +) => { try { - const channel = interaction.guild?.channels.cache.get(config.channelIds.showcase); + const channel = interaction.guild?.channels.cache.get( + config.channelIds.showcase + ); if (channel === undefined || channel.type !== ChannelType.GuildForum) { await interaction.reply({ - content: 'Showcase channel is not properly configured. Please contact an administrator.', + content: + 'Showcase channel is not properly configured. Please contact an administrator.', flags: MessageFlags.Ephemeral, }); return; @@ -47,7 +52,8 @@ export const showModal = async (interaction: ButtonInteraction | ChatInputComman } catch (error) { console.error('Error showing showcase modal:', error); await interaction.reply({ - content: 'There was an error showing the showcase modal. Please try again later.', + content: + 'There was an error showing the showcase modal. Please try again later.', flags: MessageFlags.Ephemeral, }); } @@ -74,14 +80,18 @@ const modalHandler: ModalSubmitInteraction = { await interaction.deferReply({ flags: MessageFlags.Ephemeral }); const projectName = interaction.fields.getTextInputValue('projectName'); const projectLink = interaction.fields.getTextInputValue('projectLink'); - const projectDescription = interaction.fields.getTextInputValue('projectDescription'); + const projectDescription = + interaction.fields.getTextInputValue('projectDescription'); const projectTags = interaction.fields.getStringSelectValues('projectTags'); const projectMedia = interaction.fields.getUploadedFiles('projectMedia'); - const channel = interaction.guild?.channels.cache.get(config.channelIds.showcase); + const channel = interaction.guild?.channels.cache.get( + config.channelIds.showcase + ); if (channel === undefined || channel.type !== ChannelType.GuildForum) { await interaction.editReply({ - content: 'Showcase channel is not properly configured. Please contact an administrator.', + content: + 'Showcase channel is not properly configured. Please contact an administrator.', }); return; } @@ -142,7 +152,11 @@ const modalHandler: ModalSubmitInteraction = { .setTitle('Showcase Created') .addFields( { name: 'Project Name', value: projectName || '—', inline: false }, - { name: 'Author', value: `<@${interaction.user.id}>`, inline: true }, + { + name: 'Author', + value: `<@${interaction.user.id}>`, + inline: true, + }, { name: 'Thread', value: thread.url ?? '—', inline: true } ) .setColor(Colors.Green) @@ -163,7 +177,8 @@ const modalHandler: ModalSubmitInteraction = { } catch (error) { console.error('Error creating showcase thread:', error); await interaction.editReply({ - content: 'There was an error showcasing your project. Please try again later.', + content: + 'There was an error showcasing your project. Please try again later.', }); } }, diff --git a/src/features/showcase/delete-showcase.ts b/src/features/showcase/delete-showcase.ts index 3bd7b85..9377f99 100644 --- a/src/features/showcase/delete-showcase.ts +++ b/src/features/showcase/delete-showcase.ts @@ -19,7 +19,10 @@ export const deleteShowcase: ButtonSubmitInteraction = { return; } - if (interactionUser.id !== ownerId && !isUserModerator(interaction.member, interaction)) { + if ( + interactionUser.id !== ownerId && + !isUserModerator(interaction.member, interaction) + ) { await interaction.reply({ content: '❌ You do not have permission to delete this showcase.', flags: MessageFlags.Ephemeral, @@ -29,7 +32,9 @@ export const deleteShowcase: ButtonSubmitInteraction = { try { const forumPost = - interaction.channel?.type === ChannelType.PublicThread ? interaction.channel : null; + interaction.channel?.type === ChannelType.PublicThread + ? interaction.channel + : null; if (forumPost === null) { await interaction.reply({ content: '❌ This command can only be used in a forum post.', @@ -67,7 +72,10 @@ export const deleteShowcase: ButtonSubmitInteraction = { `**Project Name:** ${projectName}\n**Deleted By:** <@${interactionUser.id}>` ) .setColor(Colors.Red) - .setAuthor({ name: interactionUser.tag, iconURL: interactionUser.displayAvatarURL() }), + .setAuthor({ + name: interactionUser.tag, + iconURL: interactionUser.displayAvatarURL(), + }), }, }); } catch (error) { diff --git a/src/features/showcase/edit-showcase.ts b/src/features/showcase/edit-showcase.ts index 20c9f7f..a9443a7 100644 --- a/src/features/showcase/edit-showcase.ts +++ b/src/features/showcase/edit-showcase.ts @@ -1,4 +1,10 @@ -import { ChannelType, Colors, EmbedBuilder, escapeCodeBlock, MessageFlags } from 'discord.js'; +import { + ChannelType, + Colors, + EmbedBuilder, + escapeCodeBlock, + MessageFlags, +} from 'discord.js'; import type { ButtonSubmitInteraction } from '@/common/interactions/button-interaction.js'; import { type ModalSubmitInteraction, @@ -31,7 +37,10 @@ export const editShowcaseInteraction: ButtonSubmitInteraction = { return; } - if (interactionUser.id !== ownerId && !isUserModerator(interaction.member, interaction)) { + if ( + interactionUser.id !== ownerId && + !isUserModerator(interaction.member, interaction) + ) { await interaction.reply({ content: '❌ You do not have permission to edit this showcase.', flags: MessageFlags.Ephemeral, @@ -40,7 +49,9 @@ export const editShowcaseInteraction: ButtonSubmitInteraction = { } const forumPost = - interaction.channel?.type === ChannelType.PublicThread ? interaction.channel : null; + interaction.channel?.type === ChannelType.PublicThread + ? interaction.channel + : null; if (forumPost === null) { await interaction.reply({ content: '❌ This command can only be used in a forum post.', @@ -60,7 +71,9 @@ export const editShowcaseInteraction: ButtonSubmitInteraction = { } const parentChannel = - forumPost.parent?.type === ChannelType.GuildForum ? forumPost.parent : null; + forumPost.parent?.type === ChannelType.GuildForum + ? forumPost.parent + : null; if (parentChannel === null) { await interaction.reply({ content: '❌ Could not find the parent forum channel.', @@ -69,7 +82,9 @@ export const editShowcaseInteraction: ButtonSubmitInteraction = { return; } - const { projectName, link, description } = parseShowcaseMessage(message.content); + const { projectName, link, description } = parseShowcaseMessage( + message.content + ); const appliedTags = forumPost.appliedTags; const tags = parentChannel.availableTags; @@ -108,7 +123,10 @@ const modalHandler: ModalSubmitInteraction = { return; } - if (interactionUser.id !== ownerId && !isUserModerator(interaction.member, interaction)) { + if ( + interactionUser.id !== ownerId && + !isUserModerator(interaction.member, interaction) + ) { await interaction.reply({ content: '❌ You do not have permission to edit this showcase.', flags: MessageFlags.Ephemeral, @@ -117,7 +135,9 @@ const modalHandler: ModalSubmitInteraction = { } const forumPost = - interaction.channel?.type === ChannelType.PublicThread ? interaction.channel : null; + interaction.channel?.type === ChannelType.PublicThread + ? interaction.channel + : null; if (forumPost === null) { await interaction.reply({ content: '❌ This command can only be used in a forum post.', @@ -142,7 +162,9 @@ const modalHandler: ModalSubmitInteraction = { }); const threadParent = - forumPost.parent?.type === ChannelType.GuildForum ? forumPost.parent : null; + forumPost.parent?.type === ChannelType.GuildForum + ? forumPost.parent + : null; if (threadParent === null) { await interaction.reply({ content: '❌ Could not find the parent forum channel.', @@ -159,10 +181,14 @@ const modalHandler: ModalSubmitInteraction = { const prevTagIds = forumPost.appliedTags ?? []; const prevAttachmentsCount = getAttachmentsCount(message.attachments); - const newProjectName = interaction.fields.getTextInputValue('projectName'); - const newProjectLink = interaction.fields.getTextInputValue('projectLink'); - const newProjectDescription = interaction.fields.getTextInputValue('projectDescription'); - const newProjectTags = interaction.fields.getStringSelectValues('projectTags'); + const newProjectName = + interaction.fields.getTextInputValue('projectName'); + const newProjectLink = + interaction.fields.getTextInputValue('projectLink'); + const newProjectDescription = + interaction.fields.getTextInputValue('projectDescription'); + const newProjectTags = + interaction.fields.getStringSelectValues('projectTags'); const projectMedia = interaction.fields.getUploadedFiles('projectMedia'); let newAttachmentsCount = prevAttachmentsCount; @@ -174,10 +200,18 @@ const modalHandler: ModalSubmitInteraction = { const changes: ShowcaseEditChange[] = []; if (prevTitle !== newProjectName) { - changes.push({ field: 'title', before: prevTitle, after: newProjectName }); + changes.push({ + field: 'title', + before: prevTitle, + after: newProjectName, + }); } if (prevLink !== newProjectLink) { - changes.push({ field: 'link', before: prevLink, after: newProjectLink }); + changes.push({ + field: 'link', + before: prevLink, + after: newProjectLink, + }); } if (prevDescription !== newProjectDescription) { changes.push({ @@ -196,8 +230,13 @@ const modalHandler: ModalSubmitInteraction = { if (!tagsEqual()) { changes.push({ field: 'tags', - before: resolveTagNames(prevTagIds, threadParent.availableTags).join(', '), - after: resolveTagNames(newProjectTags, threadParent.availableTags).join(', '), + before: resolveTagNames(prevTagIds, threadParent.availableTags).join( + ', ' + ), + after: resolveTagNames( + newProjectTags, + threadParent.availableTags + ).join(', '), }); } if (newAttachmentsCount > 0) { @@ -242,7 +281,10 @@ const modalHandler: ModalSubmitInteraction = { acc.push( wrapInDiffBlock( clampText( - toDiscordDiff(escapeCodeBlock(change.before), escapeCodeBlock(change.after)), + toDiscordDiff( + escapeCodeBlock(change.before), + escapeCodeBlock(change.after) + ), 3500 ) ) diff --git a/src/features/showcase/send-pinned-message.ts b/src/features/showcase/send-pinned-message.ts index f303eec..1097246 100644 --- a/src/features/showcase/send-pinned-message.ts +++ b/src/features/showcase/send-pinned-message.ts @@ -1,96 +1,96 @@ import { - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, - ChannelFlags, - type MessageActionRowComponentBuilder, - MessageFlags, - PermissionFlagsBits, - PermissionsBitField, -} from "discord.js"; -import { createSlashCommand } from "@/common/commands/create-commands.js"; -import { config } from "@/env.js"; + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ChannelFlags, + type MessageActionRowComponentBuilder, + MessageFlags, + PermissionFlagsBits, + PermissionsBitField, +} from 'discord.js'; +import { createSlashCommand } from '@/common/commands/create-commands.js'; +import { config } from '@/env.js'; export const sendShowcasePinnedMessage = createSlashCommand({ - data: { - name: "send-showcase-pinned-message", - description: "Send showcase pinned message", - default_member_permissions: new PermissionsBitField( - PermissionFlagsBits.Administrator | - PermissionFlagsBits.ManageGuild | - PermissionFlagsBits.ModerateMembers, - ).toJSON(), - }, - execute: async (interaction) => { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - const showcaseChannel = interaction.guild?.channels.cache.get( - config.channelIds.showcase, - ); - if (showcaseChannel === undefined || !showcaseChannel.isThreadOnly()) { - await interaction.editReply({ - content: "Showcase channel not found or is not a forum channel.", - }); - return; - } + data: { + name: 'send-showcase-pinned-message', + description: 'Send showcase pinned message', + default_member_permissions: new PermissionsBitField( + PermissionFlagsBits.Administrator | + PermissionFlagsBits.ManageGuild | + PermissionFlagsBits.ModerateMembers + ).toJSON(), + }, + execute: async (interaction) => { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + const showcaseChannel = interaction.guild?.channels.cache.get( + config.channelIds.showcase + ); + if (showcaseChannel === undefined || !showcaseChannel.isThreadOnly()) { + await interaction.editReply({ + content: 'Showcase channel not found or is not a forum channel.', + }); + return; + } - const activeThreads = await showcaseChannel.threads.fetchActive(); - const pinnedThread = activeThreads.threads.find((thread) => - thread.flags.has(ChannelFlags.Pinned), - ); + const activeThreads = await showcaseChannel.threads.fetchActive(); + const pinnedThread = activeThreads.threads.find((thread) => + thread.flags.has(ChannelFlags.Pinned) + ); - const guideLines = [ - "Welcome to the Showcase channel! Please read the rules and guidelines before posting your content. Make sure to follow the format and include all necessary information. Happy sharing!", - "", - "1. Be respectful and follow the community guidelines.", - "2. Use the correct format for your posts (e.g., title, description, tags).", - "3. Include relevant media (images, videos) to showcase your content effectively.", - "4. Avoid spamming or posting irrelevant content.", - "5. Engage with other members and provide constructive feedback.", - "6. If you have any questions, feel free to reach out to the moderators.", - "7. Abusing the channel or violating the rules may result in removal of your post or further action by the moderators.", - "8. Have fun and enjoy sharing your projects with the community!", - "", - "Press the button below to create a new post and share your project with the community!", - ].join("\n"); + const guideLines = [ + 'Welcome to the Showcase channel! Please read the rules and guidelines before posting your content. Make sure to follow the format and include all necessary information. Happy sharing!', + '', + '1. Be respectful and follow the community guidelines.', + '2. Use the correct format for your posts (e.g., title, description, tags).', + '3. Include relevant media (images, videos) to showcase your content effectively.', + '4. Avoid spamming or posting irrelevant content.', + '5. Engage with other members and provide constructive feedback.', + '6. If you have any questions, feel free to reach out to the moderators.', + '7. Abusing the channel or violating the rules may result in removal of your post or further action by the moderators.', + '8. Have fun and enjoy sharing your projects with the community!', + '', + 'Press the button below to create a new post and share your project with the community!', + ].join('\n'); - const actionRow = - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId("create_showcase") - .setLabel("Create Showcase Post") - .setStyle(ButtonStyle.Primary), - ); + const actionRow = + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('create_showcase') + .setLabel('Create Showcase Post') + .setStyle(ButtonStyle.Primary) + ); - if (pinnedThread) { - if (!pinnedThread.locked) { - await pinnedThread.setLocked(true); - } - const message = await pinnedThread.fetchStarterMessage(); - if (message !== null) { - await message.edit({ - content: guideLines, - components: [actionRow], - }); - } + if (pinnedThread) { + if (!pinnedThread.locked) { + await pinnedThread.setLocked(true); + } + const message = await pinnedThread.fetchStarterMessage(); + if (message !== null) { + await message.edit({ + content: guideLines, + components: [actionRow], + }); + } - await interaction.editReply({ - content: "Showcase pinned message updated successfully.", - }); - return; - } + await interaction.editReply({ + content: 'Showcase pinned message updated successfully.', + }); + return; + } - const thread = await showcaseChannel.threads.create({ - name: "Rules and Guidelines", - message: { - content: guideLines, - components: [actionRow], - }, - }); - void thread.pin(); - void thread.setLocked(true); + const thread = await showcaseChannel.threads.create({ + name: 'Rules and Guidelines', + message: { + content: guideLines, + components: [actionRow], + }, + }); + void thread.pin(); + void thread.setLocked(true); - await interaction.editReply({ - content: "Showcase pinned message sent successfully.", - }); - }, + await interaction.editReply({ + content: 'Showcase pinned message sent successfully.', + }); + }, }); diff --git a/src/features/showcase/types.ts b/src/features/showcase/types.ts index ce9e529..af52a39 100644 --- a/src/features/showcase/types.ts +++ b/src/features/showcase/types.ts @@ -7,7 +7,12 @@ export type ShowcaseCreateLog = { tagIds?: string[]; }; -export type ShowcaseEditChangeField = 'title' | 'link' | 'description' | 'tags' | 'attachments'; +export type ShowcaseEditChangeField = + | 'title' + | 'link' + | 'description' + | 'tags' + | 'attachments'; export type ShowcaseEditChange = { field: ShowcaseEditChangeField; diff --git a/src/features/showcase/util.ts b/src/features/showcase/util.ts index 3f0511d..52d9d15 100644 --- a/src/features/showcase/util.ts +++ b/src/features/showcase/util.ts @@ -24,7 +24,9 @@ export const parseShowcaseMessage = (content: string): ShowcaseMessageData => { const headerLines = header.split('\n'); const projectName = headerLines[0]?.replace(/^## Project Name:\s*/, '') ?? ''; - const authorLine = headerLines.find((line) => line.startsWith('**Author:** ')); + const authorLine = headerLines.find((line) => + line.startsWith('**Author:** ') + ); const authorId = authorLine?.match(/<@(\d+)>/)?.[1] ?? ''; const linkLine = headerLines.find((line) => line.startsWith('**Link:** ')); const link = linkLine?.replace(/^\*\*Link:\*\*\s*/, '') ?? ''; @@ -115,7 +117,9 @@ export const buildShowcaseModal = ({ ), new LabelBuilder() .setLabel('Media') - .setDescription('Attach images or videos showcasing your project (optional)') + .setDescription( + 'Attach images or videos showcasing your project (optional)' + ) .setFileUploadComponent( new FileUploadBuilder() .setCustomId('projectMedia') @@ -126,7 +130,9 @@ export const buildShowcaseModal = ({ ); }; -export const getAttachmentsCount = (files: Collection): number => { +export const getAttachmentsCount = ( + files: Collection +): number => { return files.size; }; diff --git a/src/features/tips/index.ts b/src/features/tips/index.ts index 4cf8883..47bac17 100644 --- a/src/features/tips/index.ts +++ b/src/features/tips/index.ts @@ -76,7 +76,10 @@ const slashCommand = createSlashCommand({ }, }); - await interaction.reply({ content: 'Tip sent!', flags: MessageFlags.Ephemeral }); + await interaction.reply({ + content: 'Tip sent!', + flags: MessageFlags.Ephemeral, + }); }, }); @@ -91,7 +94,10 @@ const contextMenuCommands = Array.from(subjectChoices).map(([key, value]) => } const message = interaction.targetMessage; - await interaction.reply({ content: 'Fetching tip...', flags: MessageFlags.Ephemeral }); + await interaction.reply({ + content: 'Fetching tip...', + flags: MessageFlags.Ephemeral, + }); await message.reply({ content: value, diff --git a/src/index.ts b/src/index.ts index c174771..6d427af 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,7 +17,9 @@ const client = new Client({ GatewayIntentBits.GuildMessageTyping, ], presence: { - activities: [{ type: ActivityType.Custom, name: 'Helping you out in the community' }], + activities: [ + { type: ActivityType.Custom, name: 'Helping you out in the community' }, + ], status: 'online', }, }); diff --git a/src/sanity.test.ts b/src/sanity.test.ts index 2effd86..a79bde3 100644 --- a/src/sanity.test.ts +++ b/src/sanity.test.ts @@ -1,6 +1,6 @@ -import assert from "node:assert/strict"; -import test from "node:test"; +import assert from 'node:assert/strict'; +import test from 'node:test'; -void test("sanity: 1 + 1 equals 2", () => { - assert.equal(1 + 1, 2); +void test('sanity: 1 + 1 equals 2', () => { + assert.equal(1 + 1, 2); }); diff --git a/src/scripts/deploy-commands.ts b/src/scripts/deploy-commands.ts index fb42264..a08e87f 100644 --- a/src/scripts/deploy-commands.ts +++ b/src/scripts/deploy-commands.ts @@ -1,39 +1,39 @@ import { - REST, - type RESTPutAPIApplicationCommandsResult, - Routes, -} from "discord.js"; -import { commands } from "@/common/commands/index.js"; -import { config } from "@/env.js"; + REST, + type RESTPutAPIApplicationCommandsResult, + Routes, +} from 'discord.js'; +import { commands } from '@/common/commands/index.js'; +import { config } from '@/env.js'; export async function deployCommands() { - const commandData = [...commands.values()].map((command) => command.data); + const commandData = [...commands.values()].map((command) => command.data); - const rest = new REST({ version: "10" }).setToken(config.discord.token); + const rest = new REST({ version: '10' }).setToken(config.discord.token); - const random = Math.floor(Math.random() * 1000); - if (random < 10) { - console.log(`ds ${random}`); - } + const random = Math.floor(Math.random() * 1000); + if (random < 10) { + console.log(`ds ${random}`); + } - try { - const result = (await rest.put( - Routes.applicationGuildCommands( - config.discord.clientId, - config.discord.serverId, - ), - { - body: commandData, - }, - )) as RESTPutAPIApplicationCommandsResult; - console.log( - `✅ Successfully deployed ${result.length} commands to guild ${config.discord.serverId}`, - ); - } catch (error) { - console.error("❌ Error deploying commands:", error); - } + try { + const result = (await rest.put( + Routes.applicationGuildCommands( + config.discord.clientId, + config.discord.serverId + ), + { + body: commandData, + } + )) as RESTPutAPIApplicationCommandsResult; + console.log( + `✅ Successfully deployed ${result.length} commands to guild ${config.discord.serverId}` + ); + } catch (error) { + console.error('❌ Error deploying commands:', error); + } } // If run directly with `node deploy.ts` if (import.meta.url === `file://${process.argv[1]}`) { - void deployCommands(); + void deployCommands(); } diff --git a/src/scripts/sync-guides.ts b/src/scripts/sync-guides.ts index e3108d4..9674326 100644 --- a/src/scripts/sync-guides.ts +++ b/src/scripts/sync-guides.ts @@ -7,7 +7,10 @@ import { Client, GatewayIntentBits } from 'discord.js'; import { config } from '@/env.js'; -import { initializeGuidesChannel, syncGuidesToChannel } from '@/util/post-guides.js'; +import { + initializeGuidesChannel, + syncGuidesToChannel, +} from '@/util/post-guides.js'; async function main() { const args = process.argv.slice(2); @@ -30,7 +33,9 @@ async function main() { console.log(`✅ Logged in as ${client.user?.tag}`); if (shouldInitialize) { - console.log('🚀 Initializing guides channel (will post all guides fresh)...'); + console.log( + '🚀 Initializing guides channel (will post all guides fresh)...' + ); await initializeGuidesChannel(client, config.channelIds.guides); } else { console.log('🔄 Synchronizing guides...'); diff --git a/src/util/add-role-to-user.ts b/src/util/add-role-to-user.ts index e4cdf02..6bdbe1f 100644 --- a/src/util/add-role-to-user.ts +++ b/src/util/add-role-to-user.ts @@ -1,6 +1,9 @@ import type { GuildMember, Role } from 'discord.js'; -export async function addRoleToUser(member: GuildMember, role: Role): Promise { +export async function addRoleToUser( + member: GuildMember, + role: Role +): Promise { const hasRole = member.roles.cache.has(role.id); if (!hasRole) { try { diff --git a/src/util/advent-scheduler.test.ts b/src/util/advent-scheduler.test.ts index 2301e3c..ae201cc 100644 --- a/src/util/advent-scheduler.test.ts +++ b/src/util/advent-scheduler.test.ts @@ -1,50 +1,50 @@ -import assert from "node:assert/strict"; -import { promises as fs } from "node:fs"; -import test from "node:test"; -import { config } from "@/env.js"; +import assert from 'node:assert/strict'; +import { promises as fs } from 'node:fs'; +import test from 'node:test'; +import { config } from '@/env.js'; // Import after setting env var -const { loadTracker, saveTracker } = await import("./advent-scheduler.js"); +const { loadTracker, saveTracker } = await import('./advent-scheduler.js'); async function cleanupTestTracker() { - try { - await fs.unlink(config.adventOfCodeTrackerPath); - } catch { - // File might not exist, that's fine - } + try { + await fs.unlink(config.adventOfCodeTrackerPath); + } catch { + // File might not exist, that's fine + } } -void test("advent scheduler: tracker file operations", async (t) => { - await t.test( - "should create empty tracker if file does not exist", - async () => { - await cleanupTestTracker(); - const tracker = await loadTracker(); - assert.deepEqual(tracker, {}); - }, - ); +void test('advent scheduler: tracker file operations', async (t) => { + await t.test( + 'should create empty tracker if file does not exist', + async () => { + await cleanupTestTracker(); + const tracker = await loadTracker(); + assert.deepEqual(tracker, {}); + } + ); - await t.test("should save and load tracker data correctly", async () => { - const testData = { - "2025": [1, 2, 3], - "2026": [1], - }; - await saveTracker(testData); - const loaded = await loadTracker(); - assert.deepEqual(loaded, testData); - }); + await t.test('should save and load tracker data correctly', async () => { + const testData = { + '2025': [1, 2, 3], + '2026': [1], + }; + await saveTracker(testData); + const loaded = await loadTracker(); + assert.deepEqual(loaded, testData); + }); - await t.test("should track multiple days per year", async () => { - const tracker = { - "2025": [1, 5, 10, 15, 20, 25], - }; - await saveTracker(tracker); - const loaded = await loadTracker(); - assert.equal(loaded["2025"].length, 6); - assert.ok(loaded["2025"].includes(1)); - assert.ok(loaded["2025"].includes(25)); - }); + await t.test('should track multiple days per year', async () => { + const tracker = { + '2025': [1, 5, 10, 15, 20, 25], + }; + await saveTracker(tracker); + const loaded = await loadTracker(); + assert.equal(loaded['2025'].length, 6); + assert.ok(loaded['2025'].includes(1)); + assert.ok(loaded['2025'].includes(25)); + }); - // Cleanup - await cleanupTestTracker(); + // Cleanup + await cleanupTestTracker(); }); diff --git a/src/util/advent-scheduler.ts b/src/util/advent-scheduler.ts index a73ffc3..bbbbd82 100644 --- a/src/util/advent-scheduler.ts +++ b/src/util/advent-scheduler.ts @@ -59,7 +59,9 @@ async function createAdventPost( } if (channel.type !== ChannelType.GuildForum) { - console.error(`❌ Advent of Code channel is not a forum channel. Type: ${channel.type}`); + console.error( + `❌ Advent of Code channel is not a forum channel. Type: ${channel.type}` + ); return false; } @@ -77,12 +79,18 @@ async function createAdventPost( console.log(`✅ Created Advent of Code post: ${title}`); return true; } catch (error) { - console.error(`❌ Failed to create Advent of Code post for day ${day}:`, error); + console.error( + `❌ Failed to create Advent of Code post for day ${day}:`, + error + ); return false; } } -async function checkAndCreateTodaysPost(client: Client, channelId: string): Promise { +async function checkAndCreateTodaysPost( + client: Client, + channelId: string +): Promise { const now = new Date(); const month = now.getUTCMonth(); // 0-indexed, so December is 11 const day = now.getUTCDate(); @@ -113,11 +121,17 @@ async function checkAndCreateTodaysPost(client: Client, channelId: string): Prom * Initialize the Advent of Code scheduler * Runs every day at midnight UTC-5 and checks if we should create a post */ -export function initializeAdventScheduler(client: Client, channelId: string): void { +export function initializeAdventScheduler( + client: Client, + channelId: string +): void { console.log('🎄 Initializing Advent of Code scheduler...'); checkAndCreateTodaysPost(client, channelId).catch((error) => { - console.error('❌ Error checking for Advent of Code post on startup:', error); + console.error( + '❌ Error checking for Advent of Code post on startup:', + error + ); }); // Schedule to run every day at midnight UTC-5 @@ -129,5 +143,7 @@ export function initializeAdventScheduler(client: Client, channelId: string): vo }); }); - console.log('✅ Advent of Code scheduler initialized (runs daily at midnight UTC)'); + console.log( + '✅ Advent of Code scheduler initialized (runs daily at midnight UTC)' + ); } diff --git a/src/util/build-command-string.ts b/src/util/build-command-string.ts index 89a8469..501d97c 100644 --- a/src/util/build-command-string.ts +++ b/src/util/build-command-string.ts @@ -1,6 +1,8 @@ import type { ChatInputCommandInteraction } from 'discord.js'; -export const buildCommandString = (interaction: ChatInputCommandInteraction): string => { +export const buildCommandString = ( + interaction: ChatInputCommandInteraction +): string => { const commandName = interaction.commandName; return `/${commandName} ${interaction.options.data.map((option) => `${option.name}:${option.value}`).join(' ')}`; }; diff --git a/src/util/cache.ts b/src/util/cache.ts index 24e7366..2ea348a 100644 --- a/src/util/cache.ts +++ b/src/util/cache.ts @@ -4,7 +4,10 @@ import { getPublicChannels } from './channel.js'; const PER_CHANNEL_CACHE_LIMIT = 100; export const cachedChannelsMap = new Set(); -export const fetchAndCachePublicChannelsMessages = async (guild: Guild, force = false) => { +export const fetchAndCachePublicChannelsMessages = async ( + guild: Guild, + force = false +) => { let cachedChannels = 0; const failedChannels: string[] = []; @@ -14,7 +17,9 @@ export const fetchAndCachePublicChannelsMessages = async (guild: Guild, force = channels.map(async (channel) => { if (force || !cachedChannelsMap.has(channel.id)) { try { - const messages = await channel.messages.fetch({ limit: PER_CHANNEL_CACHE_LIMIT }); + const messages = await channel.messages.fetch({ + limit: PER_CHANNEL_CACHE_LIMIT, + }); console.log( `Fetched and cached ${messages.size} messages from channel ${channel.name} (${channel.id})` ); diff --git a/src/util/channel-logging.ts b/src/util/channel-logging.ts index 892c532..e6a944a 100644 --- a/src/util/channel-logging.ts +++ b/src/util/channel-logging.ts @@ -30,7 +30,10 @@ export type LoggerOptions = { silent?: boolean; }; -const sendMessage = async (channel: SendableChannels, content: LogContent): Promise => { +const sendMessage = async ( + channel: SendableChannels, + content: LogContent +): Promise => { try { let options: MessageCreateOptions; switch (content.type) { @@ -39,7 +42,9 @@ const sendMessage = async (channel: SendableChannels, content: LogContent): Prom break; case 'embed': options = { - embeds: Array.isArray(content.embed) ? content.embed : [content.embed], + embeds: Array.isArray(content.embed) + ? content.embed + : [content.embed], content: content.content, }; break; @@ -58,7 +63,9 @@ const sendMessage = async (channel: SendableChannels, content: LogContent): Prom } }; -export const logToChannel = async (options: LoggerOptions): Promise => { +export const logToChannel = async ( + options: LoggerOptions +): Promise => { try { const { channel, content } = options; if (!channel || !channel.isSendable()) { @@ -81,7 +88,9 @@ export const logToChannel = async (options: LoggerOptions): Promise => return sendMessage(fallbackChannel, options.content); } else { if (!options.silent) { - console.error('Fallback channel is not a text-based channel or could not be fetched'); + console.error( + 'Fallback channel is not a text-based channel or could not be fetched' + ); } return false; } diff --git a/src/util/channel.ts b/src/util/channel.ts index a55617a..233dfa1 100644 --- a/src/util/channel.ts +++ b/src/util/channel.ts @@ -1,4 +1,9 @@ -import { ChannelType, type Guild, PermissionFlagsBits, type TextChannel } from 'discord.js'; +import { + ChannelType, + type Guild, + PermissionFlagsBits, + type TextChannel, +} from 'discord.js'; export const getPublicChannels = (guild: Guild) => { return guild.channels.cache.filter( diff --git a/src/util/fuzzy-search.ts b/src/util/fuzzy-search.ts index 1ecffb1..4f3e20b 100644 --- a/src/util/fuzzy-search.ts +++ b/src/util/fuzzy-search.ts @@ -1,5 +1,7 @@ export const levenshtein = (a: string, b: string) => { - const dp = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0)); + const dp = Array.from({ length: a.length + 1 }, () => + Array(b.length + 1).fill(0) + ); for (let i = 0; i <= a.length; i++) { dp[i][0] = i; @@ -80,7 +82,10 @@ export function fuzzySearch({ // Apply a large, non-normalized boost for exact or prefix matches if (text === query) { titleMatchScore = EXACT_MATCH_BOOST; // Query "array" matches title "Array" - } else if (titleMatchScore < EXACT_MATCH_BOOST && text.startsWith(query)) { + } else if ( + titleMatchScore < EXACT_MATCH_BOOST && + text.startsWith(query) + ) { // If not a perfect match, check for prefix match titleMatchScore = Math.max(titleMatchScore, PREFIX_BOOST); } else if (titleMatchScore === 0 && text.includes(` ${query} `)) { diff --git a/src/util/member.ts b/src/util/member.ts index 2648104..09463bf 100644 --- a/src/util/member.ts +++ b/src/util/member.ts @@ -7,11 +7,17 @@ import { } from 'discord.js'; import { config } from '@/env.js'; -export const hasAllRoles = (member: GuildMember, ...roles: string[]): boolean => { +export const hasAllRoles = ( + member: GuildMember, + ...roles: string[] +): boolean => { return roles.every((roleId) => member.roles.cache.has(roleId)); }; -export const hasAnyRole = (member: GuildMember, ...roles: string[]): boolean => { +export const hasAnyRole = ( + member: GuildMember, + ...roles: string[] +): boolean => { return roles.some((roleId) => member.roles.cache.has(roleId)); }; @@ -21,7 +27,10 @@ export const isUserInServer = ( return target instanceof GuildMember; }; -export const isUserModerator = (member: GuildMember, interaction: BaseInteraction): boolean => { +export const isUserModerator = ( + member: GuildMember, + interaction: BaseInteraction +): boolean => { return ( hasAnyRole(member, ...config.roleIds.moderators) || member.permissions.has( diff --git a/src/util/post-guides.ts b/src/util/post-guides.ts index 6fc51e4..f833bd1 100644 --- a/src/util/post-guides.ts +++ b/src/util/post-guides.ts @@ -1,7 +1,12 @@ import { createHash } from 'node:crypto'; import { readdir, readFile, writeFile } from 'node:fs/promises'; import path, { join } from 'node:path'; -import { ChannelType, type Client, EmbedBuilder, type TextChannel } from 'discord.js'; +import { + ChannelType, + type Client, + EmbedBuilder, + type TextChannel, +} from 'discord.js'; import { config } from '@/env.js'; import { parseMarkdown } from './markdown.js'; @@ -14,8 +19,11 @@ export type GuideInfo = { frontmatter: Record; }; -const guidesColors = [0xff5733, 0x33ff57, 0x3357ff, 0xff33a8, 0xa833ff, 0x33fff5]; -const getRandomColor = () => guidesColors[Math.floor(Math.random() * guidesColors.length)]; +const guidesColors = [ + 0xff5733, 0x33ff57, 0x3357ff, 0xff33a8, 0xa833ff, 0x33fff5, +]; +const getRandomColor = () => + guidesColors[Math.floor(Math.random() * guidesColors.length)]; const createGuideEmbed = (guide: GuideInfo) => new EmbedBuilder() .setTitle(guide.name) @@ -34,7 +42,8 @@ export type GuideTracker = { const GUIDES_DIR = path.join(process.cwd(), 'assets/guides'); // const TRACKER_FILE = config.guidesTrackerPath ?? 'guides-tracker.json'; -const TRACKER_FILE = config.guidesTrackerPath ?? path.join(process.cwd(), 'guides-tracker.json'); +const TRACKER_FILE = + config.guidesTrackerPath ?? path.join(process.cwd(), 'guides-tracker.json'); const calculateHash = (content: string): string => { return createHash('sha256').update(content, 'utf8').digest('hex'); @@ -65,7 +74,8 @@ const scanGuideFiles = async (): Promise => { const filePath = join(GUIDES_DIR, filename); const content = await readFile(filePath, 'utf8'); - const { frontmatter, content: markdownContent } = await parseMarkdown(content); + const { frontmatter, content: markdownContent } = + await parseMarkdown(content); const hash = calculateHash(content); const name = (frontmatter.name as string) || filename.replace('.md', ''); @@ -82,7 +92,10 @@ const scanGuideFiles = async (): Promise => { return guides; }; -const postGuideToChannel = async (channel: TextChannel, guide: GuideInfo): Promise => { +const postGuideToChannel = async ( + channel: TextChannel, + guide: GuideInfo +): Promise => { const message = await channel.send({ embeds: [createGuideEmbed(guide)], }); @@ -104,7 +117,10 @@ const editGuideMessage = async ( console.log(`📝 Updated guide "${guide.name}" (${guide.filename})`); } catch (error) { - console.error(`Failed to edit message ${messageId} for guide "${guide.name}":`, error); + console.error( + `Failed to edit message ${messageId} for guide "${guide.name}":`, + error + ); throw error; } }; @@ -120,16 +136,26 @@ const deleteGuideMessage = async ( console.log(`🗑️ Deleted guide "${guideName}"`); } catch (error) { - console.error(`Failed to delete message ${messageId} for guide "${guideName}":`, error); + console.error( + `Failed to delete message ${messageId} for guide "${guideName}":`, + error + ); } }; -export const syncGuidesToChannel = async (client: Client, channelId: string): Promise => { +export const syncGuidesToChannel = async ( + client: Client, + channelId: string +): Promise => { console.log('🔄 Starting guide synchronization...'); try { const channel = await client.channels.fetch(channelId); - if (!channel || !channel.isTextBased() || channel.type !== ChannelType.GuildText) { + if ( + !channel || + !channel.isTextBased() || + channel.type !== ChannelType.GuildText + ) { throw new Error(`Channel ${channelId} is not a valid text channel`); } // Load current state @@ -137,16 +163,24 @@ export const syncGuidesToChannel = async (client: Client, channelId: string): Pr const currentGuides = await scanGuideFiles(); // Create maps for easier lookup - const currentGuideMap = new Map(currentGuides.map((guide) => [guide.filename, guide])); + const currentGuideMap = new Map( + currentGuides.map((guide) => [guide.filename, guide]) + ); const trackedFiles = new Set(Object.keys(tracker)); const currentFiles = new Set(currentGuides.map((guide) => guide.filename)); // Find changes - const newFiles = [...currentFiles].filter((file) => !trackedFiles.has(file)); - const deletedFiles = [...trackedFiles].filter((file) => !currentFiles.has(file)); + const newFiles = [...currentFiles].filter( + (file) => !trackedFiles.has(file) + ); + const deletedFiles = [...trackedFiles].filter( + (file) => !currentFiles.has(file) + ); const modifiedFiles = [...currentFiles].filter((file) => { const guide = currentGuideMap.get(file); - return guide && trackedFiles.has(file) && tracker[file].hash !== guide.hash; + return ( + guide && trackedFiles.has(file) && tracker[file].hash !== guide.hash + ); }); console.log( @@ -191,11 +225,14 @@ export const syncGuidesToChannel = async (client: Client, channelId: string): Pr await saveTracker(tracker); - const totalChanges = newFiles.length + modifiedFiles.length + deletedFiles.length; + const totalChanges = + newFiles.length + modifiedFiles.length + deletedFiles.length; if (totalChanges === 0) { console.log('✨ All guides are up to date!'); } else { - console.log(`✅ Guide synchronization complete! Made ${totalChanges} changes.`); + console.log( + `✅ Guide synchronization complete! Made ${totalChanges} changes.` + ); } } catch (error) { console.error('❌ Guide synchronization failed:', error); @@ -203,7 +240,10 @@ export const syncGuidesToChannel = async (client: Client, channelId: string): Pr } }; -export const initializeGuidesChannel = async (client: Client, channelId: string): Promise => { +export const initializeGuidesChannel = async ( + client: Client, + channelId: string +): Promise => { console.log('🚀 Initializing guides channel...'); // Clear existing tracker for fresh start diff --git a/src/util/server-guard.ts b/src/util/server-guard.ts index 20acdc4..6ccd6ce 100644 --- a/src/util/server-guard.ts +++ b/src/util/server-guard.ts @@ -14,7 +14,9 @@ export function isAllowedServer(guildId: string): boolean { */ export async function leaveIfNotAllowedServer(guild: Guild): Promise { if (!isAllowedServer(guild.id)) { - console.log(`⚠️ Bot added to unauthorized server: ${guild.name} (${guild.id})`); + console.log( + `⚠️ Bot added to unauthorized server: ${guild.name} (${guild.id})` + ); console.log(`🚪 Leaving server...`); await guild.leave(); console.log(`✅ Left unauthorized server: ${guild.name}`); From 831580da89052e66641d5745038caa81b7bec85d Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Sat, 20 Jun 2026 00:03:56 +0300 Subject: [PATCH 7/7] chore: update vscode settings.json --- .vscode/settings.json | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 5680403..19fa416 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,11 +1,23 @@ { + "biome.enabled": false, + "prettier.enable": false, + "eslint.enable": false, "editor.codeActionsOnSave": { "source.fixAll.oxc": "always" }, "oxc.fmt.configPath": "oxfmt.config.ts", "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true, - "biome.enabled": false, - "prettier.enable": false, - "eslint.enable": false + "[typescript]": { + "editor.defaultFormatter": "oxc.oxc-vscode", + "editor.formatOnSave": true + }, + "[javascript]": { + "editor.defaultFormatter": "oxc.oxc-vscode", + "editor.formatOnSave": true + }, + "[json]": { + "editor.defaultFormatter": "oxc.oxc-vscode", + "editor.formatOnSave": true + } }