diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 000000000..1a68a6cc8 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "dashboard-preview", + "runtimeExecutable": "npx", + "runtimeArgs": ["serve", "-p", "5173", "src/web/public"], + "port": 5173 + } + ] +} diff --git a/.env.example b/.env.example index 0f426a175..d1ee0ef62 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,13 @@ WEB_HOST=0.0.0.0 PORT_RETRY_ATTEMPTS=5 CORS_ORIGIN=* +# Dashboard (OAuth2 — create an app at discord.com/developers) +# Set DASHBOARD_URL to where your bot is hosted, e.g. http://localhost:3000 +# Add /auth/callback as a Redirect URI in your Discord app +DISCORD_CLIENT_SECRET=your_discord_client_secret_here +DASHBOARD_URL=http://localhost:3000 +SESSION_SECRET=change_this_to_a_long_random_string + # PostgreSQL Configuration (Primary Database) # Railway: use the private POSTGRES_URL / DATABASE_URL variable (includes SSL). # Public proxy logs showing "invalid length of startup packet" or "SSL without ALPN" diff --git a/Dockerfile b/Dockerfile index 576813ed8..ea743d4f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-alpine +FROM node:20 # Create app directory WORKDIR /usr/src/app @@ -8,7 +8,7 @@ WORKDIR /usr/src/app COPY package*.json ./ # Install only production dependencies -RUN npm ci --omit=dev +RUN npm install --omit=dev # Bundle app source COPY . . @@ -17,4 +17,4 @@ COPY . . EXPOSE 3000 # Start the bot -CMD [ "npm", "start" ] +CMD [ "npm", "start" ] \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index edef65e76..c0cbbe234 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,20 +9,31 @@ "version": "2.1.0", "dependencies": { "@discordjs/rest": "^2.6.1", + "@discordjs/voice": "^0.17.0", "axios": "^1.15.2", + "connect-pg-simple": "^10.0.0", "discord.js": "^14.26.4", "dotenv": "^17.2.3", "express": "^5.1.0", + "express-session": "^1.19.0", + "ffmpeg-static": "^5.2.0", + "libsodium-wrappers": "^0.7.15", "node-cron": "^4.2.1", "pg": "^8.11.3", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", + "youtubei.js": "^17.2.0", "zod": "^3.25.76" }, "engines": { "node": ">=18.0.0" } }, + "node_modules/@bufbuild/protobuf": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.1.tgz", + "integrity": "sha512-BvAMfS6LrgZiryOAZ4pBYucu4wG/Ei/9o9DZ9akbREnMLbPJiom2i8b9C8IsKErQoiKqVhrerzt3kOT/RrzLHg==" + }, "node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -41,6 +52,20 @@ "kuler": "^2.0.0" } }, + "node_modules/@derhuerst/http-basic": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz", + "integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==", + "dependencies": { + "caseless": "^0.12.0", + "concat-stream": "^2.0.0", + "http-response-object": "^3.0.1", + "parse-cache-control": "^1.0.1" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@discordjs/builders": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", @@ -122,6 +147,30 @@ "url": "https://github.com/discordjs/discord.js?sponsor" } }, + "node_modules/@discordjs/voice": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@discordjs/voice/-/voice-0.17.0.tgz", + "integrity": "sha512-hArn9FF5ZYi1IkxdJEVnJi+OxlwLV0NJYWpKXsmNOojtGtAZHxmsELA+MZlu2KW1F/K1/nt7lFOfcMXNYweq9w==", + "deprecated": "This version uses deprecated encryption modes. Please use a newer version.", + "dependencies": { + "@types/ws": "^8.5.10", + "discord-api-types": "0.37.83", + "prism-media": "^1.3.5", + "tslib": "^2.6.2", + "ws": "^8.16.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/voice/node_modules/discord-api-types": { + "version": "0.37.83", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.83.tgz", + "integrity": "sha512-urGGYeWtWNYMKnYlZnOnDHm8fVRffQs3U0SpE8RHeiuLKb/u92APS8HoQnPTFbnXmY1vVnXjXO4dOxcAn3J+DA==" + }, "node_modules/@discordjs/ws": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", @@ -280,6 +329,11 @@ "url": "https://opencollective.com/express" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -315,6 +369,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==" + }, "node_modules/color": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", @@ -368,6 +427,31 @@ "node": ">= 0.8" } }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/connect-pg-simple": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-10.0.0.tgz", + "integrity": "sha512-pBGVazlqiMrackzCr0eKhn4LO5trJXsOX0nQoey9wCOayh80MYtThCbq8eoLsjpiWgiok/h+1/uti9/2/Una8A==", + "dependencies": { + "pg": "^8.12.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=22.0.0" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -526,6 +610,14 @@ "node": ">= 0.8" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "engines": { + "node": ">=6" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -622,6 +714,46 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-session": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", + "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", + "dependencies": { + "cookie": "~0.7.2", + "cookie-signature": "~1.0.7", + "debug": "~2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "~5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" + }, + "node_modules/express-session/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express-session/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -632,6 +764,26 @@ "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==" }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==" + }, + "node_modules/ffmpeg-static": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz", + "integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==", + "hasInstallScript": true, + "dependencies": { + "@derhuerst/http-basic": "^8.2.0", + "env-paths": "^2.2.0", + "https-proxy-agent": "^5.0.0", + "progress": "^2.0.3" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/file-stream-rotator": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz", @@ -843,6 +995,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-response-object": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz", + "integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==", + "dependencies": { + "@types/node": "^10.0.3" + } + }, + "node_modules/http-response-object/node_modules/@types/node": { + "version": "10.17.60", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", + "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==" + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -904,6 +1069,19 @@ "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" }, + "node_modules/libsodium": { + "version": "0.7.16", + "resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.7.16.tgz", + "integrity": "sha512-3HrzSPuzm6Yt9aTYCDxYEG8x8/6C0+ag655Y7rhhWZM9PT4NpdnbqlzXhGZlDnkgR6MeSTnOt/VIyHLs9aSf+Q==" + }, + "node_modules/libsodium-wrappers": { + "version": "0.7.16", + "resolved": "https://registry.npmjs.org/libsodium-wrappers/-/libsodium-wrappers-0.7.16.tgz", + "integrity": "sha512-Gtr/WBx4dKjvRL1pvfwZqu7gO6AfrQ0u9vFL+kXihtHf6NfkROR8pjYWn98MFDI3jN19Ii1ZUfPR9afGiPyfHg==", + "dependencies": { + "libsodium": "^0.7.16" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -962,6 +1140,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/meriyah": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/meriyah/-/meriyah-6.1.4.tgz", + "integrity": "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -1044,6 +1230,14 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -1060,6 +1254,18 @@ "fn.name": "1.x.x" } }, + "node_modules/opusscript": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.0.8.tgz", + "integrity": "sha512-VSTi1aWFuCkRCVq+tx/BQ5q9fMnQ9pVZ3JU4UHKqTkf0ED3fKEPdr+gKAAl3IA2hj9rrP6iyq3hlcJq3HELtNQ==", + "optional": true, + "peer": true + }, + "node_modules/parse-cache-control": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz", + "integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1193,6 +1399,39 @@ "node": ">=0.10.0" } }, + "node_modules/prism-media": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz", + "integrity": "sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA==", + "peerDependencies": { + "@discordjs/opus": ">=0.8.0 <1.0.0", + "ffmpeg-static": "^5.0.2 || ^4.2.7 || ^3.0.0 || ^2.4.0", + "node-opus": "^0.3.3", + "opusscript": "^0.0.8" + }, + "peerDependenciesMeta": { + "@discordjs/opus": { + "optional": true + }, + "ffmpeg-static": { + "optional": true + }, + "node-opus": { + "optional": true + }, + "opusscript": { + "optional": true + } + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1227,6 +1466,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -1517,6 +1764,22 @@ "url": "https://opencollective.com/express" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" + }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/undici": { "version": "6.24.1", "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", @@ -1635,6 +1898,19 @@ "node": ">=0.4" } }, + "node_modules/youtubei.js": { + "version": "17.2.0", + "resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-17.2.0.tgz", + "integrity": "sha512-XLNsgRKO1h7t4i9tIMWSQSeWdD7Ujkk5v1m5YCaumaHMhu/xuLqtO3M0Hq7CXNup9HlJ1NGrT1Y+HLIHnL6Ujg==", + "funding": [ + "https://github.com/sponsors/LuanRT" + ], + "dependencies": { + "@bufbuild/protobuf": "^2.0.0", + "fflate": "^0.8.2", + "meriyah": "^6.1.4" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -1645,6 +1921,11 @@ } }, "dependencies": { + "@bufbuild/protobuf": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.1.tgz", + "integrity": "sha512-BvAMfS6LrgZiryOAZ4pBYucu4wG/Ei/9o9DZ9akbREnMLbPJiom2i8b9C8IsKErQoiKqVhrerzt3kOT/RrzLHg==" + }, "@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -1660,6 +1941,17 @@ "kuler": "^2.0.0" } }, + "@derhuerst/http-basic": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz", + "integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==", + "requires": { + "caseless": "^0.12.0", + "concat-stream": "^2.0.0", + "http-response-object": "^3.0.1", + "parse-cache-control": "^1.0.1" + } + }, "@discordjs/builders": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", @@ -1711,6 +2003,25 @@ "discord-api-types": "^0.38.33" } }, + "@discordjs/voice": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@discordjs/voice/-/voice-0.17.0.tgz", + "integrity": "sha512-hArn9FF5ZYi1IkxdJEVnJi+OxlwLV0NJYWpKXsmNOojtGtAZHxmsELA+MZlu2KW1F/K1/nt7lFOfcMXNYweq9w==", + "requires": { + "@types/ws": "^8.5.10", + "discord-api-types": "0.37.83", + "prism-media": "^1.3.5", + "tslib": "^2.6.2", + "ws": "^8.16.0" + }, + "dependencies": { + "discord-api-types": { + "version": "0.37.83", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.83.tgz", + "integrity": "sha512-urGGYeWtWNYMKnYlZnOnDHm8fVRffQs3U0SpE8RHeiuLKb/u92APS8HoQnPTFbnXmY1vVnXjXO4dOxcAn3J+DA==" + } + } + }, "@discordjs/ws": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", @@ -1835,6 +2146,11 @@ "type-is": "^2.0.1" } }, + "buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, "bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1858,6 +2174,11 @@ "get-intrinsic": "^1.3.0" } }, + "caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==" + }, "color": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", @@ -1896,6 +2217,25 @@ "delayed-stream": "~1.0.0" } }, + "concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "connect-pg-simple": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-10.0.0.tgz", + "integrity": "sha512-pBGVazlqiMrackzCr0eKhn4LO5trJXsOX0nQoey9wCOayh80MYtThCbq8eoLsjpiWgiok/h+1/uti9/2/Una8A==", + "requires": { + "pg": "^8.12.0" + } + }, "content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -2001,6 +2341,11 @@ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" }, + "env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==" + }, "es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2075,6 +2420,41 @@ "vary": "^1.1.2" } }, + "express-session": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", + "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", + "requires": { + "cookie": "~0.7.2", + "cookie-signature": "~1.0.7", + "debug": "~2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "~5.2.1", + "uid-safe": "~2.1.5" + }, + "dependencies": { + "cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + } + } + }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2085,6 +2465,22 @@ "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==" }, + "fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==" + }, + "ffmpeg-static": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz", + "integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==", + "requires": { + "@derhuerst/http-basic": "^8.2.0", + "env-paths": "^2.2.0", + "https-proxy-agent": "^5.0.0", + "progress": "^2.0.3" + } + }, "file-stream-rotator": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz", @@ -2222,6 +2618,21 @@ "toidentifier": "~1.0.1" } }, + "http-response-object": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz", + "integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==", + "requires": { + "@types/node": "^10.0.3" + }, + "dependencies": { + "@types/node": { + "version": "10.17.60", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", + "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==" + } + } + }, "https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -2264,6 +2675,19 @@ "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" }, + "libsodium": { + "version": "0.7.16", + "resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.7.16.tgz", + "integrity": "sha512-3HrzSPuzm6Yt9aTYCDxYEG8x8/6C0+ag655Y7rhhWZM9PT4NpdnbqlzXhGZlDnkgR6MeSTnOt/VIyHLs9aSf+Q==" + }, + "libsodium-wrappers": { + "version": "0.7.16", + "resolved": "https://registry.npmjs.org/libsodium-wrappers/-/libsodium-wrappers-0.7.16.tgz", + "integrity": "sha512-Gtr/WBx4dKjvRL1pvfwZqu7gO6AfrQ0u9vFL+kXihtHf6NfkROR8pjYWn98MFDI3jN19Ii1ZUfPR9afGiPyfHg==", + "requires": { + "libsodium": "^0.7.16" + } + }, "lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -2307,6 +2731,11 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==" }, + "meriyah": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/meriyah/-/meriyah-6.1.4.tgz", + "integrity": "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==" + }, "mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -2358,6 +2787,11 @@ "ee-first": "1.1.1" } }, + "on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==" + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -2374,6 +2808,18 @@ "fn.name": "1.x.x" } }, + "opusscript": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.0.8.tgz", + "integrity": "sha512-VSTi1aWFuCkRCVq+tx/BQ5q9fMnQ9pVZ3JU4UHKqTkf0ED3fKEPdr+gKAAl3IA2hj9rrP6iyq3hlcJq3HELtNQ==", + "optional": true, + "peer": true + }, + "parse-cache-control": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz", + "integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==" + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2467,6 +2913,17 @@ "xtend": "^4.0.0" } }, + "prism-media": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz", + "integrity": "sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA==", + "requires": {} + }, + "progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==" + }, "proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2489,6 +2946,11 @@ "side-channel": "^1.1.0" } }, + "random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==" + }, "range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -2685,6 +3147,19 @@ } } }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" + }, + "uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "requires": { + "random-bytes": "~1.0.0" + } + }, "undici": { "version": "6.24.1", "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", @@ -2765,6 +3240,16 @@ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" }, + "youtubei.js": { + "version": "17.2.0", + "resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-17.2.0.tgz", + "integrity": "sha512-XLNsgRKO1h7t4i9tIMWSQSeWdD7Ujkk5v1m5YCaumaHMhu/xuLqtO3M0Hq7CXNup9HlJ1NGrT1Y+HLIHnL6Ujg==", + "requires": { + "@bufbuild/protobuf": "^2.0.0", + "fflate": "^0.8.2", + "meriyah": "^6.1.4" + } + }, "zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index 2240607d7..334e10efc 100644 --- a/package.json +++ b/package.json @@ -16,14 +16,20 @@ }, "dependencies": { "@discordjs/rest": "^2.6.1", + "@discordjs/voice": "^0.17.0", "axios": "^1.15.2", + "connect-pg-simple": "^10.0.0", "discord.js": "^14.26.4", "dotenv": "^17.2.3", "express": "^5.1.0", + "express-session": "^1.19.0", + "ffmpeg-static": "^5.2.0", + "libsodium-wrappers": "^0.7.15", "node-cron": "^4.2.1", "pg": "^8.11.3", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", + "youtubei.js": "^17.2.0", "zod": "^3.25.76" }, "engines": { diff --git a/src/app.js b/src/app.js index bea0ab7d8..77921dc2a 100644 --- a/src/app.js +++ b/src/app.js @@ -1,4 +1,4 @@ -import 'dotenv/config'; +import 'dotenv/config'; import { Client, Collection, GatewayIntentBits } from 'discord.js'; import { REST } from '@discordjs/rest'; import express from 'express'; @@ -10,8 +10,10 @@ import { getGuildConfig } from './services/guildConfig.js'; import { getServerCounters, saveServerCounters, updateCounter } from './services/serverstatsService.js'; import { logger, startupLog, shutdownLog } from './utils/logger.js'; import { checkBirthdays } from './services/birthdayService.js'; +import { processScheduledRemovals } from './services/punishmentScheduler.js'; import { checkGiveaways } from './services/giveawayService.js'; import { loadCommands, registerCommands as registerSlashCommands } from './handlers/commandLoader.js'; +import { mountDashboard } from './web/index.js'; import pkg from '../package.json' with { type: 'json' }; import { EXPECTED_SCHEMA_VERSION, EXPECTED_SCHEMA_LABEL } from './config/schemaVersion.js'; @@ -19,18 +21,14 @@ class TitanBot extends Client { constructor() { super({ intents: [ - - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildMembers, - - GatewayIntentBits.GuildMessages, - GatewayIntentBits.GuildMessageReactions, - GatewayIntentBits.MessageContent, + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.GuildMessageReactions, + GatewayIntentBits.MessageContent, GatewayIntentBits.DirectMessages, - - GatewayIntentBits.GuildVoiceStates, - - GatewayIntentBits.GuildBans, + GatewayIntentBits.GuildVoiceStates, + GatewayIntentBits.GuildBans, ], }); @@ -54,7 +52,6 @@ class TitanBot extends Client { const dbInstance = await initializeDatabase(); this.db = dbInstance.db; - // Check database status and report const dbStatus = this.db.getStatus(); if (dbStatus.isDegraded) { logger.warn(''); @@ -133,7 +130,7 @@ class TitanBot extends Client { }); const requestCounts = new Map(); - const windowMs = 60000; + const windowMs = 60000; const maxRequests = this.config.api?.rateLimit?.max || 100; app.use((req, res, next) => { @@ -203,13 +200,15 @@ class TitanBot extends Client { }); app.get('/', (req, res) => { - res.status(200).json({ + res.status(200).json({ message: 'TitanBot System Online', version: pkg.version, timestamp: new Date().toISOString() }); }); + mountDashboard(app, this); + const startServer = (port, attempt = 0) => { let hasStartedListening = false; const server = app.listen(port, host, () => { @@ -249,6 +248,7 @@ class TitanBot extends Client { setupCronJobs() { cron.schedule('0 6 * * *', () => checkBirthdays(this)); + cron.schedule('* * * * *', () => processScheduledRemovals(this)); // Check scheduled punishments every minute cron.schedule('* * * * *', () => checkGiveaways(this)); cron.schedule('*/15 * * * *', () => this.updateAllCounters()); } @@ -278,8 +278,6 @@ class TitanBot extends Client { } } - // Save cleaned counters if any were orphaned - // Save cleaned counters if any were orphaned if (orphanedCounters.length > 0) { await saveServerCounters(this, guildId, validCounters); logger.info(`Cleaned up ${orphanedCounters.length} orphaned counter(s) from guild ${guildId} during scheduled update`); @@ -338,13 +336,10 @@ class TitanBot extends Client { logger.info(`${'='.repeat(60)}`); try { - logger.info('Stopping cron jobs...'); cron.getTasks().forEach(task => task.stop()); logger.info('✅ Cron jobs stopped'); - // Close database connection - // Close database connection if (this.db && this.db.db) { logger.info('Closing database connection...'); try { @@ -363,13 +358,12 @@ class TitanBot extends Client { this.destroy(); logger.info('✅ Discord client destroyed'); } catch (error) { - logger.warn('Discord client destroy warning (non-critical):', error.message); } } logger.info('✅ Graceful shutdown complete'); - shutdownLog('Bot stopped successfully.'); + shutdownLog('Bot stopped successfully.'); process.exit(0); } catch (error) { logger.error('Error during graceful shutdown:', error); @@ -412,4 +406,4 @@ try { process.exit(1); } -export default TitanBot; \ No newline at end of file +export default TitanBot; diff --git a/src/commands/Community/announcement.js b/src/commands/Community/announcement.js new file mode 100644 index 000000000..82807a9e2 --- /dev/null +++ b/src/commands/Community/announcement.js @@ -0,0 +1,352 @@ +import { + SlashCommandBuilder, + PermissionFlagsBits, + EmbedBuilder, + ChannelType, + MessageFlags, +} from 'discord.js'; +import { successEmbed, errorEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { getFromDb, setInDb } from '../../utils/database.js'; + +const CONFIG_KEY = (guildId) => `announcement_config_${guildId}`; + +async function getConfig(client, guildId) { + return await getFromDb(CONFIG_KEY(guildId), { + channelId: null, + welcomeChannelId: null, + boostChannelId: null, + scheduledAnnouncements: [], + }); +} + +async function saveConfig(client, guildId, config) { + await setInDb(CONFIG_KEY(guildId), config); +} + +function formatAnnouncementMessage(message) { + const cleaned = String(message || '') + .split(/\r?\n/) + .map(line => line.trim()) + .filter(line => line.length > 0 && !/^[\s\-_=·]{3,}$/.test(line)) + .map(line => line.replace(/^[-*]\s+/, '• ')) + .map(line => line.replace(/·/g, '•')) + .map(line => line.replace(/\s*—\s*/g, ' — ')) + .map(line => line.replace(/\s{2,}/g, ' ')) + .join('\n') + .replace(/\n{3,}/g, '\n\n'); + + return cleaned; +} + +function buildAnnouncementEmbed(title, message, color, image) { + const embed = new EmbedBuilder() + .setTitle(title) + .setDescription(formatAnnouncementMessage(message)) + .setColor(color); + + if (image) { + embed.setImage(image); + } + + return embed; +} + +export default { + data: new SlashCommandBuilder() + .setName('announcement') + .setDescription('Manage server announcements') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + + // /announcement setchannel + .addSubcommand(sub => + sub.setName('setchannel') + .setDescription('Set the channel for announcements') + .addChannelOption(opt => + opt.setName('channel') + .setDescription('The announcement channel') + .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement) + .setRequired(true) + ) + .addStringOption(opt => + opt.setName('type') + .setDescription('Type of channel to set') + .setRequired(true) + .addChoices( + { name: 'General Announcements', value: 'general' }, + { name: 'Welcome Messages', value: 'welcome' }, + { name: 'Boost Announcements', value: 'boost' }, + ) + ) + ) + + // /announcement send + .addSubcommand(sub => + sub.setName('send') + .setDescription('Send a manual announcement') + .addStringOption(opt => + opt.setName('title').setDescription('Announcement title').setRequired(true) + ) + .addStringOption(opt => + opt.setName('message').setDescription('Announcement message').setRequired(true) + ) + .addStringOption(opt => + opt.setName('ping') + .setDescription('Who to ping') + .setRequired(false) + .addChoices( + { name: '@everyone', value: 'everyone' }, + { name: '@here', value: 'here' }, + { name: 'A specific role', value: 'role' }, + { name: 'No ping', value: 'none' }, + ) + ) + .addRoleOption(opt => + opt.setName('role').setDescription('Role to ping (if ping is set to role)').setRequired(false) + ) + .addStringOption(opt => + opt.setName('color') + .setDescription('Embed color') + .setRequired(false) + .addChoices( + { name: 'Blue', value: '0x3498DB' }, + { name: 'Green', value: '0x2ECC71' }, + { name: 'Red', value: '0xE74C3C' }, + { name: 'Gold', value: '0xF1C40F' }, + { name: 'Purple', value: '0x9B59B6' }, + { name: 'White', value: '0xFFFFFF' }, + ) + ) + .addStringOption(opt => + opt.setName('image').setDescription('Image URL to attach to the announcement').setRequired(false) + ) + ) + + // /announcement schedule + .addSubcommand(sub => + sub.setName('schedule') + .setDescription('Schedule a recurring announcement') + .addStringOption(opt => + opt.setName('title').setDescription('Announcement title').setRequired(true) + ) + .addStringOption(opt => + opt.setName('message').setDescription('Announcement message').setRequired(true) + ) + .addStringOption(opt => + opt.setName('interval') + .setDescription('How often to post') + .setRequired(true) + .addChoices( + { name: 'Every hour', value: '0 * * * *' }, + { name: 'Every 6 hours', value: '0 */6 * * *' }, + { name: 'Every 12 hours', value: '0 */12 * * *' }, + { name: 'Daily (9am)', value: '0 9 * * *' }, + { name: 'Weekly (Monday 9am)', value: '0 9 * * 1' }, + ) + ) + .addStringOption(opt => + opt.setName('ping') + .setDescription('Who to ping') + .setRequired(false) + .addChoices( + { name: '@everyone', value: 'everyone' }, + { name: '@here', value: 'here' }, + { name: 'No ping', value: 'none' }, + ) + ) + ) + + // /announcement listschedules + .addSubcommand(sub => + sub.setName('listschedules') + .setDescription('List all scheduled announcements') + ) + + // /announcement deleteschedule + .addSubcommand(sub => + sub.setName('deleteschedule') + .setDescription('Delete a scheduled announcement') + .addIntegerOption(opt => + opt.setName('id').setDescription('Schedule ID to delete').setRequired(true) + ) + ), + + category: 'community', + + async execute(interaction, config, client) { + try { + const sub = interaction.options.getSubcommand(); + const guildConfig = await getConfig(client, interaction.guild.id); + + if (sub === 'setchannel') { + const channel = interaction.options.getChannel('channel'); + const type = interaction.options.getString('type'); + + if (type === 'general') guildConfig.channelId = channel.id; + if (type === 'welcome') guildConfig.welcomeChannelId = channel.id; + if (type === 'boost') guildConfig.boostChannelId = channel.id; + + await saveConfig(client, interaction.guild.id, guildConfig); + + const typeLabel = { general: 'General Announcements', welcome: 'Welcome Messages', boost: 'Boost Announcements' }[type]; + await InteractionHelper.universalReply(interaction, { + embeds: [successEmbed(`✅ Channel Set`, `**${typeLabel}** will now be posted in <#${channel.id}>`)], + }); + + } else if (sub === 'send') { + if (!guildConfig.channelId) { + throw new TitanBotError('No channel set', ErrorTypes.CONFIGURATION, 'Please set an announcement channel first with `/announcement setchannel`.', { subtype: 'missing_channel' }); + } + + const title = interaction.options.getString('title'); + const message = interaction.options.getString('message'); + const ping = interaction.options.getString('ping') || 'none'; + const role = interaction.options.getRole('role'); + const colorStr = interaction.options.getString('color') || '0x3498DB'; + const image = interaction.options.getString('image'); + + const channel = interaction.guild.channels.cache.get(guildConfig.channelId) + || await interaction.guild.channels.fetch(guildConfig.channelId).catch(() => null); + + if (!channel) { + throw new TitanBotError('Channel not found', ErrorTypes.CONFIGURATION, 'Announcement channel not found. Please set it again with `/announcement setchannel`.', { subtype: 'missing_channel' }); + } + + const embed = buildAnnouncementEmbed(title, message, parseInt(colorStr, 16), image); + + const announcementPayload = { embeds: [embed] }; + if (ping === 'everyone') announcementPayload.content = '@everyone'; + else if (ping === 'here') announcementPayload.content = '@here'; + else if (ping === 'role' && role) announcementPayload.content = `<@&${role.id}>`; + + await channel.send(announcementPayload); + + await InteractionHelper.universalReply(interaction, { + embeds: [successEmbed('✅ Announcement Sent', `Your announcement has been posted in <#${channel.id}>`)], + flags: MessageFlags.Ephemeral, + }); + + } else if (sub === 'schedule') { + if (!guildConfig.channelId) { + throw new TitanBotError('No channel set', ErrorTypes.CONFIGURATION, 'Please set an announcement channel first with `/announcement setchannel`.', { subtype: 'missing_channel' }); + } + + const title = interaction.options.getString('title'); + const message = interaction.options.getString('message'); + const interval = interaction.options.getString('interval'); + const ping = interaction.options.getString('ping') || 'none'; + + const schedule = { + id: Date.now(), + title, + message, + interval, + ping, + channelId: guildConfig.channelId, + createdBy: interaction.user.id, + createdAt: new Date().toISOString(), + }; + + if (!guildConfig.scheduledAnnouncements) guildConfig.scheduledAnnouncements = []; + guildConfig.scheduledAnnouncements.push(schedule); + await saveConfig(client, interaction.guild.id, guildConfig); + + // Register the cron job + registerSchedule(client, interaction.guild.id, schedule); + + const intervalLabel = { + '0 * * * *': 'Every hour', + '0 */6 * * *': 'Every 6 hours', + '0 */12 * * *': 'Every 12 hours', + '0 9 * * *': 'Daily at 9am', + '0 9 * * 1': 'Weekly on Monday at 9am', + }[interval]; + + await InteractionHelper.universalReply(interaction, { + embeds: [successEmbed('✅ Schedule Created', `**${title}** will be posted **${intervalLabel}** in <#${guildConfig.channelId}>`)], + }); + + } else if (sub === 'listschedules') { + const schedules = guildConfig.scheduledAnnouncements || []; + + if (schedules.length === 0) { + return InteractionHelper.universalReply(interaction, { + embeds: [new EmbedBuilder().setColor(0x3498DB).setDescription('No scheduled announcements.')], + }); + } + + const intervalLabels = { + '0 * * * *': 'Every hour', + '0 */6 * * *': 'Every 6 hours', + '0 */12 * * *': 'Every 12 hours', + '0 9 * * *': 'Daily at 9am', + '0 9 * * 1': 'Weekly on Monday', + }; + + const embed = new EmbedBuilder() + .setColor(0x3498DB) + .setTitle('📅 Scheduled Announcements') + .setDescription( + schedules.map(s => + `**ID:** \`${s.id}\`\n**Title:** ${s.title}\n**Interval:** ${intervalLabels[s.interval] || s.interval}\n**Channel:** <#${s.channelId}>\n` + ).join('\n') + ); + + await InteractionHelper.universalReply(interaction, { embeds: [embed] }); + + } else if (sub === 'deleteschedule') { + const id = interaction.options.getInteger('id'); + const schedules = guildConfig.scheduledAnnouncements || []; + const index = schedules.findIndex(s => s.id === id); + + if (index === -1) { + throw new TitanBotError('Schedule not found', ErrorTypes.USER_INPUT, `No schedule found with ID \`${id}\`. Use \`/announcement listschedules\` to see all schedules.`, { subtype: 'not_found' }); + } + + schedules.splice(index, 1); + guildConfig.scheduledAnnouncements = schedules; + await saveConfig(client, interaction.guild.id, guildConfig); + + await InteractionHelper.universalReply(interaction, { + embeds: [successEmbed('✅ Schedule Deleted', `Schedule \`${id}\` has been removed.`)], + }); + } + + } catch (error) { + logger.error('Announcement command error:', error); + await handleInteractionError(interaction, error, { subtype: 'announcement_failed' }); + } + }, +}; + +// Register a cron schedule for an announcement +export async function registerSchedule(client, guildId, schedule) { + try { + const { default: cron } = await import('node-cron'); + cron.schedule(schedule.interval, async () => { + try { + const guild = client.guilds.cache.get(guildId); + if (!guild) return; + + const channel = guild.channels.cache.get(schedule.channelId) + || await guild.channels.fetch(schedule.channelId).catch(() => null); + if (!channel) return; + + const embed = buildAnnouncementEmbed(`📢 ${schedule.title}`, schedule.message, 0x3498DB) + .setTimestamp(); + + let pingContent = ''; + if (schedule.ping === 'everyone') pingContent = '@everyone'; + else if (schedule.ping === 'here') pingContent = '@here'; + + await channel.send({ content: pingContent || undefined, embeds: [embed] }); + } catch (err) { + logger.error(`Error sending scheduled announcement ${schedule.id}:`, err); + } + }); + } catch (err) { + logger.error('Error registering schedule:', err); + } +} diff --git a/src/commands/Community/app-admin.js b/src/commands/Community/app-admin.js index d4891c03c..487e204cb 100644 --- a/src/commands/Community/app-admin.js +++ b/src/commands/Community/app-admin.js @@ -584,7 +584,7 @@ export async function handleApplicationReviewModal(interaction) { if (!customId.startsWith('app_review_')) return; const [, appId, action] = customId.split('_'); - const reason = interaction.fields.getTextInputValue('reason') || 'No reason provided.'; + const reason = interaction.fields.getTextInputValue('review_reason') || 'No reason provided.'; const isApprove = action === 'approve'; try { diff --git a/src/commands/Community/loa.js b/src/commands/Community/loa.js new file mode 100644 index 000000000..e9a889377 --- /dev/null +++ b/src/commands/Community/loa.js @@ -0,0 +1,277 @@ +import { + SlashCommandBuilder, + PermissionFlagsBits, + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + MessageFlags, +} from 'discord.js'; +import { successEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { getFromDb, setInDb } from '../../utils/database.js'; + +const LOA_FORUM_CHANNEL_ID = '1517999241182576710'; +const LOA_ROLE_ID = '1513775663834992730'; + +function generateLoaId() { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); +} + +export default { + data: new SlashCommandBuilder() + .setName('loa') + .setDescription('Leave of Absence request system') + + // /loa request + .addSubcommand(sub => + sub.setName('request') + .setDescription('Submit a Leave of Absence request') + .addStringOption(opt => + opt.setName('start_date') + .setDescription('Start date of your LOA (e.g. June 20, 2026)') + .setRequired(true) + ) + .addStringOption(opt => + opt.setName('end_date') + .setDescription('Expected return date (e.g. June 27, 2026)') + .setRequired(true) + ) + .addStringOption(opt => + opt.setName('reason') + .setDescription('Reason for your LOA') + .setRequired(true) + ) + .addStringOption(opt => + opt.setName('notes') + .setDescription('Any additional notes') + .setRequired(false) + ) + ) + + // /loa return + .addSubcommand(sub => + sub.setName('return') + .setDescription('Mark yourself as returned from LOA') + ) + + // /loa view + .addSubcommand(sub => + sub.setName('view') + .setDescription('View your current LOA status') + ) + + // /loa list (staff only) + .addSubcommand(sub => + sub.setName('list') + .setDescription('List all active LOAs (staff only)') + ), + + category: 'community', + + async execute(interaction, config, client) { + try { + const sub = interaction.options.getSubcommand(); + + if (sub === 'request') { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const startDate = interaction.options.getString('start_date'); + const endDate = interaction.options.getString('end_date'); + const reason = interaction.options.getString('reason'); + const notes = interaction.options.getString('notes') || 'None'; + + // Check if user already has an active LOA + const existingLoa = await getFromDb(`loa_active_${interaction.guild.id}_${interaction.user.id}`, null); + if (existingLoa) { + throw new TitanBotError('Active LOA exists', ErrorTypes.USER_INPUT, 'You already have an active LOA request. Use `/loa view` to check its status.', { subtype: 'duplicate_loa' }); + } + + const loaId = generateLoaId(); + const now = new Date(); + + // Build the LOA embed + const embed = new EmbedBuilder() + .setTitle(`LOA Request — \`${loaId}\``) + .setColor(0xF39C12) + .setThumbnail(interaction.user.displayAvatarURL({ dynamic: true, size: 128 })) + .addFields( + { name: 'Member', value: `<@${interaction.user.id}> (\`${interaction.user.id}\`)`, inline: false }, + { name: 'Start Date', value: startDate, inline: true }, + { name: 'Return Date', value: endDate, inline: true }, + { name: 'Submitted', value: ``, inline: false }, + { name: 'Reason', value: reason, inline: false }, + { name: 'Additional Notes', value: notes, inline: false }, + { name: 'Status', value: '🟡 **Pending Review**', inline: false }, + ) + .setTimestamp(); + + // Approval buttons + const buttons = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`loa_approve_${loaId}_${interaction.user.id}`) + .setLabel('✅ Approve') + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`loa_deny_${loaId}_${interaction.user.id}`) + .setLabel('❌ Deny') + .setStyle(ButtonStyle.Danger), + ); + + // Post to LOA forum channel + const forumChannel = interaction.guild.channels.cache.get(LOA_FORUM_CHANNEL_ID) + || await interaction.guild.channels.fetch(LOA_FORUM_CHANNEL_ID).catch(() => null); + + if (!forumChannel) { + throw new TitanBotError('Forum not found', ErrorTypes.CONFIGURATION, 'LOA forum channel not found.', { subtype: 'missing_channel' }); + } + + const forumPost = await forumChannel.threads.create({ + name: `LOA — ${interaction.user.username} — ${loaId}`, + message: { + embeds: [embed], + components: [buttons], + }, + }); + + // Store the LOA request + await setInDb(`loa_active_${interaction.guild.id}_${interaction.user.id}`, { + loaId, + userId: interaction.user.id, + startDate, + endDate, + reason, + notes, + status: 'pending', + threadId: forumPost.id, + submittedAt: now.toISOString(), + }); + + // Store by loaId for lookup + await setInDb(`loa_id_${interaction.guild.id}_${loaId}`, interaction.user.id); + + await InteractionHelper.universalReply(interaction, { + embeds: [ + new EmbedBuilder() + .setColor(0xF39C12) + .setTitle('📋 LOA Request Submitted') + .setDescription(`Your LOA request (\`${loaId}\`) has been submitted for review!\n\nYou'll be notified once it's approved or denied.`) + .addFields( + { name: 'Start Date', value: startDate, inline: true }, + { name: 'Return Date', value: endDate, inline: true }, + ) + .setTimestamp(), + ], + }); + + } else if (sub === 'return') { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const loa = await getFromDb(`loa_active_${interaction.guild.id}_${interaction.user.id}`, null); + if (!loa) { + throw new TitanBotError('No active LOA', ErrorTypes.USER_INPUT, 'You don\'t have an active LOA.', { subtype: 'no_loa' }); + } + + // Remove the LOA role + const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null); + if (member) { + await member.roles.remove(LOA_ROLE_ID).catch(() => {}); + } + + // Update status + loa.status = 'returned'; + loa.returnedAt = new Date().toISOString(); + await setInDb(`loa_active_${interaction.guild.id}_${interaction.user.id}`, null); + await setInDb(`loa_returned_${interaction.guild.id}_${interaction.user.id}_${loa.loaId}`, loa); + + // Update forum thread if possible + if (loa.threadId) { + const thread = interaction.guild.channels.cache.get(loa.threadId) + || await interaction.guild.channels.fetch(loa.threadId).catch(() => null); + if (thread) { + await thread.send({ + embeds: [ + new EmbedBuilder() + .setColor(0x2ECC71) + .setDescription(`✅ <@${interaction.user.id}> has returned from LOA on .`) + ] + }).catch(() => {}); + await thread.setArchived(true).catch(() => {}); + } + } + + await InteractionHelper.universalReply(interaction, { + embeds: [successEmbed('✅ Welcome Back!', 'Your LOA has been marked as complete and your LOA role has been removed.')], + }); + + } else if (sub === 'view') { + const loa = await getFromDb(`loa_active_${interaction.guild.id}_${interaction.user.id}`, null); + if (!loa) { + return InteractionHelper.universalReply(interaction, { + embeds: [new EmbedBuilder().setColor(0x3498DB).setDescription('You have no active LOA request.')], + flags: MessageFlags.Ephemeral, + }); + } + + const statusEmoji = { pending: '🟡', approved: '🟢', denied: '🔴', returned: '⚪' }[loa.status] || '🟡'; + + const embed = new EmbedBuilder() + .setColor(0x3498DB) + .setTitle(`Your LOA — \`${loa.loaId}\``) + .addFields( + { name: 'Status', value: `${statusEmoji} ${loa.status.charAt(0).toUpperCase() + loa.status.slice(1)}`, inline: true }, + { name: 'Start Date', value: loa.startDate, inline: true }, + { name: 'Return Date', value: loa.endDate, inline: true }, + { name: 'Reason', value: loa.reason, inline: false }, + { name: 'Notes', value: loa.notes || 'None', inline: false }, + { name: 'Submitted', value: ``, inline: false }, + ) + .setTimestamp(); + + await InteractionHelper.universalReply(interaction, { embeds: [embed], flags: MessageFlags.Ephemeral }); + + } else if (sub === 'list') { + if (!interaction.member.permissions.has(PermissionFlagsBits.ManageRoles)) { + throw new TitanBotError('No permission', ErrorTypes.PERMISSIONS, 'You need Manage Roles permission to view all LOAs.', { subtype: 'missing_permission' }); + } + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + // Get all active LOAs for this guild — scan known pattern + const loas = []; + for (const [, member] of interaction.guild.members.cache) { + const loa = await getFromDb(`loa_active_${interaction.guild.id}_${member.id}`, null); + if (loa) loas.push(loa); + } + + if (loas.length === 0) { + return InteractionHelper.universalReply(interaction, { + embeds: [new EmbedBuilder().setColor(0x3498DB).setDescription('No active LOAs at this time.')], + }); + } + + const statusEmoji = { pending: '🟡', approved: '🟢', denied: '🔴' }; + + const embed = new EmbedBuilder() + .setColor(0x3498DB) + .setTitle('📋 Active LOAs') + .setDescription( + loas.map(l => + `${statusEmoji[l.status] || '🟡'} <@${l.userId}> — \`${l.loaId}\` — **${l.startDate}** to **${l.endDate}**` + ).join('\n') + ) + .setFooter({ text: `${loas.length} active LOA(s)` }) + .setTimestamp(); + + await InteractionHelper.universalReply(interaction, { embeds: [embed] }); + } + + } catch (error) { + logger.error('LOA command error:', error); + await handleInteractionError(interaction, error, { subtype: 'loa_failed' }); + } + }, +}; diff --git a/src/commands/Economy/balance.js b/src/commands/Economy/balance.js deleted file mode 100644 index 95528ecd9..000000000 --- a/src/commands/Economy/balance.js +++ /dev/null @@ -1,87 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, getMaxBankCapacity } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export default { - data: new SlashCommandBuilder() - .setName('balance') - .setDescription("Check your or someone else's balance") - .addUserOption(option => - option - .setName('user') - .setDescription('User to check balance for') - .setRequired(false) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userOption = interaction.options.getUser("user"); - const targetUser = userOption || interaction.user; - const guildId = interaction.guildId; - - logger.info(`[ECONOMY] Balance check - userOption: ${userOption?.id || 'null'}, targetUser: ${targetUser.id}, guildId: ${guildId}, isPrefix: ${!!interaction._commandStartTime}`); - - logger.debug(`[ECONOMY] Balance check for ${targetUser.id}`, { userId: targetUser.id, guildId }); - - if (targetUser.bot) { - throw createError( - "Bot user queried for balance", - ErrorTypes.VALIDATION, - "Bots don't have an economy balance." - ); - } - - const userData = await getEconomyData(client, guildId, targetUser.id); - - logger.info(`[ECONOMY] Economy data retrieved - userData:`, userData); - - if (!userData) { - throw createError( - "Failed to load economy data", - ErrorTypes.DATABASE, - "Failed to load economy data. Please try again later.", - { userId: targetUser.id, guildId } - ); - } - - const maxBank = getMaxBankCapacity(userData); - - const wallet = typeof userData.wallet === 'number' ? userData.wallet : 0; - const bank = typeof userData.bank === 'number' ? userData.bank : 0; - - const embed = createEmbed({ - title: `${targetUser.username}'s Balance`, - description: `Here is the current financial status for ${targetUser.username}.`, - }) - .addFields( - { - name: "💵 Cash", - value: `$${wallet.toLocaleString()}`, - inline: true, - }, - { - name: "🏦 Bank", - value: `$${bank.toLocaleString()} / $${maxBank.toLocaleString()}`, - inline: true, - }, - { - name: "💰 Total", - value: `$${(wallet + bank).toLocaleString()}`, - inline: true, - } - ) - .setFooter({ - text: `Requested by ${interaction.user.tag}`, - iconURL: interaction.user.displayAvatarURL(), - }); - - logger.info(`[ECONOMY] Balance retrieved`, { userId: targetUser.id, wallet, bank }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'balance' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/beg.js b/src/commands/Economy/beg.js deleted file mode 100644 index e2b37b0db..000000000 --- a/src/commands/Economy/beg.js +++ /dev/null @@ -1,99 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { successEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { botConfig } from '../../config/bot.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const COOLDOWN = 30 * 60 * 1000; -const MIN_WIN = 50; -const MAX_WIN = 200; -const SUCCESS_CHANCE = 0.7; - -export default { - data: new SlashCommandBuilder() - .setName('beg') - .setDescription('Beg for a small amount of money'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - - let userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - const lastBeg = userData.lastBeg || 0; - const remainingTime = lastBeg + COOLDOWN - Date.now(); - - if (remainingTime > 0) { - const minutes = Math.floor(remainingTime / 60000); - const seconds = Math.floor((remainingTime % 60000) / 1000); - - let timeMessage = - minutes > 0 ? `${minutes} minute(s)` : `${seconds} second(s)`; - - throw createError( - "Beg cooldown active", - ErrorTypes.RATE_LIMIT, - `You are tired from begging! Try again in **${timeMessage}**.`, - { remainingTime, minutes, seconds, cooldownType: 'beg' } - ); - } - - const success = Math.random() < SUCCESS_CHANCE; - - let replyEmbed; - let newCash = userData.wallet; - - if (success) { - const amountWon = - Math.floor(Math.random() * (MAX_WIN - MIN_WIN + 1)) + MIN_WIN; - - newCash += amountWon; - - const successMessages = [ - `A kind stranger drops **$${amountWon.toLocaleString()}** into your cup.`, - `You spotted an unattended wallet! You grab **$${amountWon.toLocaleString()}** and run.`, - `Someone took pity on you and gave you **$${amountWon.toLocaleString()}**!`, - `You found **$${amountWon.toLocaleString()}** under a park bench.`, - ]; - - replyEmbed = successEmbed( - 'Begging Successful', - successMessages[ - Math.floor(Math.random() * successMessages.length) - ] - ); - } else { - const failMessages = [ - "The police chased you off. You got nothing.", - "Someone yelled, 'Get a job!' and walked past.", - "A squirrel stole the single coin you had.", - "You tried to beg, but you were too embarrassed and gave up.", - ]; - - replyEmbed = warningEmbed( - 'Insufficient Funds', - failMessages[Math.floor(Math.random() * failMessages.length)] - ); - } - - userData.wallet = newCash; -userData.lastBeg = Date.now(); - - await setEconomyData(client, guildId, userId, userData); - - await InteractionHelper.safeEditReply(interaction, { embeds: [replyEmbed] }); - }, { command: 'beg' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/buy.js b/src/commands/Economy/buy.js deleted file mode 100644 index 55a3d121f..000000000 --- a/src/commands/Economy/buy.js +++ /dev/null @@ -1,157 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { shopItems } from '../../config/shop/items.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { getGuildConfig } from '../../services/guildConfig.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const SHOP_ITEMS = shopItems; - -export default { - data: new SlashCommandBuilder() - .setName('buy') - .setDescription('Buy an item from the shop') - .addStringOption(option => - option - .setName('item_id') - .setDescription('ID of the item to buy') - .setRequired(true) - ) - .addIntegerOption(option => - option - .setName('quantity') - .setDescription('Quantity to buy (default: 1)') - .setRequired(false) - .setMinValue(1) - .setMaxValue(10) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const itemId = interaction.options.getString("item_id").toLowerCase(); - const quantity = interaction.options.getInteger("quantity") || 1; - - const item = SHOP_ITEMS.find(i => i.id === itemId); - - if (!item) { - throw createError( - `Item ${itemId} not found`, - ErrorTypes.VALIDATION, - `The item ID \`${itemId}\` does not exist in the shop.`, - { itemId } - ); - } - - if (quantity < 1) { - throw createError( - "Invalid quantity", - ErrorTypes.VALIDATION, - "You must purchase a quantity of 1 or more.", - { quantity } - ); - } - - const totalCost = item.price * quantity; - - const guildConfig = await getGuildConfig(client, guildId); - const PREMIUM_ROLE_ID = guildConfig.premiumRoleId; - - const userData = await getEconomyData(client, guildId, userId); - - if (userData.wallet < totalCost) { - throw createError( - "Insufficient funds", - ErrorTypes.VALIDATION, - `You need **$${totalCost.toLocaleString()}** to purchase ${quantity}x **${item.name}**, but you only have **$${userData.wallet.toLocaleString()}** in cash.`, - { required: totalCost, current: userData.wallet, itemId, quantity } - ); - } - - if (item.type === "role" && itemId === "premium_role") { - if (!PREMIUM_ROLE_ID) { - throw createError( - "Premium role not configured", - ErrorTypes.CONFIGURATION, - "The **Premium Shop Role** has not been configured by a server administrator yet.", - { itemId } - ); - } - if (interaction.member.roles.cache.has(PREMIUM_ROLE_ID)) { - throw createError( - "Role already owned", - ErrorTypes.VALIDATION, - `You already have the **${item.name}** role.`, - { itemId, roleId: PREMIUM_ROLE_ID } - ); - } - if (quantity > 1) { - throw createError( - "Invalid quantity for role", - ErrorTypes.VALIDATION, - `You can only purchase the **${item.name}** role once.`, - { itemId, quantity } - ); - } - } - - userData.wallet -= totalCost; - - let successDescription = `You successfully purchased ${quantity}x **${item.name}** for **$${totalCost.toLocaleString()}**!`; - - if (item.type === "role" && itemId === "premium_role") { - const member = interaction.member; - - const role = interaction.guild.roles.cache.get(PREMIUM_ROLE_ID); - - if (!role) { - throw createError( - "Role not found", - ErrorTypes.CONFIGURATION, - "The configured premium role no longer exists in this guild.", - { roleId: PREMIUM_ROLE_ID } - ); - } - - try { - await member.roles.add( - role, - `Purchased role: ${item.name}`, - ); - successDescription += `\n\n**👑 The role ${role.toString()} has been granted to you!**`; - } catch (roleError) { - userData.wallet += totalCost; - await setEconomyData(client, guildId, userId, userData); - throw createError( - "Role assignment failed", - ErrorTypes.DISCORD_API, - "Successfully deducted money, but failed to grant the role. Your cash has been refunded.", - { roleId: PREMIUM_ROLE_ID, originalError: roleError.message } - ); - } - } else if (item.type === "upgrade") { - userData.upgrades[itemId] = true; - successDescription += `\n\n**✨ Your upgrade is now active!**`; - } else if (item.type === "consumable") { - userData.inventory[itemId] = - (userData.inventory[itemId] || 0) + quantity; - } - - await setEconomyData(client, guildId, userId, userData); - - const embed = successEmbed( - "💰 Purchase Successful", - successDescription, - ).addFields({ - name: "New Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: [MessageFlags.Ephemeral] }); - }, { command: 'buy' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/crime.js b/src/commands/Economy/crime.js deleted file mode 100644 index 239c1d9c6..000000000 --- a/src/commands/Economy/crime.js +++ /dev/null @@ -1,119 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const CRIME_COOLDOWN = 60 * 60 * 1000; -const MIN_CRIME_AMOUNT = 100; -const MAX_CRIME_AMOUNT = 2000; -const FAILURE_RATE = 0.4; -const JAIL_TIME = 2 * 60 * 60 * 1000; - -const CRIME_TYPES = [ - { name: "Pickpocketing", min: 100, max: 500, risk: 0.3 }, - { name: "Burglary", min: 300, max: 1000, risk: 0.4 }, - { name: "Bank Heist", min: 1000, max: 5000, risk: 0.6 }, - { name: "Art Theft", min: 2000, max: 10000, risk: 0.7 }, - { name: "Cybercrime", min: 5000, max: 20000, risk: 0.8 }, -]; - -export default { - data: new SlashCommandBuilder() - .setName('crime') - .setDescription('Commit a crime to earn money (risky)') - .addStringOption(option => - option - .setName('type') - .setDescription('Type of crime to commit') - .setRequired(true) - .addChoices( - { name: 'Pickpocketing', value: 'pickpocketing' }, - { name: 'Burglary', value: 'burglary' }, - { name: 'Bank Heist', value: 'bank-heist' }, - { name: 'Art Theft', value: 'art-theft' }, - { name: 'Cybercrime', value: 'cybercrime' }, - ) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - await InteractionHelper.safeDefer(interaction); - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - const userData = await getEconomyData(client, guildId, userId); - const lastCrime = userData.cooldowns?.crime || 0; - const isJailed = userData.jailedUntil && userData.jailedUntil > now; - - if (isJailed) { - const timeLeft = Math.ceil((userData.jailedUntil - now) / (1000 * 60)); - throw createError( - "User is in jail", - ErrorTypes.RATE_LIMIT, - `You're in jail for ${timeLeft} more minutes!`, - { jailTimeRemaining: userData.jailedUntil - now } - ); - } - - if (now < lastCrime + CRIME_COOLDOWN) { - const timeLeft = Math.ceil((lastCrime + CRIME_COOLDOWN - now) / (1000 * 60)); - throw createError( - "Crime cooldown active", - ErrorTypes.RATE_LIMIT, - `You need to wait ${timeLeft} more minutes before committing another crime.`, - { remaining: lastCrime + CRIME_COOLDOWN - now, cooldownType: 'crime' } - ); - } - - const crimeType = interaction.options.getString("type").toLowerCase(); - const crime = CRIME_TYPES.find( - c => c.name.toLowerCase().replace(/\s+/g, '-') === crimeType - ); - - if (!crime) { - throw createError( - "Invalid crime type", - ErrorTypes.VALIDATION, - "Please select a valid crime type.", - { crimeType } - ); - } - - const isSuccess = Math.random() > crime.risk; - const amountEarned = isSuccess - ? Math.floor(Math.random() * (crime.max - crime.min + 1)) + crime.min - : 0; - - userData.cooldowns = userData.cooldowns || {}; - userData.cooldowns.crime = now; - - if (isSuccess) { - userData.wallet = (userData.wallet || 0) + amountEarned; - - await setEconomyData(client, guildId, userId, userData); - - const embed = successEmbed( - "🕵️ Crime Successful!", - `You successfully committed ${crime.name} and earned **${amountEarned}** coins!` - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } else { - const fine = Math.floor(amountEarned * 0.2); - userData.wallet = Math.max(0, (userData.wallet || 0) - fine); - userData.jailedUntil = now + JAIL_TIME; - - await setEconomyData(client, guildId, userId, userData); - - const embed = warningEmbed( - "🚔 Crime Failed!", - `You were caught while attempting ${crime.name} and have been sent to jail!` + - `You were fined ${fine} coins and will be in jail for 2 hours.` - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } - }, { command: 'crime' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/daily.js b/src/commands/Economy/daily.js deleted file mode 100644 index 06cd82ad6..000000000 --- a/src/commands/Economy/daily.js +++ /dev/null @@ -1,103 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { getGuildConfig } from '../../services/guildConfig.js'; -import { formatDuration } from '../../utils/embeds.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const DAILY_COOLDOWN = 24 * 60 * 60 * 1000; -const DAILY_AMOUNT = 1000; -const PREMIUM_BONUS_PERCENTAGE = 0.1; - -export default { - data: new SlashCommandBuilder() - .setName('daily') - .setDescription('Claim your daily cash reward'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - logger.debug(`[ECONOMY] Daily claimed started for ${userId}`, { userId, guildId }); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data for daily", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - const lastDaily = userData.lastDaily || 0; - - if (now < lastDaily + DAILY_COOLDOWN) { - const timeRemaining = lastDaily + DAILY_COOLDOWN - now; - throw createError( - "Daily cooldown active", - ErrorTypes.RATE_LIMIT, - `You need to wait before claiming daily again. Try again in **${formatDuration(timeRemaining)}**.`, - { timeRemaining, cooldownType: 'daily' } - ); - } - - const guildConfig = await getGuildConfig(client, guildId); - const PREMIUM_ROLE_ID = guildConfig.premiumRoleId; - - let earned = DAILY_AMOUNT; - let bonusMessage = ""; - let hasPremiumRole = false; - - if ( - PREMIUM_ROLE_ID && - interaction.member && - interaction.member.roles.cache.has(PREMIUM_ROLE_ID) - ) { - const bonusAmount = Math.floor( - DAILY_AMOUNT * PREMIUM_BONUS_PERCENTAGE, - ); - earned += bonusAmount; - bonusMessage = `\n✨ **Premium Bonus:** +$${bonusAmount.toLocaleString()}`; - hasPremiumRole = true; - } - - userData.wallet = (userData.wallet || 0) + earned; - userData.lastDaily = now; - - await setEconomyData(client, guildId, userId, userData); - - logger.info(`[ECONOMY_TRANSACTION] Daily claimed`, { - userId, - guildId, - amount: earned, - newWallet: userData.wallet, - hasPremium: hasPremiumRole, - timestamp: new Date().toISOString() - }); - - const embed = successEmbed( - "✅ Daily Claimed!", - `You have claimed your daily **$${earned.toLocaleString()}**!${bonusMessage}` - ) - .addFields({ - name: "New Cash Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }) - .setFooter({ - text: hasPremiumRole - ? `Next claim in 24 hours. (Premium Active)` - : `Next claim in 24 hours.`, - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'daily' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/deposit.js b/src/commands/Economy/deposit.js deleted file mode 100644 index 56015b5fb..000000000 --- a/src/commands/Economy/deposit.js +++ /dev/null @@ -1,138 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { successEmbed, buildUserErrorEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData, getMaxBankCapacity } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export default { - data: new SlashCommandBuilder() - .setName('deposit') - .setDescription('Deposit money from your wallet into your bank') - .addStringOption(option => - option - .setName('amount') - .setDescription('Amount to deposit (number or "all")') - .setRequired(true) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const amountInput = interaction.options.getString("amount"); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - const maxBank = getMaxBankCapacity(userData); - let depositAmount; - - if (amountInput.toLowerCase() === "all") { - depositAmount = userData.wallet; - } else { - depositAmount = parseInt(amountInput); - - if (isNaN(depositAmount) || depositAmount <= 0) { - throw createError( - "Invalid deposit amount", - ErrorTypes.VALIDATION, - `Please enter a valid number or 'all'. You entered: \`${amountInput}\``, - { amountInput, userId } - ); - } - } - - if (depositAmount === 0) { - throw createError( - "Zero deposit amount", - ErrorTypes.VALIDATION, - "You have no cash to deposit.", - { userId, walletBalance: userData.wallet } - ); - } - - if (depositAmount > userData.wallet) { - depositAmount = userData.wallet; - await interaction.followUp({ - embeds: [ - buildUserErrorEmbed( - 'validation', - `You tried to deposit more than you have. Depositing your remaining cash: **$${depositAmount.toLocaleString()}**` - ) - ], - flags: MessageFlags.Ephemeral, - }); - } - - const availableSpace = maxBank - userData.bank; - - if (availableSpace <= 0) { - throw createError( - "Bank is full", - ErrorTypes.VALIDATION, - `Your bank is currently full (Max Capacity: $${maxBank.toLocaleString()}). Purchase a **Bank Upgrade** to increase your limit.`, - { maxBank, currentBank: userData.bank, userId } - ); - } - - if (depositAmount > availableSpace) { - const originalDepositAmount = depositAmount; - depositAmount = availableSpace; - - if (amountInput.toLowerCase() !== "all") { - await interaction.followUp({ - embeds: [ - buildUserErrorEmbed( - 'validation', - `You only had space for **$${depositAmount.toLocaleString()}** in your bank account (Max: $${maxBank.toLocaleString()}). The rest remains in your cash.` - ) - ], - flags: MessageFlags.Ephemeral, - }); - } - } - - if (depositAmount === 0) { - throw createError( - "No space or cash for deposit", - ErrorTypes.VALIDATION, - "The amount you tried to deposit was either 0 or exceeded your bank capacity after checking your cash balance.", - { depositAmount, availableSpace, walletBalance: userData.wallet } - ); - } - - userData.wallet -= depositAmount; - userData.bank += depositAmount; - - await setEconomyData(client, guildId, userId, userData); - - const embed = successEmbed( - 'Deposit Successful', - `You successfully deposited **$${depositAmount.toLocaleString()}** into your bank.` - ) - .addFields( - { - name: "New Cash Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }, - { - name: "New Bank Balance", - value: `$${userData.bank.toLocaleString()} / $${maxBank.toLocaleString()}`, - inline: true, - }, - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'deposit' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/economy.js b/src/commands/Economy/economy.js deleted file mode 100644 index d702eb615..000000000 --- a/src/commands/Economy/economy.js +++ /dev/null @@ -1,32 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import economyDashboard from './modules/economy_dashboard.js'; - -export default { - slashOnly: true, - data: new SlashCommandBuilder() - .setName('economy') - .setDescription('Economy management commands') - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .setDMPermission(false) - .addSubcommand(subcommand => - subcommand - .setName('dashboard') - .setDescription('Open the economy management dashboard') - ), - category: 'Economy', - - async execute(interaction, config, client) { - const deferred = await InteractionHelper.safeDefer(interaction, { - flags: MessageFlags.Ephemeral, - }); - if (!deferred) return; - - const subcommand = interaction.options.getSubcommand(); - - if (subcommand === 'dashboard') { - await economyDashboard.execute(interaction, config, client); - } - } -}; \ No newline at end of file diff --git a/src/commands/Economy/eleaderboard.js b/src/commands/Economy/eleaderboard.js deleted file mode 100644 index 76a430c1b..000000000 --- a/src/commands/Economy/eleaderboard.js +++ /dev/null @@ -1,88 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed } from '../../utils/embeds.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export default { - data: new SlashCommandBuilder() - .setName("eleaderboard") - .setDescription("View the server's top 10 richest users.") - .setDMPermission(false), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const guildId = interaction.guildId; - - logger.debug(`[ECONOMY] Leaderboard requested`, { guildId }); - - const prefix = `economy:${guildId}:`; - - let allKeys = await client.db.list(prefix); - - if (!Array.isArray(allKeys)) { - allKeys = []; - } - - if (allKeys.length === 0) { - throw createError( - "No economy data found", - ErrorTypes.VALIDATION, - "No economy data found for this server." - ); - } - - let allUserData = []; - - for (const key of allKeys) { - const userId = key.replace(prefix, ""); - const userData = await client.db.get(key); - - if (userData) { - allUserData.push({ - userId: userId, - net_worth: (userData.wallet || 0) + (userData.bank || 0), - }); - } - } - - allUserData.sort((a, b) => b.net_worth - a.net_worth); - - const topUsers = allUserData.slice(0, 10); - const userRank = - allUserData.findIndex((u) => u.userId === interaction.user.id) + - 1; - const rankEmoji = ["🥇", "🥈", "🥉"]; - const leaderboardEntries = []; - - for (let i = 0; i < topUsers.length; i++) { - const user = topUsers[i]; - const rank = i + 1; - const emoji = rankEmoji[i] || `**#${rank}**`; - - leaderboardEntries.push( - `${emoji} <@${user.userId}> - 🏦 ${user.net_worth.toLocaleString()}`, - ); - } - - logger.info(`[ECONOMY] Leaderboard generated`, { - guildId, - userCount: allUserData.length, - userRank - }); - - const description = leaderboardEntries.length > 0 - ? leaderboardEntries.join("\n") - : "No economy data is available for this server yet."; - - const embed = createEmbed({ - title: `Economy Leaderboard`, - description, - footer: `Your Rank: ${userRank > 0 ?`#${userRank}`: "No ranking data available"}`, - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'eleaderboard' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js deleted file mode 100644 index 5288f048b..000000000 --- a/src/commands/Economy/fish.js +++ /dev/null @@ -1,132 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const FISH_COOLDOWN = 45 * 60 * 1000; -const BASE_MIN_REWARD = 300; -const BASE_MAX_REWARD = 900; -const FISHING_ROD_MULTIPLIER = 1.5; - -const FISH_TYPES = [ - { name: 'Bass', emoji: '🐟', rarity: 'common' }, - { name: 'Salmon', emoji: '🐟', rarity: 'common' }, - { name: 'Trout', emoji: '🐟', rarity: 'common' }, - { name: 'Tuna', emoji: '🐠', rarity: 'uncommon' }, - { name: 'Swordfish', emoji: '🐠', rarity: 'uncommon' }, - { name: 'Octopus', emoji: '🐙', rarity: 'rare' }, - { name: 'Lobster', emoji: '🦞', rarity: 'rare' }, - { name: 'Shark', emoji: '🦈', rarity: 'epic' }, - { name: 'Whale', emoji: '🐋', rarity: 'legendary' }, -]; - -const CATCH_MESSAGES = [ - "You cast your line into the crystal clear waters...", - "You wait patiently as your bobber floats...", - "After a few minutes of waiting, you feel a tug...", - "The water ripples as something takes your bait...", - "You reel in your catch with expert precision...", -]; - -export default { - data: new SlashCommandBuilder() - .setName('fish') - .setDescription('Go fishing to catch fish and earn money'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - const userData = await getEconomyData(client, guildId, userId); - const lastFish = userData.lastFish || 0; - const hasFishingRod = userData.inventory["fishing_rod"] || 0; - - if (now < lastFish + FISH_COOLDOWN) { - const remaining = lastFish + FISH_COOLDOWN - now; - const hours = Math.floor(remaining / (1000 * 60 * 60)); - const minutes = Math.floor( - (remaining % (1000 * 60 * 60)) / (1000 * 60), - ); - - throw createError( - "Fishing cooldown active", - ErrorTypes.RATE_LIMIT, - `You're too tired to fish right now. Rest for **${hours}h ${minutes}m** before fishing again.`, - { remaining, cooldownType: 'fish' } - ); - } - - const rand = Math.random(); - let fishCaught; - - if (rand < 0.5) { - - fishCaught = FISH_TYPES.filter(f => f.rarity === 'common')[Math.floor(Math.random() * 3)]; - } else if (rand < 0.75) { - - fishCaught = FISH_TYPES.filter(f => f.rarity === 'uncommon')[Math.floor(Math.random() * 2)]; - } else if (rand < 0.9) { - - fishCaught = FISH_TYPES.filter(f => f.rarity === 'rare')[Math.floor(Math.random() * 2)]; - } else if (rand < 0.98) { - - fishCaught = FISH_TYPES.find(f => f.rarity === 'epic'); - } else { - - fishCaught = FISH_TYPES.find(f => f.rarity === 'legendary'); - } - - const baseEarned = Math.floor( - Math.random() * (BASE_MAX_REWARD - BASE_MIN_REWARD + 1) - ) + BASE_MIN_REWARD; - - let finalEarned = baseEarned; - let multiplierMessage = ""; - - if (hasFishingRod > 0) { - finalEarned = Math.floor(baseEarned * FISHING_ROD_MULTIPLIER); - multiplierMessage = `\n🎣 **Fishing Rod Bonus: +50%**`; - } - - const catchMessage = CATCH_MESSAGES[Math.floor(Math.random() * CATCH_MESSAGES.length)]; - - userData.wallet += finalEarned; - userData.lastFish = now; - - await setEconomyData(client, guildId, userId, userData); - - const rarityColors = { - common: '#95A5A6', - uncommon: '#2ECC71', - rare: '#3498DB', - epic: '#9B59B6', - legendary: '#F1C40F' - }; - - const embed = createEmbed({ - title: 'Fishing Success!', - description: `${catchMessage}\n\nYou caught a **${fishCaught.emoji} ${fishCaught.name}**! You sold it for **$${finalEarned.toLocaleString()}**!${multiplierMessage}`, - color: rarityColors[fishCaught.rarity] - }) - .addFields( - { - name: "New Cash Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }, - { - name: "Rarity", - value: fishCaught.rarity.charAt(0).toUpperCase() + fishCaught.rarity.slice(1), - inline: true, - } - ) - .setFooter({ text: `Next fishing trip available in 45 minutes.` }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'fish' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/gamble.js b/src/commands/Economy/gamble.js deleted file mode 100644 index ee9837dba..000000000 --- a/src/commands/Economy/gamble.js +++ /dev/null @@ -1,130 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const BASE_WIN_CHANCE = 0.4; -const CLOVER_WIN_BONUS = 0.1; -const CHARM_WIN_BONUS = 0.08; -const PAYOUT_MULTIPLIER = 2.0; -const GAMBLE_COOLDOWN = 5 * 60 * 1000; - -export default { - data: new SlashCommandBuilder() - .setName('gamble') - .setDescription('Gamble your money for a chance to win more') - .addIntegerOption(option => - option - .setName('amount') - .setDescription('Amount of cash to gamble') - .setRequired(true) - .setMinValue(1) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const betAmount = interaction.options.getInteger("amount"); - const now = Date.now(); - - const userData = await getEconomyData(client, guildId, userId); - const lastGamble = userData.lastGamble || 0; - let cloverCount = userData.inventory["lucky_clover"] || 0; - let charmCount = userData.inventory["lucky_charm"] || 0; - - if (now < lastGamble + GAMBLE_COOLDOWN) { - const remaining = lastGamble + GAMBLE_COOLDOWN - now; - const minutes = Math.floor(remaining / (1000 * 60)); - const seconds = Math.floor((remaining % (1000 * 60)) / 1000); - - throw createError( - "Gamble cooldown active", - ErrorTypes.RATE_LIMIT, - `You need to cool down before gambling again. Wait **${minutes}m ${seconds}s**.`, - { remaining, cooldownType: 'gamble' } - ); - } - - if (userData.wallet < betAmount) { - throw createError( - "Insufficient cash for gamble", - ErrorTypes.VALIDATION, - `You only have $${userData.wallet.toLocaleString()} cash, but you are trying to bet $${betAmount.toLocaleString()}.`, - { required: betAmount, current: userData.wallet } - ); - } - - let winChance = BASE_WIN_CHANCE; - let cloverMessage = ""; - let usedClover = false; - let usedCharm = false; - - if (cloverCount > 0) { - winChance += CLOVER_WIN_BONUS; - userData.inventory["lucky_clover"] -= 1; - cloverMessage = `\n🍀 **Lucky Clover Consumed:** Your win chance was boosted!`; - usedClover = true; - } - - else if (charmCount > 0) { - winChance += CHARM_WIN_BONUS; - userData.inventory["lucky_charm"] -= 1; - cloverMessage = `\n🍀 **Lucky Charm Used (${charmCount - 1} uses remaining):** Your win chance was boosted!`; - usedCharm = true; - } - - const win = Math.random() < winChance; - let cashChange = 0; - let resultEmbed; - - if (win) { - const amountWon = Math.floor(betAmount * PAYOUT_MULTIPLIER); -cashChange = amountWon; - - resultEmbed = successEmbed( - "🎉 You Won!", - `You successfully gambled and turned your **$${betAmount.toLocaleString()}** bet into **$${amountWon.toLocaleString()}**!${cloverMessage}`, - ); - } else { -cashChange = -betAmount; - - resultEmbed = warningEmbed( - "💔 You Lost...", - `The dice rolled against you. You lost your **$${betAmount.toLocaleString()}** bet.`, - ); - } - - userData.wallet = (userData.wallet || 0) + cashChange; -userData.lastGamble = now; - - await setEconomyData(client, guildId, userId, userData); - - const newCash = userData.wallet; - - resultEmbed.addFields({ - name: "New Cash Balance", - value: `$${newCash.toLocaleString()}`, - inline: true, - }); - - if (usedClover) { - resultEmbed.setFooter({ - text: `You have ${userData.inventory["lucky_clover"]} Lucky Clovers left. Win chance was ${Math.round(winChance * 100)}%.`, - }); - } else if (usedCharm) { - resultEmbed.setFooter({ - text: `You have ${userData.inventory["lucky_charm"]} Lucky Charm uses left. Win chance was ${Math.round(winChance * 100)}%.`, - }); - } else { - resultEmbed.setFooter({ - text: `Next gamble available in 5 minutes. Base win chance: ${Math.round(BASE_WIN_CHANCE * 100)}%.`, - }); - } - - await InteractionHelper.safeEditReply(interaction, { embeds: [resultEmbed] }); - }, { command: 'gamble' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/inventory.js b/src/commands/Economy/inventory.js deleted file mode 100644 index b7efcfec8..000000000 --- a/src/commands/Economy/inventory.js +++ /dev/null @@ -1,70 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { shopItems } from '../../config/shop/items.js'; -import { getEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const SHOP_ITEMS = shopItems; - -export default { - data: new SlashCommandBuilder() - .setName('inventory') - .setDescription('View your economy inventory'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - - logger.debug(`[ECONOMY] Inventory requested for ${userId}`, { userId, guildId }); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data for inventory", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - const inventory = userData.inventory || {}; - - let inventoryDescription = "Your inventory is currently empty."; - - if (Object.keys(inventory).length > 0) { - inventoryDescription = Object.entries(inventory) - .filter( - ([itemId, quantity]) => { - const item = SHOP_ITEMS.find(i => i.id === itemId); - return quantity > 0 && item; - } - ) - .map( - ([itemId, quantity]) => { - const item = SHOP_ITEMS.find(i => i.id === itemId); - return `**${item.name}:** ${quantity}x`; - } - ) - .join("\n"); - } - - logger.info(`[ECONOMY] Inventory retrieved`, { - userId, - guildId, - itemCount: Object.keys(inventory).length - }); - - const embed = createEmbed({ - title: `🎒 ${interaction.user.username}'s Inventory`, - description: inventoryDescription, - }).setThumbnail(interaction.user.displayAvatarURL()); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'inventory' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/mine.js b/src/commands/Economy/mine.js deleted file mode 100644 index fda8ba1dd..000000000 --- a/src/commands/Economy/mine.js +++ /dev/null @@ -1,93 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const MINE_COOLDOWN = 60 * 60 * 1000; -const BASE_MIN_REWARD = 400; -const BASE_MAX_REWARD = 1200; -const PICKAXE_MULTIPLIER = 1.2; -const DIAMOND_PICKAXE_MULTIPLIER = 2.0; - -const MINE_LOCATIONS = [ - "abandoned gold mine", - "dark, damp cave", - "backyard rock quarry", - "volcanic obsidian vent", - "deep-sea mineral trench", -]; - -export default { - data: new SlashCommandBuilder() - .setName('mine') - .setDescription('Go mining to earn money'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - const userData = await getEconomyData(client, guildId, userId); - const lastMine = userData.lastMine || 0; - const hasDiamondPickaxe = userData.inventory["diamond_pickaxe"] || 0; - const hasPickaxe = userData.inventory["pickaxe"] || 0; - - if (now < lastMine + MINE_COOLDOWN) { - const remaining = lastMine + MINE_COOLDOWN - now; - const hours = Math.floor(remaining / (1000 * 60 * 60)); - const minutes = Math.floor( - (remaining % (1000 * 60 * 60)) / (1000 * 60), - ); - - throw createError( - "Mining cooldown active", - ErrorTypes.RATE_LIMIT, - `Your pickaxe is cooling down. Wait for **${hours}h ${minutes}m** before mining again.`, - { remaining, cooldownType: 'mine' } - ); - } - - const baseEarned = - Math.floor( - Math.random() * (BASE_MAX_REWARD - BASE_MIN_REWARD + 1), - ) + BASE_MIN_REWARD; - - let finalEarned = baseEarned; - let multiplierMessage = ""; - - if (hasDiamondPickaxe > 0) { - finalEarned = Math.floor(baseEarned * DIAMOND_PICKAXE_MULTIPLIER); - multiplierMessage = `\n💎 **Diamond Pickaxe Bonus: +100%**`; - } else if (hasPickaxe > 0) { - finalEarned = Math.floor(baseEarned * PICKAXE_MULTIPLIER); - multiplierMessage = `\n⛏️ **Pickaxe Bonus: +20%**`; - } - - const location = - MINE_LOCATIONS[ - Math.floor(Math.random() * MINE_LOCATIONS.length) - ]; - - userData.wallet += finalEarned; -userData.lastMine = now; - - await setEconomyData(client, guildId, userId, userData); - - const embed = successEmbed( - "💰 Mining Expedition Successful!", - `You explored a **${location}** and managed to find minerals worth **$${finalEarned.toLocaleString()}**!${multiplierMessage}`, - ) - .addFields({ - name: "New Cash Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }) - .setFooter({ text: `Next mine available in 1 hour.` }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'mine' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/modules/economy_dashboard.js b/src/commands/Economy/modules/economy_dashboard.js deleted file mode 100644 index 2bb7d74b7..000000000 --- a/src/commands/Economy/modules/economy_dashboard.js +++ /dev/null @@ -1,536 +0,0 @@ -import { - ActionRowBuilder, - StringSelectMenuBuilder, - StringSelectMenuOptionBuilder, - ModalBuilder, - TextInputBuilder, - TextInputStyle, - UserSelectMenuBuilder, - LabelBuilder, - ButtonBuilder, - ButtonStyle, - MessageFlags, - ComponentType, - EmbedBuilder, -} from 'discord.js'; -import { getColor, BotConfig } from '../../../config/bot.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { successEmbed } from '../../../utils/embeds.js'; -import { logger } from '../../../utils/logger.js'; -import { TitanBotError, ErrorTypes, replyUserError } from '../../../utils/errorHandler.js'; -import { getEconomyData, addMoney, removeMoney, getMaxBankCapacity } from '../../../utils/economy.js'; -import fs from 'fs/promises'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -async function buildDashboardEmbed(guild, client) { - const currencySymbol = BotConfig.economy.currency.symbol; - const currencyName = BotConfig.economy.currency.name; - - let totalInCirculation = 0; - let userCount = 0; - - try { - const economyKeys = await client.db.list(`economy:${guild.id}:`); - - if (economyKeys && economyKeys.length > 0) { - for (const key of economyKeys) { - const userId = key.split(':').pop(); - - const member = await guild.members.fetch(userId).catch(() => null); - if (member?.user?.bot) continue; - - const userData = await client.db.get(key, {}); - if (userData) { - totalInCirculation += (userData.wallet || 0) + (userData.bank || 0); - userCount++; - } - } - } - } catch (error) { - logger.error('Error calculating economy stats:', error); - } - - const avgBalance = userCount > 0 ? Math.floor(totalInCirculation / userCount) : 0; - - return new EmbedBuilder() - .setTitle('💰 Economy Dashboard') - .setDescription(`Manage the economy system for **${guild.name}**.\nSelect an option below to perform an action.`) - .setColor(getColor('economy')) - .addFields( - { name: '💰 Total in Circulation', value: `\`${currencySymbol}${totalInCirculation.toLocaleString()}\``, inline: true }, - { name: '👥 Active Users', value: `\`${userCount.toLocaleString()}\``, inline: true }, - { name: '📊 Average Balance', value: `\`${currencySymbol}${avgBalance.toLocaleString()}\``, inline: true }, - { name: '💱 Currency Symbol', value: `\`${currencySymbol}\``, inline: true }, - { name: '📝 Currency Name', value: `\`${currencyName}\``, inline: true }, - ) - .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) - .setTimestamp(); -} - -function buildSelectMenu(guildId) { - return new StringSelectMenuBuilder() - .setCustomId(`economy_dashboard_${guildId}`) - .setPlaceholder('Select an action...') - .addOptions( - new StringSelectMenuOptionBuilder() - .setLabel('Add Currency') - .setDescription('Add currency to a user\'s wallet or bank') - .setValue('add_currency') - .setEmoji('💰'), - new StringSelectMenuOptionBuilder() - .setLabel('Remove Currency') - .setDescription('Remove currency from a user\'s wallet or bank') - .setValue('remove_currency') - .setEmoji('💸'), - new StringSelectMenuOptionBuilder() - .setLabel('Change Currency Symbol') - .setDescription('Change the currency symbol (e.g., $, €, £)') - .setValue('change_currency') - .setEmoji('💱'), - new StringSelectMenuOptionBuilder() - .setLabel('Change Currency Name') - .setDescription('Change the currency name (e.g., coins, credits)') - .setValue('change_name') - .setEmoji('📝'), - ); -} - -async function refreshDashboard(rootInteraction, guild, client) { - const selectMenu = buildSelectMenu(guild.id); - await InteractionHelper.safeEditReply(rootInteraction, { - embeds: [await buildDashboardEmbed(guild, client)], - components: [ - new ActionRowBuilder().addComponents(selectMenu), - ], - }).catch(() => {}); -} - -async function updateConfigFile(currencySymbol, currencyName) { - try { - const configPath = path.join(__dirname, '../../config/bot.js'); - let configContent = await fs.readFile(configPath, 'utf-8'); - - configContent = configContent.replace( - /symbol:\s*"[^"]*"/, - `symbol: "${currencySymbol}"` - ); - - configContent = configContent.replace( - /name:\s*"[^"]*",\s*\/\/\s*Currency display name/, - `name: "${currencyName}", // Currency display name` - ); - - configContent = configContent.replace( - /namePlural:\s*"[^"]*",\s*\/\/\s*Plural display name/, - `namePlural: "${currencyName}s", // Plural display name` - ); - - await fs.writeFile(configPath, configContent, 'utf-8'); - logger.info('Config file updated successfully'); - return true; - } catch (error) { - logger.error('Error updating config file:', error); - return false; - } -} - -export default { - prefixOnly: false, - async execute(interaction, config, client) { - try { - const guild = interaction.guild; - const selectMenu = buildSelectMenu(guild.id); - const selectRow = new ActionRowBuilder().addComponents(selectMenu); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [await buildDashboardEmbed(guild, client)], - components: [selectRow], - }); - - const collector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.StringSelect, - filter: i => - i.user.id === interaction.user.id && i.customId === `economy_dashboard_${guild.id}`, - time: 600_000, - }); - - collector.on('collect', async selectInteraction => { - const selectedOption = selectInteraction.values[0]; - try { - switch (selectedOption) { - case 'add_currency': - await handleAddCurrency(selectInteraction, interaction, guild, client); - break; - case 'remove_currency': - await handleRemoveCurrency(selectInteraction, interaction, guild, client); - break; - case 'change_currency': - await handleChangeCurrency(selectInteraction, interaction, guild); - break; - case 'change_name': - await handleChangeName(selectInteraction, interaction, guild); - break; - } - } catch (error) { - if (error instanceof TitanBotError) { - logger.debug(`Economy dashboard validation error: ${error.message}`); - } else { - logger.error('Unexpected economy dashboard error:', error); - } - - const errorMessage = - error instanceof TitanBotError - ? error.userMessage || 'An error occurred while processing your selection.' - : 'An unexpected error occurred while processing your request.'; - - if (!selectInteraction.replied && !selectInteraction.deferred) { - await selectInteraction.deferUpdate().catch(() => {}); - } - - await replyUserError(selectInteraction, { - type: ErrorTypes.UNKNOWN, - message: errorMessage, - }).catch(() => {}); - } - }); - - collector.on('end', async (collected, reason) => { - if (reason === 'time') { - const timeoutEmbed = new EmbedBuilder() - .setTitle('Dashboard Timed Out') - .setDescription('This dashboard has been closed due to inactivity. Please run the command again to continue.') - .setColor(getColor('error')); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [timeoutEmbed], - components: [], - }).catch(() => {}); - } - }); - } catch (error) { - if (error instanceof TitanBotError) throw error; - logger.error('Unexpected error in economy_dashboard:', error); - throw new TitanBotError( - `Economy dashboard failed: ${error.message}`, - ErrorTypes.UNKNOWN, - 'Failed to open the economy dashboard.', - ); - } - }, -}; - -async function handleAddCurrency(selectInteraction, rootInteraction, guild, client) { - const modal = new ModalBuilder() - .setCustomId(`economy_add_currency_${guild.id}`) - .setTitle('Add Currency'); - - const userSelect = new UserSelectMenuBuilder() - .setCustomId('target_user') - .setPlaceholder('Select a user...') - .setMinValues(1) - .setMaxValues(1) - .setRequired(true); - - const userLabel = new LabelBuilder() - .setLabel('Target User') - .setDescription('User to add currency to') - .setUserSelectMenuComponent(userSelect); - - const amountInput = new TextInputBuilder() - .setCustomId('amount') - .setLabel('Amount to add') - .setStyle(TextInputStyle.Short) - .setPlaceholder('100') - .setMinLength(1) - .setMaxLength(10) - .setRequired(true); - - const typeInput = new TextInputBuilder() - .setCustomId('type') - .setLabel('Type (wallet or bank)') - .setStyle(TextInputStyle.Short) - .setPlaceholder('wallet') - .setMinLength(1) - .setMaxLength(5) - .setRequired(true); - - modal.addLabelComponents(userLabel); - modal.addComponents( - new ActionRowBuilder().addComponents(amountInput), - new ActionRowBuilder().addComponents(typeInput), - ); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => i.customId === `economy_add_currency_${guild.id}` && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - const userId = submitted.fields.getField('target_user').values[0]; - const amount = parseInt(submitted.fields.getTextInputValue('amount').trim(), 10); - const type = submitted.fields.getTextInputValue('type').trim().toLowerCase(); - - if (isNaN(amount) || amount <= 0) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Amount must be a positive number.' }); - return; - } - - if (type !== 'wallet' && type !== 'bank') { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Type must be either "wallet" or "bank".' }); - return; - } - - const member = await guild.members.fetch(userId).catch(() => null); - if (!member) { - await replyUserError(submitted, { type: ErrorTypes.USER_INPUT, message: 'The specified user is not in this server.' }); - return; - } - - if (member.user.bot) { - await replyUserError(submitted, { type: ErrorTypes.UNKNOWN, message: 'Bots do not have economy accounts.' }); - return; - } - - const result = await addMoney(client, guild.id, userId, amount, type); - - if (!result.success) { - await replyUserError(submitted, { type: ErrorTypes.UNKNOWN, message: 'result.error || \'An error occurred.\'' }); - return; - } - - const currencySymbol = BotConfig.economy.currency.symbol; - - await submitted.reply({ - embeds: [successEmbed('Currency Added', `Successfully added ${currencySymbol}${amount.toLocaleString()} to ${member.user.tag}'s ${type}.\n**New Balance:** ${currencySymbol}${result.newBalance.toLocaleString()}`)], - flags: MessageFlags.Ephemeral, - }); - - logger.info(`[ECONOMY_DASHBOARD] Currency added`, { - adminId: submitted.user.id, - targetUserId: userId, - amount, - type, - newBalance: result.newBalance - }); - - await refreshDashboard(rootInteraction, guild, client); -} - -async function handleRemoveCurrency(selectInteraction, rootInteraction, guild, client) { - const modal = new ModalBuilder() - .setCustomId(`economy_remove_currency_${guild.id}`) - .setTitle('Remove Currency'); - - const userSelect = new UserSelectMenuBuilder() - .setCustomId('target_user') - .setPlaceholder('Select a user...') - .setMinValues(1) - .setMaxValues(1) - .setRequired(true); - - const userLabel = new LabelBuilder() - .setLabel('Target User') - .setDescription('User to remove currency from') - .setUserSelectMenuComponent(userSelect); - - const amountInput = new TextInputBuilder() - .setCustomId('amount') - .setLabel('Amount to remove') - .setStyle(TextInputStyle.Short) - .setPlaceholder('100') - .setMinLength(1) - .setMaxLength(10) - .setRequired(true); - - const typeInput = new TextInputBuilder() - .setCustomId('type') - .setLabel('Type (wallet or bank)') - .setStyle(TextInputStyle.Short) - .setPlaceholder('wallet') - .setMinLength(1) - .setMaxLength(5) - .setRequired(true); - - modal.addLabelComponents(userLabel); - modal.addComponents( - new ActionRowBuilder().addComponents(amountInput), - new ActionRowBuilder().addComponents(typeInput), - ); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => i.customId === `economy_remove_currency_${guild.id}` && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - const userId = submitted.fields.getField('target_user').values[0]; - const amount = parseInt(submitted.fields.getTextInputValue('amount').trim(), 10); - const type = submitted.fields.getTextInputValue('type').trim().toLowerCase(); - - if (isNaN(amount) || amount <= 0) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Amount must be a positive number.' }); - return; - } - - if (type !== 'wallet' && type !== 'bank') { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Type must be either "wallet" or "bank".' }); - return; - } - - const member = await guild.members.fetch(userId).catch(() => null); - if (!member) { - await replyUserError(submitted, { type: ErrorTypes.USER_INPUT, message: 'The specified user is not in this server.' }); - return; - } - - if (member.user.bot) { - await replyUserError(submitted, { type: ErrorTypes.UNKNOWN, message: 'Bots do not have economy accounts.' }); - return; - } - - const result = await removeMoney(client, guild.id, userId, amount, type); - - if (!result.success) { - await replyUserError(submitted, { type: ErrorTypes.UNKNOWN, message: 'result.error || \'An error occurred.\'' }); - return; - } - - const currencySymbol = BotConfig.economy.currency.symbol; - - await submitted.reply({ - embeds: [successEmbed('Currency Removed', `Successfully removed ${currencySymbol}${amount.toLocaleString()} from ${member.user.tag}'s ${type}.\n**New Balance:** ${currencySymbol}${result.newBalance.toLocaleString()}`)], - flags: MessageFlags.Ephemeral, - }); - - logger.info(`[ECONOMY_DASHBOARD] Currency removed`, { - adminId: submitted.user.id, - targetUserId: userId, - amount, - type, - newBalance: result.newBalance - }); - - await refreshDashboard(rootInteraction, guild, client); -} - -async function handleChangeCurrency(selectInteraction, rootInteraction, guild) { - const modal = new ModalBuilder() - .setCustomId(`economy_change_currency_${guild.id}`) - .setTitle('Change Currency Symbol'); - - const symbolInput = new TextInputBuilder() - .setCustomId('currency_symbol') - .setLabel('New Currency Symbol') - .setStyle(TextInputStyle.Short) - .setValue(BotConfig.economy.currency.symbol) - .setPlaceholder('$') - .setMinLength(1) - .setMaxLength(3) - .setRequired(true); - - modal.addComponents(new ActionRowBuilder().addComponents(symbolInput)); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => i.customId === `economy_change_currency_${guild.id}` && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - const newSymbol = submitted.fields.getTextInputValue('currency_symbol').trim(); - - if (newSymbol.length === 0 || newSymbol.length > 3) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Currency symbol must be 1-3 characters long.' }); - return; - } - - const success = await updateConfigFile(newSymbol, BotConfig.economy.currency.name); - - if (!success) { - await replyUserError(submitted, { type: ErrorTypes.UNKNOWN, message: 'Could not update the config file. Please check the logs.' }); - return; - } - - await submitted.reply({ - embeds: [successEmbed('Currency Symbol Updated', `Currency symbol changed to **${newSymbol}**.\n\n**Note:** The bot needs to be restarted for changes to take effect.`)], - flags: MessageFlags.Ephemeral, - }); - - logger.info(`[ECONOMY_DASHBOARD] Currency symbol changed`, { - adminId: submitted.user.id, - oldSymbol: BotConfig.economy.currency.symbol, - newSymbol - }); -} - -async function handleChangeName(selectInteraction, rootInteraction, guild) { - const modal = new ModalBuilder() - .setCustomId(`economy_change_name_${guild.id}`) - .setTitle('Change Currency Name'); - - const nameInput = new TextInputBuilder() - .setCustomId('currency_name') - .setLabel('New Currency Name') - .setStyle(TextInputStyle.Short) - .setValue(BotConfig.economy.currency.name) - .setPlaceholder('coins') - .setMinLength(1) - .setMaxLength(20) - .setRequired(true); - - modal.addComponents(new ActionRowBuilder().addComponents(nameInput)); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => i.customId === `economy_change_name_${guild.id}` && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - const newName = submitted.fields.getTextInputValue('currency_name').trim(); - - if (newName.length === 0 || newName.length > 20) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Currency name must be 1-20 characters long.' }); - return; - } - - const success = await updateConfigFile(BotConfig.economy.currency.symbol, newName); - - if (!success) { - await replyUserError(submitted, { type: ErrorTypes.UNKNOWN, message: 'Could not update the config file. Please check the logs.' }); - return; - } - - await submitted.reply({ - embeds: [successEmbed('Currency Name Updated', `Currency name changed to **${newName}**.\n\n**Note:** The bot needs to be restarted for changes to take effect.`)], - flags: MessageFlags.Ephemeral, - }); - - logger.info(`[ECONOMY_DASHBOARD] Currency name changed`, { - adminId: submitted.user.id, - oldName: BotConfig.economy.currency.name, - newName - }); -} \ No newline at end of file diff --git a/src/commands/Economy/modules/shop_browse.js b/src/commands/Economy/modules/shop_browse.js deleted file mode 100644 index 0313ddb24..000000000 --- a/src/commands/Economy/modules/shop_browse.js +++ /dev/null @@ -1,90 +0,0 @@ -import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, EmbedBuilder, MessageFlags } from 'discord.js'; -import { shopItems } from '../../../config/shop/items.js'; -import { getColor } from '../../../config/bot.js'; -import { logger } from '../../../utils/logger.js'; - -export default { - async execute(interaction, config, client) { - try { - const TARGET_MAX_PAGES = 3; - const ITEMS_PER_PAGE = Math.max(1, Math.ceil(shopItems.length / TARGET_MAX_PAGES)); - const totalPages = Math.ceil(shopItems.length / ITEMS_PER_PAGE); - let currentPage = 1; - - const createShopEmbed = (page) => { - const startIndex = (page - 1) * ITEMS_PER_PAGE; - const pageItems = shopItems.slice(startIndex, startIndex + ITEMS_PER_PAGE); - const embed = new EmbedBuilder() - .setTitle('Store') - .setColor(getColor('primary')) - .setDescription('Use `/buy item_id: quantity:` to purchase an item.'); - pageItems.forEach(item => { - embed.addFields({ - name: `${item.name} (${item.id})`, - value: `**Type:** ${item.type}\n **Price:** $${item.price.toLocaleString()}\n${item.description}`, - inline: false, - }); - }); - embed.setFooter({ text: `Page ${page}/${totalPages}` }); - return embed; - }; - - const createShopComponents = (page) => { - if (totalPages <= 1) return []; - return [ - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('shop_prev') - .setLabel('⬅️ Previous') - .setStyle(ButtonStyle.Secondary) - .setDisabled(page === 1), - new ButtonBuilder() - .setCustomId('shop_next') - .setLabel('Next ➡️') - .setStyle(ButtonStyle.Secondary) - .setDisabled(page === totalPages), - ), - ]; - }; - - const message = await interaction.reply({ - embeds: [createShopEmbed(currentPage)], - components: createShopComponents(currentPage), - flags: 0, - }); - - const collector = message.createMessageComponentCollector({ - componentType: ComponentType.Button, - time: 300000, - }); - - collector.on('collect', async (buttonInteraction) => { - if (buttonInteraction.user.id !== interaction.user.id) { - await buttonInteraction.reply({ content: '❌ You cannot use these buttons. Run `/shop` to get your own shop view.', flags: 64 }); - return; - } - const { customId } = buttonInteraction; - if (customId === 'shop_prev' || customId === 'shop_next') { - await buttonInteraction.deferUpdate(); - if (customId === 'shop_prev' && currentPage > 1) currentPage--; - else if (customId === 'shop_next' && currentPage < totalPages) currentPage++; - await buttonInteraction.editReply({ - embeds: [createShopEmbed(currentPage)], - components: createShopComponents(currentPage), - }); - } - }); - - collector.on('end', async () => { - try { - const disabledComponents = createShopComponents(currentPage); - disabledComponents.forEach(row => row.components.forEach(btn => btn.setDisabled(true))); - await message.edit({ components: disabledComponents }); - } catch (_) {} - }); - } catch (error) { - logger.error('shop_browse error:', error); - await interaction.reply({ content: '❌ An error occurred while loading the shop.', flags: MessageFlags.Ephemeral }); - } - }, -}; \ No newline at end of file diff --git a/src/commands/Economy/modules/shop_config_setrole.js b/src/commands/Economy/modules/shop_config_setrole.js deleted file mode 100644 index 53a8bbca9..000000000 --- a/src/commands/Economy/modules/shop_config_setrole.js +++ /dev/null @@ -1,30 +0,0 @@ -import { PermissionsBitField } from 'discord.js'; -import { successEmbed } from '../../../utils/embeds.js'; -import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { logger } from '../../../utils/logger.js'; - -export default { - async execute(interaction, config, client) { - if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need **Manage Server** permissions to set the premium role.' }); - } - - const role = interaction.options.getRole('role'); - const guildId = interaction.guildId; - - try { - const currentConfig = await getGuildConfig(client, guildId); - currentConfig.premiumRoleId = role.id; - await setGuildConfig(client, guildId, currentConfig); - - return InteractionHelper.safeReply(interaction, { - embeds: [successEmbed('Premium Role Set', `The **Premium Shop Role** has been set to ${role.toString()}. Members who purchase the Premium Role item will be granted this role.`)], - ephemeral: true, - }); - } catch (error) { - logger.error('shop_config_setrole error:', error); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Could not save the guild configuration.' }); - } - }, -}; \ No newline at end of file diff --git a/src/commands/Economy/pay.js b/src/commands/Economy/pay.js deleted file mode 100644 index 5e8e90486..000000000 --- a/src/commands/Economy/pay.js +++ /dev/null @@ -1,149 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, addMoney, removeMoney, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import EconomyService from '../../services/economyService.js'; - -export default { - data: new SlashCommandBuilder() - .setName('pay') - .setDescription('Pay another user some of your cash') - .addUserOption(option => - option - .setName('user') - .setDescription('User to pay') - .setRequired(true) - ) - .addIntegerOption(option => - option - .setName('amount') - .setDescription('Amount to pay') - .setRequired(true) - .setMinValue(1) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const senderId = interaction.user.id; - const receiver = interaction.options.getUser("user"); - const amount = interaction.options.getInteger("amount"); - const guildId = interaction.guildId; - - logger.debug(`[ECONOMY] Pay command initiated`, { - senderId, - receiverId: receiver.id, - amount, - guildId - }); - - if (receiver.bot) { - throw createError( - "Cannot pay bot", - ErrorTypes.VALIDATION, - "You cannot pay a bot.", - { receiverId: receiver.id, isBot: true } - ); - } - - if (receiver.id === senderId) { - throw createError( - "Cannot pay self", - ErrorTypes.VALIDATION, - "You cannot pay yourself.", - { senderId, receiverId: receiver.id } - ); - } - - if (amount <= 0) { - throw createError( - "Invalid payment amount", - ErrorTypes.VALIDATION, - "Amount must be greater than zero.", - { amount, senderId } - ); - } - - const [senderData, receiverData] = await Promise.all([ - getEconomyData(client, guildId, senderId), - getEconomyData(client, guildId, receiver.id) - ]); - - if (!senderData) { - throw createError( - "Failed to load sender economy data", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId: senderId, guildId } - ); - } - - if (!receiverData) { - throw createError( - "Failed to load receiver economy data", - ErrorTypes.DATABASE, - "Failed to load the receiver's economy data. Please try again later.", - { userId: receiver.id, guildId } - ); - } - - const result = await EconomyService.transferMoney( - client, - guildId, - senderId, - receiver.id, - amount - ); - - const updatedSenderData = await getEconomyData(client, guildId, senderId); - const updatedReceiverData = await getEconomyData(client, guildId, receiver.id); - - const embed = successEmbed( - 'Payment Successful', - `You successfully paid **${receiver.username}** the amount of **$${amount.toLocaleString()}**!` - ) - .addFields( - { - name: "Payment Amount", - value: `$${amount.toLocaleString()}`, - inline: true, - }, - { - name: "Your New Balance", - value: `$${updatedSenderData.wallet.toLocaleString()}`, - inline: true, - }, - ) - .setFooter({ - text: `Paid to ${receiver.tag}`, - iconURL: receiver.displayAvatarURL(), - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - - logger.info(`[ECONOMY] Payment sent successfully`, { - senderId, - receiverId: receiver.id, - amount, - senderBalance: updatedSenderData.wallet, - receiverBalance: updatedReceiverData.wallet - }); - - try { - const receiverEmbed = createEmbed({ - title: "Incoming Payment!", - description: `${interaction.user.username} paid you **$${amount.toLocaleString()}**.` - }).addFields({ - name: "Your New Cash", - value: `$${updatedReceiverData.wallet.toLocaleString()}`, - inline: true, - }); - await receiver.send({ embeds: [receiverEmbed] }); - } catch (e) { - logger.warn(`Could not DM user ${receiver.id}: ${e.message}`); - } - }, { command: 'pay' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/rob.js b/src/commands/Economy/rob.js deleted file mode 100644 index dec0ceff3..000000000 --- a/src/commands/Economy/rob.js +++ /dev/null @@ -1,153 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { successEmbed, warningEmbed, buildUserErrorEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const ROB_COOLDOWN = 4 * 60 * 60 * 1000; -const BASE_ROB_SUCCESS_CHANCE = 0.25; -const ROB_PERCENTAGE = 0.15; -const FINE_PERCENTAGE = 0.1; - -export default { - data: new SlashCommandBuilder() - .setName('rob') - .setDescription('Attempt to rob another user (very risky)') - .addUserOption(option => - option - .setName('user') - .setDescription('User to rob') - .setRequired(true) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const robberId = interaction.user.id; - const victimUser = interaction.options.getUser("user"); - const guildId = interaction.guildId; - const now = Date.now(); - - if (robberId === victimUser.id) { - throw createError( - "Cannot rob self", - ErrorTypes.VALIDATION, - "You cannot rob yourself.", - { robberId, victimId: victimUser.id } - ); - } - - if (victimUser.bot) { - throw createError( - "Cannot rob bot", - ErrorTypes.VALIDATION, - "You cannot rob a bot.", - { victimId: victimUser.id, isBot: true } - ); - } - - const robberData = await getEconomyData(client, guildId, robberId); - const victimData = await getEconomyData(client, guildId, victimUser.id); - - if (!robberData || !victimData) { - throw createError( - "Failed to load economy data", - ErrorTypes.DATABASE, - "Failed to load economy data. Please try again later.", - { robberId: !!robberData, victimId: !!victimData, guildId } - ); - } - - const lastRob = robberData.lastRob || 0; - - if (now < lastRob + ROB_COOLDOWN) { - const remaining = lastRob + ROB_COOLDOWN - now; - const hours = Math.floor(remaining / (1000 * 60 * 60)); - const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60)); - - throw createError( - "Robbery cooldown active", - ErrorTypes.RATE_LIMIT, - `You need to lay low. Wait **${hours}h ${minutes}m** before attempting another robbery.`, - { remaining, hours, minutes, cooldownType: 'rob' } - ); - } - - if (victimData.wallet < 500) { - throw createError( - "Victim too poor", - ErrorTypes.VALIDATION, - `${victimUser.username} is too poor. They need at least $500 cash to be worth robbing.`, - { victimWallet: victimData.wallet, required: 500 } - ); - } - - const hasSafe = victimData.inventory["personal_safe"] || 0; - - if (hasSafe > 0) { - robberData.lastRob = now; - await setEconomyData(client, guildId, robberId, robberData); - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - warningEmbed( - 'Robbery Blocked', - `${victimUser.username} was prepared! Your attempt failed because they own a **Personal Safe**. You got away clean but didn't gain anything.` - ) - ], - }); - } - - const isSuccessful = Math.random() < BASE_ROB_SUCCESS_CHANCE; - let resultEmbed; - - if (isSuccessful) { - const amountStolen = Math.floor(victimData.wallet * ROB_PERCENTAGE); - - robberData.wallet = (robberData.wallet || 0) + amountStolen; - victimData.wallet = (victimData.wallet || 0) - amountStolen; - - resultEmbed = successEmbed( - 'Robbery Successful', - `You successfully stole **$${amountStolen.toLocaleString()}** from ${victimUser.username}!` - ); - } else { - const fineAmount = Math.floor((robberData.wallet || 0) * FINE_PERCENTAGE); - - if ((robberData.wallet || 0) < fineAmount) { - robberData.wallet = 0; - } else { - robberData.wallet = (robberData.wallet || 0) - fineAmount; - } - - resultEmbed = buildUserErrorEmbed( - 'unknown', - `You failed the robbery and were caught! You were fined **$${fineAmount.toLocaleString()}** of your own cash.`, - { titleOverride: 'Robbery Failed' } - ); - } - - robberData.lastRob = now; - - await setEconomyData(client, guildId, robberId, robberData); - await setEconomyData(client, guildId, victimUser.id, victimData); - - resultEmbed - .addFields( - { - name: `Your New Cash (${interaction.user.username})`, - value: `$${robberData.wallet.toLocaleString()}`, - inline: true, - }, - { - name: `Victim's New Cash (${victimUser.username})`, - value: `$${victimData.wallet.toLocaleString()}`, - inline: true, - }, - ) - .setFooter({ text: `Next robbery available in 4 hours.` }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [resultEmbed] }); - }, { command: 'rob' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/shop-config.js b/src/commands/Economy/shop-config.js deleted file mode 100644 index fb3be41dc..000000000 --- a/src/commands/Economy/shop-config.js +++ /dev/null @@ -1,28 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import shopConfigSetrole from './modules/shop_config_setrole.js'; - -export default { - slashOnly: true, - data: new SlashCommandBuilder() - .setName('shop-config') - .setDescription('Configure shop settings. (Manage Server required)') - .addSubcommand(subcommand => - subcommand - .setName('setrole') - .setDescription('Set the Discord role granted when the Premium Role shop item is purchased.') - .addRoleOption(option => - option - .setName('role') - .setDescription('The role to grant for Premium Role purchases.') - .setRequired(true), - ), - ), - - async execute(interaction, config, client) { - const subcommand = interaction.options.getSubcommand(); - - if (subcommand === 'setrole') { - return shopConfigSetrole.execute(interaction, config, client); - } - }, -}; diff --git a/src/commands/Economy/shop.js b/src/commands/Economy/shop.js deleted file mode 100644 index 16b2b62d9..000000000 --- a/src/commands/Economy/shop.js +++ /dev/null @@ -1,13 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import shopBrowse from './modules/shop_browse.js'; - -export default { - slashOnly: true, - data: new SlashCommandBuilder() - .setName('shop') - .setDescription('Browse the economy shop.'), - - async execute(interaction, config, client) { - return shopBrowse.execute(interaction, config, client); - }, -}; diff --git a/src/commands/Economy/slut.js b/src/commands/Economy/slut.js deleted file mode 100644 index 98b83e369..000000000 --- a/src/commands/Economy/slut.js +++ /dev/null @@ -1,188 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const SLUT_COOLDOWN = 45 * 60 * 1000; - -const SLUT_ACTIVITIES = [ - { name: "Cam Stream", min: 120, max: 450, risk: 0.2 }, - { name: "Private Dance Session", min: 220, max: 700, risk: 0.25 }, - { name: "After-Hours Club Host", min: 320, max: 900, risk: 0.3 }, - { name: "VIP Companion Booking", min: 550, max: 1400, risk: 0.35 }, - { name: "Exclusive Livestream", min: 850, max: 2200, risk: 0.4 }, -]; - -const POSITIVE_OUTCOMES = [ - "Your stream blew up and tips poured in.", - "A VIP booking paid far above average.", - "Your after-hours shift was packed and profitable.", - "Premium requests came through and your payout jumped.", -]; - -const FINE_OUTCOMES = [ - "Venue security issued a compliance fine.", - "A moderation strike triggered a platform fee.", - "You were flagged and had to pay a penalty.", -]; - -const ROBBED_OUTCOMES = [ - "A fake buyer chargeback wiped part of your earnings.", - "A scam booking cleaned out a chunk of your cash.", - "You got baited by a fraud account and lost money.", -]; - -const LOSS_OUTCOMES = [ - "The set flopped and you had to cover operating costs.", - "You burned budget on prep and made no return.", - "The shift went sideways and left you in the red.", -]; - -function randomInt(min, max) { - return Math.floor(Math.random() * (max - min + 1)) + min; -} - -function randomChoice(items) { - return items[Math.floor(Math.random() * items.length)]; -} - -function resolveOutcome(activity, wallet) { - const successChance = Math.max(0.35, 0.55 - activity.risk * 0.2); - const fineChance = 0.22; - const robbedChance = 0.2; - const roll = Math.random(); - - if (roll < successChance) { - const amount = randomInt(activity.min, activity.max); - return { - type: 'payout', - delta: amount, - message: randomChoice(POSITIVE_OUTCOMES), - title: `${activity.name} - Payout` - }; - } - - const remainingAfterSuccess = roll - successChance; - - if (remainingAfterSuccess < fineChance) { - const maxFine = Math.min(wallet, Math.max(150, Math.floor(activity.max * 0.4))); - const minFine = Math.min(maxFine, Math.max(50, Math.floor(activity.min * 0.2))); - const amount = maxFine > 0 ? randomInt(minFine, maxFine) : 0; - return { - type: 'fine', - delta: -amount, - message: randomChoice(FINE_OUTCOMES), - title: `${activity.name} - Fined` - }; - } - - if (remainingAfterSuccess < fineChance + robbedChance) { - const maxRobbed = Math.min(wallet, Math.max(200, Math.floor(wallet * 0.35))); - const minRobbed = Math.min(maxRobbed, Math.max(75, Math.floor(wallet * 0.1))); - const amount = maxRobbed > 0 ? randomInt(minRobbed, maxRobbed) : 0; - return { - type: 'robbed', - delta: -amount, - message: randomChoice(ROBBED_OUTCOMES), - title: `${activity.name} - Robbed` - }; - } - - const maxLoss = Math.min(wallet, Math.max(100, Math.floor(activity.max * 0.3))); - const minLoss = Math.min(maxLoss, Math.max(40, Math.floor(activity.min * 0.15))); - const amount = maxLoss > 0 ? randomInt(minLoss, maxLoss) : 0; - return { - type: 'loss', - delta: -amount, - message: randomChoice(LOSS_OUTCOMES), - title: `${activity.name} - Loss` - }; -} - -export default { - data: new SlashCommandBuilder() - .setName('slut') - .setDescription('Take a risky provocative job for random payout or loss'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - logger.debug(`[ECONOMY] Slut command started for ${userId}`, { userId, guildId }); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data for slut command", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - const lastSlut = userData.lastSlut || 0; - - if (now - lastSlut < SLUT_COOLDOWN) { - const remainingTime = lastSlut + SLUT_COOLDOWN - now; - throw createError( - "Slut cooldown active", - ErrorTypes.RATE_LIMIT, - `You need to wait before you can work again! Try again in **${Math.ceil(remainingTime / 60000)}** minutes.`, - { timeRemaining: remainingTime, cooldownType: 'slut' } - ); - } - - const activity = randomChoice(SLUT_ACTIVITIES); - - const outcome = resolveOutcome(activity, userData.wallet || 0); - - userData.lastSlut = now; - userData.totalSluts = (userData.totalSluts || 0) + 1; - userData.totalSlutEarnings = (userData.totalSlutEarnings || 0) + Math.max(0, outcome.delta); - userData.totalSlutLosses = (userData.totalSlutLosses || 0) + Math.max(0, -outcome.delta); - - if (outcome.type !== 'payout') { - userData.failedSluts = (userData.failedSluts || 0) + 1; - } - - userData.wallet = Math.max(0, (userData.wallet || 0) + outcome.delta); - - await setEconomyData(client, guildId, userId, userData); - - logger.info(`[ECONOMY_TRANSACTION] Slut activity resolved`, { - userId, - guildId, - activity: activity.name, - outcomeType: outcome.type, - amountDelta: outcome.delta, - newWallet: userData.wallet, - timestamp: new Date().toISOString() - }); - - const amountLabel = `${outcome.delta >= 0 ? '+' : '-'}$${Math.abs(outcome.delta).toLocaleString()}`; - const summaryLines = [ - `${outcome.message}`, - `💸 **Net Result:** ${amountLabel}`, - `💳 **Current Balance:** $${userData.wallet.toLocaleString()}`, - `📊 **Total Sessions:** ${userData.totalSluts}`, - `💵 **Total Earned:** $${(userData.totalSlutEarnings || 0).toLocaleString()}`, - `🧾 **Total Lost:** $${(userData.totalSlutLosses || 0).toLocaleString()}` - ]; - - const embed = createEmbed({ - title: outcome.title, - description: summaryLines.join('\n'), - color: outcome.delta >= 0 ? 'success' : 'error', - timestamp: true - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'slut' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/withdraw.js b/src/commands/Economy/withdraw.js deleted file mode 100644 index bf45af02a..000000000 --- a/src/commands/Economy/withdraw.js +++ /dev/null @@ -1,85 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData, getMaxBankCapacity } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName('withdraw') - .setDescription('Withdraw money from your bank to your wallet') - .addIntegerOption(option => - option - .setName('amount') - .setDescription('Amount to withdraw') - .setRequired(true) - .setMinValue(1) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - await InteractionHelper.safeDefer(interaction); - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const amountInput = interaction.options.getInteger("amount"); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - let withdrawAmount = amountInput; - - if (withdrawAmount <= 0) { - throw createError( - "Invalid withdrawal amount", - ErrorTypes.VALIDATION, - "You must withdraw a positive amount.", - { amount: withdrawAmount, userId } - ); - } - - if (withdrawAmount > userData.bank) { - withdrawAmount = userData.bank; - } - - if (withdrawAmount === 0) { - throw createError( - "Empty bank account", - ErrorTypes.VALIDATION, - "Your bank account is empty.", - { userId, bankBalance: userData.bank } - ); - } - - userData.wallet += withdrawAmount; - userData.bank -= withdrawAmount; - - await setEconomyData(client, guildId, userId, userData); - - const embed = successEmbed( - 'Withdrawal Successful', - `You successfully withdrew **$${withdrawAmount.toLocaleString()}** from your bank.` - ) - .addFields( - { - name: "New Cash Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }, - { - name: "New Bank Balance", - value: `$${userData.bank.toLocaleString()}`, - inline: true, - }, - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'withdraw' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/work.js b/src/commands/Economy/work.js deleted file mode 100644 index 1f7f1979b..000000000 --- a/src/commands/Economy/work.js +++ /dev/null @@ -1,122 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const WORK_COOLDOWN = 30 * 60 * 1000; -const MIN_WORK_AMOUNT = 50; -const MAX_WORK_AMOUNT = 300; -const LAPTOP_MULTIPLIER = 1.5; -const WORK_JOBS = [ - "Software Developer", - "Barista", - "Janitor", - "YouTuber", - "Discord Bot Developer", - "Cashier", - "Pizza Delivery Driver", - "Librarian", - "Gardener", - "Data Analyst", -]; - -export default { - data: new SlashCommandBuilder() - .setName('work') - .setDescription('Work to earn some money'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data for work", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - logger.debug(`[ECONOMY] Work command started for ${userId}`, { userId, guildId }); - - const lastWork = userData.lastWork || 0; - const inventory = userData.inventory || {}; - const extraWorkShifts = inventory["extra_work"] || 0; - const hasLaptop = inventory["laptop"] || 0; - - let cooldownActive = now < lastWork + WORK_COOLDOWN; - let usedConsumable = false; - - if (cooldownActive) { - if (extraWorkShifts > 0) { - inventory["extra_work"] = (inventory["extra_work"] || 0) - 1; - usedConsumable = true; - } else { - const remaining = lastWork + WORK_COOLDOWN - now; - throw createError( - "Work cooldown active", - ErrorTypes.RATE_LIMIT, - `You're working too fast! Wait **${Math.floor(remaining / 3600000)}h ${Math.floor((remaining % 3600000) / 60000)}m** before working again.`, - { timeRemaining: remaining, cooldownType: 'work' } - ); - } - } - - let earned = Math.floor(Math.random() * (MAX_WORK_AMOUNT - MIN_WORK_AMOUNT + 1)) + MIN_WORK_AMOUNT; - const job = WORK_JOBS[Math.floor(Math.random() * WORK_JOBS.length)]; - - let multiplierMessage = ""; - if (hasLaptop > 0) { - earned = Math.floor(earned * LAPTOP_MULTIPLIER); - multiplierMessage = "\n💻 **Laptop Bonus:** +50% earnings!"; - } - - userData.wallet = (userData.wallet || 0) + earned; - userData.lastWork = now; - - await setEconomyData(client, guildId, userId, userData); - - logger.info(`[ECONOMY_TRANSACTION] Work completed`, { - userId, - guildId, - amount: earned, - job, - usedConsumable, - hasLaptop: hasLaptop > 0, - newWallet: userData.wallet, - timestamp: new Date().toISOString() - }); - - const embed = successEmbed( - "💼 Work Complete!", - `You worked as a **${job}** and earned **$${earned.toLocaleString()}**!${multiplierMessage}` - ) - .addFields( - { - name: "New Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }, - { - name: "Next Work", - value: ``, - inline: true, - } - ) - .setFooter({ - text: `Requested by ${interaction.user.tag}`, - iconURL: interaction.user.displayAvatarURL(), - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'work' }) -}; \ No newline at end of file diff --git a/src/commands/Fun/fight.js b/src/commands/Fun/fight.js deleted file mode 100644 index 0e64e8771..000000000 --- a/src/commands/Fun/fight.js +++ /dev/null @@ -1,92 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { successEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -const rand = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; -const EMBED_DESCRIPTION_LIMIT = 4096; - -export default { - data: new SlashCommandBuilder() - .setName("fight") - .setDescription("Starts a simulated 1v1 text-based battle.") - .addUserOption((option) => - option - .setName("opponent") - .setDescription("The user to fight.") - .setRequired(true), - ), - category: 'Fun', - - async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction); - - const challenger = interaction.user; - const opponent = interaction.options.getUser("opponent"); - - if (challenger.id === opponent.id) { - const embed = warningEmbed( - `**${challenger.username}**, you can't fight yourself! That's a draw before it even starts.`, - "⚔️ Invalid Challenge" - ); - return await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } - - if (opponent.bot) { - const embed = warningEmbed( - "You can't fight bots! Challenge a real person instead.", - "⚔️ Invalid Opponent" - ); - return await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } - - const winner = rand(0, 1) === 0 ? challenger : opponent; - const loser = winner.id === challenger.id ? opponent : challenger; - const rounds = rand(3, 7); - const damage = rand(10, 50); - - const log = []; - log.push( - `💥 **${challenger.username}** challenges **${opponent.username}** to a duel! (Best of ${rounds} rounds)`, - ); - - for (let i = 1; i <= rounds; i++) { - const attacker = rand(0, 1) === 0 ? challenger : opponent; - const target = attacker.id === challenger.id ? opponent : challenger; - const action = [ - "throws a wild punch", - "lands a critical hit", - "uses a weak spell", - "parries and counterattacks", - ][rand(0, 3)]; - log.push( - `\n**Round ${i}:** ${attacker.username} ${action} on ${target.username} for ${rand(1, damage)} damage!`, - ); - } - - const outcomeText = log.join("\n"); - const winnerText = `👑 **${winner.username}** has defeated ${loser.username} and claims the victory!`; - const fullDescription = `${outcomeText}\n\n${winnerText}`; - - const description = fullDescription.length <= EMBED_DESCRIPTION_LIMIT - ? fullDescription - : `${fullDescription.slice(0, EMBED_DESCRIPTION_LIMIT - 15)}\n\n...`; - - const embed = successEmbed( - description, - "🏆 Duel Complete!" - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.debug(`Fight command executed between ${challenger.id} and ${opponent.id} in guild ${interaction.guildId}`); - } catch (error) { - logger.error('Fight command error:', error); - await handleInteractionError(interaction, error, { - commandName: 'fight', - source: 'fight_command' - }); - } - }, -}; \ No newline at end of file diff --git a/src/commands/Fun/flip.js b/src/commands/Fun/flip.js deleted file mode 100644 index 115a011ea..000000000 --- a/src/commands/Fun/flip.js +++ /dev/null @@ -1,33 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("flip") - .setDescription("Flips a coin (Heads or Tails)."), - category: 'Fun', - - async execute(interaction, config, client) { - try { - const result = Math.random() < 0.5 ? "Heads" : "Tails"; - const emoji = result === "Heads" ? "🪙" : "🔮"; - - const embed = successEmbed( - "Heads or Tails?", - `The coin landed on... **${result}** ${emoji}!`, - ); - - await InteractionHelper.safeReply(interaction, { embeds: [embed] }); - logger.debug(`Flip command executed by user ${interaction.user.id} in guild ${interaction.guildId}`); - } catch (error) { - logger.error('Flip command error:', error); - await handleInteractionError(interaction, error, { - commandName: 'flip', - source: 'flip_command' - }); - } - }, -}; \ No newline at end of file diff --git a/src/commands/Fun/roll.js b/src/commands/Fun/roll.js deleted file mode 100644 index b502d958f..000000000 --- a/src/commands/Fun/roll.js +++ /dev/null @@ -1,88 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("roll") - .setDescription("Rolls dice using standard notation (e.g., 2d20, 1d6 + 5).") - .addStringOption((option) => - option - .setName("notation") - .setDescription("The dice notation (e.g., 2d6, 1d20 + 4)") - .setRequired(true) - .setMaxLength(50), - ), - category: 'Fun', - - async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction); - - const notation = interaction.options - .getString("notation") - .toLowerCase() - .replace(/\s/g, ""); - - const match = notation.match(/^(\d*)d(\d+)([\+\-]\d+)?$/); - - if (!match) { - throw new TitanBotError( - `Invalid dice notation: ${notation}`, - ErrorTypes.USER_INPUT, - 'Invalid notation. Use format like `1d20` or `3d6+5`.' - ); - } - - const numDice = parseInt(match[1] || "1", 10); - const numSides = parseInt(match[2], 10); - const modifier = parseInt(match[3] || "0", 10); - - if (numDice < 1 || numDice > 20) { - throw new TitanBotError( - `Too many dice requested: ${numDice}`, - ErrorTypes.VALIDATION, - 'Please keep the number of dice between 1 and 20.' - ); - } - - if (numSides < 1 || numSides > 1000) { - throw new TitanBotError( - `Invalid number of sides: ${numSides}`, - ErrorTypes.VALIDATION, - 'Please keep the number of sides between 1 and 1000.' - ); - } - - let rolls = []; - let totalRoll = 0; - - for (let i = 0; i < numDice; i++) { - const roll = Math.floor(Math.random() * numSides) + 1; - rolls.push(roll); - totalRoll += roll; - } - - const finalTotal = totalRoll + modifier; - - const resultsDetail = - numDice > 1 ? `**Rolls:** ${rolls.join(" + ")}\n` : ""; - const modifierText = modifier !== 0 ? `+ (${modifier})` : ""; - - const embed = successEmbed( - `🎲 Rolling ${numDice}d${numSides}${modifier !== 0 ? match[3] : ""}`, - `${resultsDetail}**Total Roll:** ${totalRoll}${modifierText} = **${finalTotal}**`, - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.debug(`Roll command executed by user ${interaction.user.id} with notation ${notation} in guild ${interaction.guildId}`); - } catch (error) { - await handleInteractionError(interaction, error, { - commandName: 'roll', - source: 'roll_command' - }); - } - }, -}; \ No newline at end of file diff --git a/src/commands/Moderation/massban.js b/src/commands/Moderation/massban.js deleted file mode 100644 index 1ef637286..000000000 --- a/src/commands/Moderation/massban.js +++ /dev/null @@ -1,193 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; -import { createEmbed, successEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logModerationAction } from '../../utils/moderation.js'; -import { logger } from '../../utils/logger.js'; -import { ModerationService } from '../../services/moderationService.js'; -import { TitanBotError } from '../../utils/errorHandler.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("massban") - .setDescription("Ban multiple users from the server at once") - .addStringOption(option => - option - .setName("users") - .setDescription("User IDs or mentions to ban (separated by spaces or commas)") - .setRequired(true) - ) - .addStringOption(option => - option.setName("reason") - .setDescription("Reason for the mass ban") - .setRequired(false) - ) - .addIntegerOption(option => - option - .setName("delete_days") - .setDescription("Number of days of messages to delete (0-7)") - .setMinValue(0) - .setMaxValue(7) - .setRequired(false) - ) - .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers), - category: "moderation", - abuseProtection: { maxAttempts: 3, windowMs: 60_000 }, - - async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Massban interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'massban' - }); - return; - } - - if (!interaction.member.permissions.has(PermissionFlagsBits.BanMembers)) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You do not have permission to ban members.' }); - } - - const usersInput = interaction.options.getString("users"); - const reason = interaction.options.getString("reason") || "Mass ban - No reason provided"; - const deleteDays = interaction.options.getInteger("delete_days") || 0; - - try { - const userIds = usersInput -.replace(/<@!?(\d+)>/g, '$1') -.split(/[\s,]+/) -.filter(id => id && /^\d+$/.test(id)) -.slice(0, 20); - - if (userIds.length === 0) { - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please provide valid user IDs or mentions. Maximum 20 users at once.' }); - } - - if (userIds.includes(interaction.user.id)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You cannot include yourself in a mass ban.' }); - } - - if (userIds.includes(client.user.id)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You cannot include the bot in a mass ban.' }); - } - - const results = { - successful: [], - failed: [], - skipped: [] - }; - - for (const userId of userIds) { - try { - const user = await client.users.fetch(userId).catch(() => null); - - if (!user) { - results.failed.push({ userId, reason: "User not found" }); - continue; - } - - const member = await interaction.guild.members.fetch(userId).catch(() => null); - - if (member) { - const modCheck = ModerationService.validateHierarchy(interaction.member, member, 'ban'); - if (!modCheck.valid) { - results.skipped.push({ - user: user.tag, - userId, - reason: ModerationService.buildHierarchySkipReason(interaction.member, member, 'ban'), - }); - continue; - } - - const botCheck = ModerationService.validateBotHierarchy(member, 'ban'); - if (!botCheck.valid) { - results.skipped.push({ - user: user.tag, - userId, - reason: ModerationService.buildHierarchySkipReason(interaction.member, member, 'ban', 'bot'), - }); - continue; - } - } - - await interaction.guild.members.ban(userId, { - reason: reason, - deleteMessageDays: deleteDays - }); - - results.successful.push({ - user: user.tag, - userId - }); - - await logModerationAction({ - client, - guild: interaction.guild, - event: { - action: "Member Banned", - target: `${user.tag} (${user.id})`, - executor: `${interaction.user.tag} (${interaction.user.id})`, - reason: `${reason} (Mass Ban)`, - metadata: { - userId: user.id, - moderatorId: interaction.user.id, - massBan: true, - permanent: true - } - } - }); - - } catch (error) { - logger.error(`Failed to ban user ${userId}:`, error); - const reason = error instanceof TitanBotError - ? (error.userMessage || error.message) - : (error.message || "Unknown error"); - results.failed.push({ - userId, - reason, - }); - } - } - - let description = `**Mass Ban Results:**\n\n`; - - if (results.successful.length > 0) { - description += `✅ **Successfully Banned (${results.successful.length}):**\n`; - results.successful.forEach(result => { - description += `• ${result.user} (${result.userId})\n`; - }); - description += '\n'; - } - - if (results.skipped.length > 0) { - description += `⚠️ **Skipped (${results.skipped.length}):**\n`; - results.skipped.forEach(result => { - description += `• ${result.user} - ${result.reason}\n`; - }); - description += '\n'; - } - - if (results.failed.length > 0) { - description += `❌ **Failed (${results.failed.length}):**\n`; - results.failed.forEach(result => { - description += `• ${result.userId} - ${result.reason}\n`; - }); - } - - const embed = results.successful.length > 0 ? successEmbed : warningEmbed; - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - embed( - `🔨 Mass Ban Completed`, - description - ) - ] - }); - - } catch (error) { - logger.error("Error in massban command:", error); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while processing the mass ban. Please try again later.' }); - } - } -}; \ No newline at end of file diff --git a/src/commands/Moderation/masskick.js b/src/commands/Moderation/masskick.js deleted file mode 100644 index 6d95bbda5..000000000 --- a/src/commands/Moderation/masskick.js +++ /dev/null @@ -1,185 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; -import { createEmbed, successEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logModerationAction } from '../../utils/moderation.js'; -import { logger } from '../../utils/logger.js'; -import { ModerationService } from '../../services/moderationService.js'; -import { TitanBotError } from '../../utils/errorHandler.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("masskick") - .setDescription("Kick multiple users from the server at once") - .addStringOption(option => - option - .setName("users") - .setDescription("User IDs or mentions to kick (separated by spaces or commas)") - .setRequired(true) - ) - .addStringOption(option => - option.setName("reason") - .setDescription("Reason for the mass kick") - .setRequired(false) - ) - .setDefaultMemberPermissions(PermissionFlagsBits.KickMembers), - category: "moderation", - abuseProtection: { maxAttempts: 3, windowMs: 60_000 }, - - async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Masskick interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'masskick' - }); - return; - } - - if (!interaction.member.permissions.has(PermissionFlagsBits.KickMembers)) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You do not have permission to kick members.' }); - } - - const usersInput = interaction.options.getString("users"); - const reason = interaction.options.getString("reason") || "Mass kick - No reason provided"; - - try { - const userIds = usersInput -.replace(/<@!?(\d+)>/g, '$1') -.split(/[\s,]+/) -.filter(id => id && /^\d+$/.test(id)) -.slice(0, 20); - - if (userIds.length === 0) { - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please provide valid user IDs or mentions. Maximum 20 users at once.' }); - } - - if (userIds.includes(interaction.user.id)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You cannot include yourself in a mass kick.' }); - } - - if (userIds.includes(client.user.id)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You cannot include the bot in a mass kick.' }); - } - - const results = { - successful: [], - failed: [], - skipped: [] - }; - - for (const userId of userIds) { - try { - const member = await interaction.guild.members.fetch(userId).catch(() => null); - - if (!member) { - results.failed.push({ userId, reason: "User not in server" }); - continue; - } - - const modCheck = ModerationService.validateHierarchy(interaction.member, member, 'kick'); - if (!modCheck.valid) { - results.skipped.push({ - user: member.user.tag, - userId, - reason: ModerationService.buildHierarchySkipReason(interaction.member, member, 'kick'), - }); - continue; - } - - const botCheck = ModerationService.validateBotHierarchy(member, 'kick'); - if (!botCheck.valid) { - results.skipped.push({ - user: member.user.tag, - userId, - reason: ModerationService.buildHierarchySkipReason(interaction.member, member, 'kick', 'bot'), - }); - continue; - } - - if (!member.kickable) { - results.skipped.push({ - user: member.user.tag, - userId, - reason: 'Target has Admin or a managed role, or bot lacks Kick Members', - }); - continue; - } - - await member.kick(reason); - - results.successful.push({ - user: member.user.tag, - userId - }); - - await logModerationAction({ - client, - guild: interaction.guild, - event: { - action: "Member Kicked", - target: `${member.user.tag} (${member.user.id})`, - executor: `${interaction.user.tag} (${interaction.user.id})`, - reason: `${reason} (Mass Kick)`, - metadata: { - userId: member.user.id, - moderatorId: interaction.user.id, - massKick: true - } - } - }); - - } catch (error) { - logger.error(`Failed to kick user ${userId}:`, error); - const reason = error instanceof TitanBotError - ? (error.userMessage || error.message) - : (error.message || "Unknown error"); - results.failed.push({ - userId, - reason, - }); - } - } - - let description = `**Mass Kick Results:**\n\n`; - - if (results.successful.length > 0) { - description += `✅ **Successfully Kicked (${results.successful.length}):**\n`; - results.successful.forEach(result => { - description += `• ${result.user} (${result.userId})\n`; - }); - description += '\n'; - } - - if (results.skipped.length > 0) { - description += `⚠️ **Skipped (${results.skipped.length}):**\n`; - results.skipped.forEach(result => { - description += `• ${result.user} - ${result.reason}\n`; - }); - description += '\n'; - } - - if (results.failed.length > 0) { - description += `❌ **Failed (${results.failed.length}):**\n`; - results.failed.forEach(result => { - description += `• ${result.userId} - ${result.reason}\n`; - }); - } - - const embed = results.successful.length > 0 ? successEmbed : warningEmbed; - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - embed( - `👢 Mass Kick Completed`, - description - ) - ] - }); - - } catch (error) { - logger.error("Error in masskick command:", error); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while processing the mass kick. Please try again later.' }); - } - } -}; \ No newline at end of file diff --git a/src/commands/Moderation/offences.js b/src/commands/Moderation/offences.js new file mode 100644 index 000000000..267ed383a --- /dev/null +++ b/src/commands/Moderation/offences.js @@ -0,0 +1,128 @@ +import { + SlashCommandBuilder, + PermissionFlagsBits, + EmbedBuilder, + MessageFlags, +} from 'discord.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { getFromDb, setInDb } from '../../utils/database.js'; + +const OFFENCE_KEY = (guildId, userId) => `offences_${guildId}_${userId}`; +const OFFENCE_RESET_MS = 60 * 24 * 60 * 60 * 1000; // 60 days + +const ESCALATION_LADDER = [ + { level: 1, label: 'Verbal Warning or Written Warning' }, + { level: 2, label: 'Mute for 30 minutes' }, + { level: 3, label: 'Kick OR Extend Mute by 24 hours' }, + { level: 4, label: 'Temporary Mute for 3 days' }, + { level: 5, label: 'Permanent Mute' }, +]; + +export default { + data: new SlashCommandBuilder() + .setName('offences') + .setDescription('View or manage a member\'s offence history') + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers) + + .addSubcommand(sub => + sub.setName('view') + .setDescription('View a member\'s offence history') + .addUserOption(opt => + opt.setName('member') + .setDescription('The member to check') + .setRequired(true) + ) + ) + + .addSubcommand(sub => + sub.setName('clear') + .setDescription('Clear a member\'s offence history') + .addUserOption(opt => + opt.setName('member') + .setDescription('The member to clear offences for') + .setRequired(true) + ) + ), + + category: 'moderation', + + async execute(interaction, config, client) { + try { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const sub = interaction.options.getSubcommand(); + const user = interaction.options.getUser('member'); + + if (sub === 'view') { + const raw = await getFromDb(OFFENCE_KEY(interaction.guild.id, user.id), { offences: [] }); + const now = Date.now(); + const activeOffences = (raw.offences || []).filter(o => now - new Date(o.date).getTime() < OFFENCE_RESET_MS); + const expiredOffences = (raw.offences || []).filter(o => now - new Date(o.date).getTime() >= OFFENCE_RESET_MS); + + const nextLevel = Math.min(activeOffences.length + 1, ESCALATION_LADDER.length); + const nextEscalation = ESCALATION_LADDER[nextLevel - 1]; + + const embed = new EmbedBuilder() + .setColor(activeOffences.length === 0 ? 0x2ECC71 : activeOffences.length >= 4 ? 0xE74C3C : 0xF39C12) + .setTitle(`📋 Offence History — ${user.username}`) + .setThumbnail(user.displayAvatarURL({ dynamic: true })) + .addFields( + { name: 'Active Offences', value: `${activeOffences.length} / ${ESCALATION_LADDER.length}`, inline: true }, + { name: 'Expired Offences', value: `${expiredOffences.length}`, inline: true }, + { name: 'Resets After', value: '60 days of no offences', inline: true }, + ); + + if (activeOffences.length > 0) { + embed.addFields({ + name: 'Offence Log', + value: activeOffences.map((o, i) => + `**#${i + 1}** — ${o.type} • Case \`${o.caseCode}\` • ` + ).join('\n'), + inline: false, + }); + } else { + embed.addFields({ name: 'Offence Log', value: 'No active offences.', inline: false }); + } + + embed.addFields({ + name: 'Next Escalation', + value: activeOffences.length >= ESCALATION_LADDER.length + ? '⚠️ Max level reached — Perm Mute' + : `Level ${nextEscalation.level}: ${nextEscalation.label}`, + inline: false, + }); + + await InteractionHelper.universalReply(interaction, { embeds: [embed] }); + + } else if (sub === 'clear') { + if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { + throw new TitanBotError('No permission', ErrorTypes.PERMISSIONS, 'You need Manage Server permission to clear offences.', { subtype: 'missing_permission' }); + } + + await setInDb(OFFENCE_KEY(interaction.guild.id, user.id), { offences: [], lastOffence: null }); + + await InteractionHelper.universalReply(interaction, { + embeds: [ + new EmbedBuilder() + .setColor(0x2ECC71) + .setTitle('✅ Offences Cleared') + .setDescription(`All offences for <@${user.id}> have been cleared.`) + .setTimestamp(), + ], + }); + + logger.info('Offences cleared', { + userId: interaction.user.id, + targetId: user.id, + guildId: interaction.guild.id, + }); + } + + } catch (error) { + logger.error('Offences command error:', error); + await handleInteractionError(interaction, error, { subtype: 'offences_failed' }); + } + }, +}; diff --git a/src/commands/Moderation/punish.js b/src/commands/Moderation/punish.js new file mode 100644 index 000000000..09d6cb177 --- /dev/null +++ b/src/commands/Moderation/punish.js @@ -0,0 +1,501 @@ +import { + SlashCommandBuilder, + PermissionFlagsBits, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + MessageFlags, +} from 'discord.js'; +import { logModerationAction, generateCaseId, storeModerationCase } from '../../utils/moderation.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { getFromDb, setInDb } from '../../utils/database.js'; +import { scheduleRoleRemoval } from '../../services/punishmentScheduler.js'; + +const PUNISHMENT_LOG_CHANNEL_ID = '1517145309015314442'; +const WARNING_ROLE_ID = '1519540353881866404'; +const MUTED_ROLE_ID = '1516865012554141801'; +const SUSPENSION_ROLE_ID = '1519537206182809743'; + +// Key to store saved roles per user +const SAVED_ROLES_KEY = (guildId, userId) => `saved_roles_${guildId}_${userId}`; + +// Escalation system +const OFFENCE_KEY = (guildId, userId) => `offences_${guildId}_${userId}`; +const OFFENCE_RESET_MS = 60 * 24 * 60 * 60 * 1000; // 60 days + +const ESCALATION_LADDER = [ + { level: 1, label: 'Verbal Warning or Written Warning', types: ['Verbal Warning', 'Written Warning'] }, + { level: 2, label: 'Mute for 30 minutes', types: ['Mute/Timeout'], duration: '30m' }, + { level: 3, label: 'Kick OR Extend Mute by 24 hours', types: ['Kick', 'Mute/Timeout'], duration: '24h' }, + { level: 4, label: 'Temporary Mute for 3 days', types: ['Mute/Timeout'], duration: '3d', auto: true }, + { level: 5, label: 'Permanent Mute', types: ['Permanent Ban'], auto: true }, +]; + +async function getOffenceData(guildId, userId) { + const data = await getFromDb(OFFENCE_KEY(guildId, userId), { offences: [], lastOffence: null }); + + // Filter out offences older than 60 days + const now = Date.now(); + data.offences = (data.offences || []).filter(o => now - new Date(o.date).getTime() < OFFENCE_RESET_MS); + + return data; +} + +async function recordOffence(guildId, userId, punishmentType, caseCode) { + const data = await getOffenceData(guildId, userId); + data.offences.push({ + type: punishmentType, + caseCode, + date: new Date().toISOString(), + }); + data.lastOffence = new Date().toISOString(); + await setInDb(OFFENCE_KEY(guildId, userId), data); + return data.offences.length; +} + +function getNextEscalation(offenceCount) { + const nextLevel = Math.min(offenceCount + 1, ESCALATION_LADDER.length); + return ESCALATION_LADDER[nextLevel - 1]; +} + +async function saveAndRemoveRoles(member, guild) { + try { + // Get all roles except @everyone, bot-managed roles, and punishment roles + const EXCLUDED_ROLES = [MUTED_ROLE_ID, SUSPENSION_ROLE_ID, WARNING_ROLE_ID]; + const rolesToSave = member.roles.cache + .filter(r => r.id !== guild.id && !r.managed && !EXCLUDED_ROLES.includes(r.id)) + .map(r => r.id); + + if (rolesToSave.length === 0) return; + + // Save roles to database + await setInDb(SAVED_ROLES_KEY(guild.id, member.id), { + roles: rolesToSave, + savedAt: new Date().toISOString(), + }); + + // Remove all saved roles + await member.roles.remove(rolesToSave).catch(() => {}); + } catch (err) { + throw new Error(`Failed to save/remove roles: ${err.message}`); + } +} + +const PUNISHMENT_TYPES = [ + 'Verbal Warning', + 'Written Warning', + 'Mute/Timeout', + 'Kick', + 'Temporary Ban', + 'Permanent Ban', + 'Termination', + 'Demotion', + 'Suspension', +]; + +async function executePunishmentAction(member, punishmentType, durationStr, guild) { + const results = []; + + try { + switch (punishmentType) { + case 'Verbal Warning': + case 'Written Warning': { + // Add warning role + try { + await member.roles.add(WARNING_ROLE_ID); + results.push(`✅ Warning role added`); + } catch (roleErr) { + results.push(`❌ Failed to add warning role: ${roleErr.message}`); + } + break; + } + + case 'Mute/Timeout': + case 'Temporary Ban': { + // Save and remove all roles, then add muted role + try { + await saveAndRemoveRoles(member, guild); + results.push(`✅ All roles saved and removed`); + } catch (saveErr) { + results.push(`⚠️ Role save failed: ${saveErr.message}`); + } + try { + await member.roles.add(MUTED_ROLE_ID); + results.push(`✅ Muted role added`); + } catch (roleErr) { + results.push(`❌ Failed to add muted role: ${roleErr.message}`); + } + + // Schedule role removal if duration provided + if (durationStr) { + const ms = parseDuration(durationStr); + if (ms) { + const removeAt = new Date(Date.now() + ms).toISOString(); + await scheduleRoleRemoval(guild.id, member.id, MUTED_ROLE_ID, removeAt, 'pending'); + results.push(`⏰ Muted role will be removed after ${formatDuration(durationStr)}`); + } + } + break; + } + + case 'Kick': { + await member.kick().catch(() => {}); + results.push(`✅ Member kicked`); + break; + } + + case 'Permanent Ban': + case 'Termination': { + // Save and remove all roles, then add muted role permanently + try { + await saveAndRemoveRoles(member, guild); + results.push(`✅ All roles saved and removed`); + } catch (saveErr) { + results.push(`⚠️ Role save failed: ${saveErr.message}`); + } + try { + await member.roles.add(MUTED_ROLE_ID); + results.push(`✅ Muted role added permanently`); + } catch (roleErr) { + results.push(`❌ Failed to add muted role: ${roleErr.message}`); + } + break; + } + + case 'Suspension': { + // Save and remove all roles, then add suspension role + try { + await saveAndRemoveRoles(member, guild); + results.push(`✅ All roles saved and removed`); + } catch (saveErr) { + results.push(`⚠️ Role save failed: ${saveErr.message}`); + } + try { + await member.roles.add(SUSPENSION_ROLE_ID); + results.push(`✅ Suspension role added`); + } catch (roleErr) { + results.push(`❌ Failed to add suspension role: ${roleErr.message}`); + } + + // Schedule role removal if duration provided + if (durationStr) { + const ms = parseDuration(durationStr); + if (ms) { + const removeAt = new Date(Date.now() + ms).toISOString(); + await scheduleRoleRemoval(guild.id, member.id, SUSPENSION_ROLE_ID, removeAt, 'pending'); + results.push(`⏰ Suspension role will be removed after ${formatDuration(durationStr)}`); + } + } + break; + } + + case 'Demotion': { + results.push(`📝 Demotion logged`); + break; + } + } + } catch (err) { + results.push(`⚠️ Action partially failed: ${err.message}`); + } + + return results; +} + +function generateCaseCode() { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + return Array.from({ length: 8 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); +} + +function parseDuration(durationStr) { + if (!durationStr) return null; + const match = durationStr.match(/^(\d+)\s*(s|m|h|d|w)$/i); + if (!match) return null; + const value = parseInt(match[1]); + const unit = match[2].toLowerCase(); + const multipliers = { s: 1000, m: 60000, h: 3600000, d: 86400000, w: 604800000 }; + return value * multipliers[unit]; +} + +function formatDuration(durationStr) { + if (!durationStr) return null; + const match = durationStr.match(/^(\d+)\s*(s|m|h|d|w)$/i); + if (!match) return durationStr; + const value = parseInt(match[1]); + const unit = match[2].toLowerCase(); + const labels = { s: 'Second', m: 'Minute', h: 'Hour', d: 'Day', w: 'Week' }; + return `${value} ${labels[unit]}${value !== 1 ? 's' : ''}`; +} + +export default { + data: new SlashCommandBuilder() + .setName('punish') + .setDescription('Issue a punishment and log it to the punishment channel') + .addUserOption(option => + option.setName('member').setDescription('The member to punish').setRequired(true) + ) + .addStringOption(option => + option + .setName('type') + .setDescription('Type of punishment') + .setRequired(true) + .addChoices( + ...PUNISHMENT_TYPES.map(t => ({ name: t, value: t })) + ) + ) + .addStringOption(option => + option.setName('reason').setDescription('Reason for the punishment').setRequired(true) + ) + .addStringOption(option => + option + .setName('duration') + .setDescription('Duration (e.g. 30d, 7d, 24h, 1w) — leave blank for permanent') + .setRequired(false) + ) + .addAttachmentOption(option => + option.setName('evidence1').setDescription('Evidence image #1').setRequired(false) + ) + .addAttachmentOption(option => + option.setName('evidence2').setDescription('Evidence image #2').setRequired(false) + ) + .addAttachmentOption(option => + option.setName('evidence3').setDescription('Evidence image #3').setRequired(false) + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), + + category: 'moderation', + + async execute(interaction, config, client) { + try { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const member = interaction.options.getMember('member'); + const user = interaction.options.getUser('member'); + const punishmentType = interaction.options.getString('type'); + const reason = interaction.options.getString('reason'); + const durationStr = interaction.options.getString('duration'); + const evidence1 = interaction.options.getAttachment('evidence1'); + const evidence2 = interaction.options.getAttachment('evidence2'); + const evidence3 = interaction.options.getAttachment('evidence3'); + + if (!user) { + throw new TitanBotError('Missing target member', ErrorTypes.USER_INPUT, 'You must specify a member to punish.', { subtype: 'invalid_user' }); + } + + if (user.id === interaction.user.id) { + throw new TitanBotError('Self punishment', ErrorTypes.USER_INPUT, 'You cannot punish yourself.', { subtype: 'self_action' }); + } + + if (user.id === client.user.id) { + throw new TitanBotError('Bot punishment', ErrorTypes.USER_INPUT, 'You cannot punish the bot.', { subtype: 'bot_action' }); + } + + // Validate duration format if provided + const cleanDuration = durationStr?.trim() || null; + if (cleanDuration && !parseDuration(cleanDuration)) { + throw new TitanBotError('Invalid duration', ErrorTypes.USER_INPUT, 'Invalid duration format. Use formats like `30d`, `7d`, `24h`, `1w`, `30m`.', { subtype: 'invalid_duration' }); + } + + // Execute the punishment action + let actionResults = []; + if (member) { + actionResults = await executePunishmentAction(member, punishmentType, cleanDuration, interaction.guild); + } else { + actionResults = ['⚠️ Member not found in server — action skipped, log created']; + } + + const caseCode = generateCaseCode(); + + // Record offence and get escalation info + const offenceCount = await recordOffence(interaction.guild.id, user.id, punishmentType, caseCode); + const nextEscalation = getNextEscalation(offenceCount); + + const now = new Date(); + const formattedDate = now.toLocaleDateString('en-US', { + weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', + hour: '2-digit', minute: '2-digit', timeZoneName: 'short', + }); + + // Calculate expiry if duration provided + let expiresText = null; + if (cleanDuration) { + const ms = parseDuration(cleanDuration); + const expiryDate = new Date(now.getTime() + ms); + expiresText = expiryDate.toLocaleDateString('en-US', { + weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', + hour: '2-digit', minute: '2-digit', timeZoneName: 'short', + }); + } + + const evidenceAttachments = [evidence1, evidence2, evidence3].filter(Boolean); + + // Build the punishment embed + const embed = new EmbedBuilder() + .setTitle(`Punishment Log - Case \`${caseCode}\``) + .setColor(0xE74C3C) + .addFields( + { + name: 'Member', + value: `<@${user.id}> (\`${user.id}\`)`, + inline: false, + }, + { + name: 'Issued by', + value: `<@${interaction.user.id}> (\`${interaction.user.id}\`)`, + inline: false, + }, + { + name: 'Issued', + value: formattedDate, + inline: false, + }, + { + name: 'Punishment Issued', + value: `**${punishmentType.toUpperCase()}**`, + inline: false, + }, + { + name: 'Reason', + value: reason, + inline: false, + }, + ) + .setThumbnail(user.displayAvatarURL({ dynamic: true, size: 128 })) + .setTimestamp(); + + if (actionResults.length > 0) { + embed.addFields({ + name: 'Actions Taken', + value: actionResults.join('\n'), + inline: false, + }); + } + + // Add offence tracking info + embed.addFields( + { name: 'Offence Count', value: `#${offenceCount} (resets after 60 days)`, inline: true }, + { name: 'Next Escalation', value: offenceCount >= ESCALATION_LADDER.length ? '⚠️ Max level reached — Perm Mute' : `Level ${nextEscalation.level}: ${nextEscalation.label}`, inline: false }, + ); + + if (cleanDuration) { + embed.addFields( + { name: 'Active For', value: formatDuration(cleanDuration), inline: true }, + { name: 'Expires', value: expiresText, inline: true }, + ); + } else { + embed.addFields({ name: 'Duration', value: 'Permanent', inline: false }); + } + + if (evidenceAttachments.length > 0) { + embed.addFields({ + name: 'Evidence', + value: evidenceAttachments.map((att, i) => `[Image ${i + 1}](${att.url})`).join(' · '), + inline: false, + }); + // Set first image as the embed image + embed.setImage(evidenceAttachments[0].url); + } + + // Build status buttons + const buttons = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`punish_reviewed_${caseCode}`) + .setLabel('✅ Reviewed by Management') + .setStyle(ButtonStyle.Success), + + ); + + // Send to punishment log forum channel + let logChannel = interaction.guild.channels.cache.get(PUNISHMENT_LOG_CHANNEL_ID); + if (!logChannel) { + logChannel = await interaction.guild.channels.fetch(PUNISHMENT_LOG_CHANNEL_ID).catch((err) => { + logger.error(`Fetch failed: ${err.message}`); + return null; + }); + } + if (!logChannel) { + throw new TitanBotError('Log channel not found', ErrorTypes.CONFIGURATION, 'Punishment log channel not found. Please check the channel ID.', { subtype: 'missing_channel' }); + } + + // Create a new forum post for this punishment case + const forumPost = await logChannel.threads.create({ + name: `Case ${caseCode} — ${user.username} — ${punishmentType}`, + message: { + embeds: [embed], + components: [buttons], + files: evidenceAttachments.length > 0 + ? evidenceAttachments.map(att => att.url) + : [], + }, + }); + + const logMessage = forumPost.messages.cache.first() || { id: forumPost.id }; + + // Store the case in the database + await storeModerationCase({ + guildId: interaction.guild.id, + caseId: caseCode, + caseData: { + action: punishmentType, + target: `${user.tag} (${user.id})`, + executor: `${interaction.user.tag} (${interaction.user.id})`, + reason, + duration: cleanDuration ? formatDuration(cleanDuration) : 'Permanent', + metadata: { + userId: user.id, + moderatorId: interaction.user.id, + caseCode, + messageId: logMessage.id, + channelId: PUNISHMENT_LOG_CHANNEL_ID, + evidenceUrls: evidenceAttachments.map(a => a.url), + }, + }, + }); + + // Log to audit system + await logModerationAction({ + client, + guild: interaction.guild, + event: { + action: punishmentType, + target: `${user.tag} (${user.id})`, + executor: `${interaction.user.tag} (${interaction.user.id})`, + reason, + duration: cleanDuration ? formatDuration(cleanDuration) : null, + caseId: caseCode, + metadata: { + userId: user.id, + moderatorId: interaction.user.id, + }, + }, + }); + + const nextEscalationText = offenceCount >= ESCALATION_LADDER.length + ? '⚠️ This member is at the maximum offence level (Perm Mute).' + : `📋 Next escalation (offence #${offenceCount + 1}): **${nextEscalation.label}**`; + + await InteractionHelper.universalReply(interaction, { + embeds: [ + new EmbedBuilder() + .setColor(0x2ECC71) + .setTitle('✅ Punishment Logged') + .setDescription(`Case \`${caseCode}\` has been created and logged to <#${PUNISHMENT_LOG_CHANNEL_ID}>.`) + .addFields( + { name: 'Member', value: `<@${user.id}>`, inline: true }, + { name: 'Type', value: punishmentType, inline: true }, + { name: 'Offence Count', value: `#${offenceCount}`, inline: true }, + { name: 'Reason', value: reason, inline: false }, + { name: 'Escalation Info', value: nextEscalationText, inline: false }, + ) + .setTimestamp(), + ], + }); + + } catch (error) { + logger.error('Punish command error:', error); + await handleInteractionError(interaction, error, { subtype: 'punish_failed' }); + } + }, +}; \ No newline at end of file diff --git a/src/commands/Moderation/restore.js b/src/commands/Moderation/restore.js new file mode 100644 index 000000000..9e34cfbcf --- /dev/null +++ b/src/commands/Moderation/restore.js @@ -0,0 +1,119 @@ +import { + SlashCommandBuilder, + PermissionFlagsBits, + EmbedBuilder, + MessageFlags, +} from 'discord.js'; +import { successEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { getFromDb, setInDb } from '../../utils/database.js'; + +const SAVED_ROLES_KEY = (guildId, userId) => `saved_roles_${guildId}_${userId}`; + +export default { + data: new SlashCommandBuilder() + .setName('restore') + .setDescription('Restore a member\'s saved roles after a punishment') + .addUserOption(opt => + opt.setName('member') + .setDescription('The member to restore roles for') + .setRequired(true) + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles), + + category: 'moderation', + + async execute(interaction, config, client) { + try { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const user = interaction.options.getUser('member'); + const member = interaction.options.getMember('member'); + + if (!member) { + throw new TitanBotError('Member not found', ErrorTypes.USER_INPUT, 'That member is not in this server.', { subtype: 'not_found' }); + } + + // Get saved roles from database + const saved = await getFromDb(SAVED_ROLES_KEY(interaction.guild.id, user.id), null); + + if (!saved || !saved.roles || saved.roles.length === 0) { + throw new TitanBotError('No saved roles', ErrorTypes.USER_INPUT, `No saved roles found for <@${user.id}>. They may not have been punished with role removal.`, { subtype: 'no_saved_roles' }); + } + + // Filter out roles that no longer exist + const restoredRoles = []; + const missingRoles = []; + + for (const roleId of saved.roles) { + const role = interaction.guild.roles.cache.get(roleId); + if (role) { + restoredRoles.push(roleId); + } else { + missingRoles.push(roleId); + } + } + + // Add roles back + if (restoredRoles.length > 0) { + await member.roles.add(restoredRoles).catch(err => { + throw new TitanBotError('Role restore failed', ErrorTypes.UNKNOWN, `Failed to restore roles: ${err.message}`, { subtype: 'restore_failed' }); + }); + } + + // Clear saved roles from database + await setInDb(SAVED_ROLES_KEY(interaction.guild.id, user.id), null); + + const embed = new EmbedBuilder() + .setColor(0x2ECC71) + .setTitle('✅ Roles Restored') + .setDescription(`Successfully restored **${restoredRoles.length}** role(s) for <@${user.id}>.`) + .addFields( + { + name: 'Restored Roles', + value: restoredRoles.map(id => `<@&${id}>`).join(', ') || 'None', + inline: false, + }, + ) + .setTimestamp(); + + if (missingRoles.length > 0) { + embed.addFields({ + name: '⚠️ Missing Roles', + value: `${missingRoles.length} role(s) no longer exist and could not be restored.`, + inline: false, + }); + } + + embed.addFields({ + name: 'Restored by', + value: `<@${interaction.user.id}>`, + inline: false, + }); + + await InteractionHelper.universalReply(interaction, { embeds: [embed] }); + + // Also notify in channel + await interaction.channel.send({ + embeds: [ + new EmbedBuilder() + .setColor(0x2ECC71) + .setDescription(`✅ <@${user.id}>'s roles have been restored by <@${interaction.user.id}>.`), + ], + }).catch(() => {}); + + logger.info('Roles restored', { + userId: interaction.user.id, + targetId: user.id, + guildId: interaction.guild.id, + restoredCount: restoredRoles.length, + }); + + } catch (error) { + logger.error('Restore command error:', error); + await handleInteractionError(interaction, error, { subtype: 'restore_failed' }); + } + }, +}; diff --git a/src/commands/Reaction_roles/reactroles.js b/src/commands/Reaction_roles/reactroles.js index 3361f8290..ca1093498 100644 --- a/src/commands/Reaction_roles/reactroles.js +++ b/src/commands/Reaction_roles/reactroles.js @@ -1,5 +1,5 @@ import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, RoleSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ButtonBuilder, ButtonStyle, MessageFlags, ComponentType, EmbedBuilder, LabelBuilder, CheckboxBuilder, TextDisplayBuilder } from 'discord.js'; +import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, RoleSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ButtonBuilder, ButtonStyle, MessageFlags, ComponentType, EmbedBuilder } from 'discord.js'; import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError, createError, TitanBotError, ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; @@ -63,6 +63,31 @@ export default { .setDescription('Fifth role to add') .setRequired(false) ) + .addRoleOption(option => + option.setName('role6') + .setDescription('Sixth role to add') + .setRequired(false) + ) + .addRoleOption(option => + option.setName('role7') + .setDescription('Seventh role to add') + .setRequired(false) + ) + .addRoleOption(option => + option.setName('role8') + .setDescription('Eighth role to add') + .setRequired(false) + ) + .addRoleOption(option => + option.setName('role9') + .setDescription('Ninth role to add') + .setRequired(false) + ) + .addRoleOption(option => + option.setName('role10') + .setDescription('Tenth role to add') + .setRequired(false) + ) ) .addSubcommand(subcommand => subcommand @@ -226,7 +251,7 @@ async function handleSetup(interaction) { const roles = []; const roleValidationErrors = []; - for (let i = 1; i <= 5; i++) { + for (let i = 1; i <= 10; i++) { const role = interaction.options.getRole(`role${i}`); if (role) { if (role.position >= interaction.guild.members.me.roles.highest.position) { @@ -1012,22 +1037,19 @@ async function handleDeletePanel(btnInteraction, rootInteraction, panelData, pan const deleteModal = new ModalBuilder() .setCustomId('rr_delete_confirm_modal') - .setTitle('Delete Reaction Role Panel'); - - const deleteWarningText = new TextDisplayBuilder() - .setContent(`⚠️ You are about to permanently delete the panel **${title}**. This will remove the Discord message and all associated reaction role assignments.`); - - const deleteCheckbox = new CheckboxBuilder() - .setCustomId('delete_confirmation') - .setDefault(false); - - const deleteCheckboxLabel = new LabelBuilder() - .setLabel('I confirm — this cannot be undone') - .setCheckboxComponent(deleteCheckbox); - - deleteModal - .addTextDisplayComponents(deleteWarningText) - .addLabelComponents(deleteCheckboxLabel); + .setTitle('Delete Reaction Role Panel') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('delete_confirmation') + .setLabel('Type "DELETE" to confirm') + .setStyle(TextInputStyle.Short) + .setPlaceholder('DELETE') + .setMaxLength(6) + .setMinLength(6) + .setRequired(true), + ), + ); await btnInteraction.showModal(deleteModal); @@ -1043,10 +1065,10 @@ async function handleDeletePanel(btnInteraction, rootInteraction, panelData, pan return; } - const confirmed = submitted.fields.getCheckbox('delete_confirmation'); + const confirmed = submitted.fields.getTextInputValue('delete_confirmation').trim(); - if (!confirmed) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'You must tick the confirmation checkbox to delete the panel.' }); + if (confirmed !== 'DELETE') { + await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'You must type "DELETE" exactly to confirm deletion.' }); await showPanelDashboard(rootInteraction, panelData, discordMsg, guildId, guild, client); return; } diff --git a/src/commands/Search/define.js b/src/commands/Search/define.js deleted file mode 100644 index 3e9c95843..000000000 --- a/src/commands/Search/define.js +++ /dev/null @@ -1,104 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import axios from 'axios'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getColor } from '../../config/bot.js'; - -export default { - data: new SlashCommandBuilder() - .setName('define') - .setDescription('Look up a word definition') - .addStringOption(option => - option.setName('word') - .setDescription('The word to look up') - .setRequired(true)), - async execute(interaction) { - try { - - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) { - return; - } - - const word = interaction.options.getString('word'); - - if (word.length < 2) { - logger.warn('Define command - word too short', { - userId: interaction.user.id, - word: word, - guildId: interaction.guildId - }); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Please enter a word with at least 2 characters.' }); - } - - const response = await axios.get( - `https://api.dictionaryapi.dev/api/v2/entries/en/${encodeURIComponent(word)}`, - { timeout: 5000 } - ); - - if (!response.data || response.data.length === 0) { - return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'No definitions found for "${word}".' }); - } - - const data = response.data[0]; - const embed = createEmbed({ - title: data.word, - description: data.phonetic ? `*${data.phonetic}*` : '', - color: 'success' - }); - - data.meanings.slice(0, 5).forEach(meaning => { - const definitions = meaning.definitions - .slice(0, 3) - .map((def, idx) => { - let text = `${idx + 1}. ${def.definition}`; - if (def.example) { - text += `\n *Example: ${def.example}*`; - } - return text; - }) - .join('\n\n'); - - if (definitions) { - embed.addFields({ - name: `**${meaning.partOfSpeech || 'Definition'}**`, - value: definitions, - inline: false - }); - } - }); - - embed.setFooter({ text: 'Powered by Free Dictionary API' }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - - logger.info('Dictionary definition retrieved', { - userId: interaction.user.id, - word: word, - guildId: interaction.guildId, - commandName: 'define' - }); - - } catch (error) { - logger.error('Dictionary lookup error', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - word: interaction.options.getString('word'), - guildId: interaction.guildId, - commandName: 'define' - }); - - if (error.response?.status === 404) { - await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'No definitions found for "${interaction.options.getString(\'word\')}".' }); - } else { - await handleInteractionError(interaction, error, { - commandName: 'define', - source: 'dictionary_api' - }); - } - } - }, -}; \ No newline at end of file diff --git a/src/commands/Search/google.js b/src/commands/Search/google.js deleted file mode 100644 index 21b172c96..000000000 --- a/src/commands/Search/google.js +++ /dev/null @@ -1,50 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { getColor } from '../../config/bot.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName('google') - .setDescription('Search Google') - .addStringOption(option => - option.setName('query') - .setDescription('What would you like to search for?') - .setRequired(true)), - async execute(interaction) { - try { - const query = interaction.options.getString('query'); - const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}`; - - const embed = createEmbed({ - title: 'Google Search', - description: `[Search for "${query}"](${searchUrl})`, - color: 'info' - }) - .setFooter({ text: 'Google Search Results' }); - - await InteractionHelper.safeReply(interaction, { embeds: [embed] }); - - logger.info('Google search link generated', { - userId: interaction.user.id, - query: query, - guildId: interaction.guildId, - commandName: 'google' - }); - } catch (error) { - logger.error('Error in google command', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'google' - }); - await handleInteractionError(interaction, error, { - commandName: 'google', - source: 'google_search' - }); - } - }, -}; \ No newline at end of file diff --git a/src/commands/Search/urban.js b/src/commands/Search/urban.js deleted file mode 100644 index bb3d2f605..000000000 --- a/src/commands/Search/urban.js +++ /dev/null @@ -1,131 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import axios from 'axios'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getColor } from '../../config/bot.js'; - -export default { - data: new SlashCommandBuilder() - .setName('urban') - .setDescription('Search Urban Dictionary for definitions') - .addStringOption(option => - option.setName('term') - .setDescription('The term to look up on Urban Dictionary') - .setRequired(true)), - - async execute(interaction) { - try { - const term = interaction.options.getString('term'); - - if (term.length < 2) { - logger.warn('Urban command - term too short', { - userId: interaction.user.id, - term: term, - guildId: interaction.guildId - }); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Please enter a term with at least 2 characters.' }); - } - - let deferTimer = null; - const clearDeferTimer = () => { - if (deferTimer) { - clearTimeout(deferTimer); - deferTimer = null; - } - }; - - deferTimer = setTimeout(() => { - InteractionHelper.safeDefer(interaction).catch((deferError) => { - logger.debug('Urban command defer fallback failed', { - error: deferError?.message, - interactionId: interaction.id, - commandName: 'urban' - }); - }); - }, 1500); - - const response = await axios.get( - `https://api.urbandictionary.com/v0/define?term=${encodeURIComponent(term)}`, - { timeout: 5000 } - ); - clearDeferTimer(); - - if (!response.data?.list?.length) { - return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'No definitions found for "${term}" on Urban Dictionary.' }); - } - - const definition = response.data.list[0]; - const cleanDefinition = definition.definition.replace(/\[|\]/g, ''); - const cleanExample = definition.example.replace(/\[|\]/g, ''); - - const formattedDefinition = cleanDefinition -.replace(/\n\s*\n/g, '\n\n') - .slice(0, 2000); - - const formattedExample = cleanExample - ? `*"${cleanExample.replace(/\n/g, ' ').slice(0, 500)}..."*` - : '*No example provided*'; - - const embed = createEmbed({ - title: definition.word, - description: formattedDefinition, - color: 'info' - }) - .setURL(definition.permalink) - .addFields( - { - name: 'Example', - value: formattedExample, - inline: false - }, - { - name: 'Stats', - value: `${definition.thumbs_up.toLocaleString()} • ${definition.thumbs_down.toLocaleString()}`, - inline: true - }, - { - name: 'Author', - value: definition.author || 'Anonymous', - inline: true - } - ) - .setFooter({ - text: 'Urban Dictionary', - iconURL: 'https://i.imgur.com/8aQrX3a.png' - }); - - await InteractionHelper.safeReply(interaction, { embeds: [embed] }); - - logger.info('Urban Dictionary definition retrieved', { - userId: interaction.user.id, - term: term, - guildId: interaction.guildId, - commandName: 'urban' - }); - - } catch (error) { - logger.error('Urban Dictionary error', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - term: interaction.options.getString('term'), - guildId: interaction.guildId, - apiStatus: error.response?.status, - commandName: 'urban' - }); - - if (error.response?.status === 404 || !error.response) { - await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'No definitions found for "${interaction.options.getString(\'term\')}" on Urban Dictionary.' }); - } else if (error.response?.status === 429) { - await replyUserError(interaction, { type: ErrorTypes.RATE_LIMIT, message: 'Too many requests to Urban Dictionary. Please try again in a few minutes.' }); - } else { - await handleInteractionError(interaction, error, { - commandName: 'urban', - source: 'urban_dictionary_api' - }); - } - } - }, -}; \ No newline at end of file diff --git a/src/commands/Ticket/add.js b/src/commands/Ticket/add.js new file mode 100644 index 000000000..9971ae8c8 --- /dev/null +++ b/src/commands/Ticket/add.js @@ -0,0 +1,72 @@ +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; +import { successEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { handleInteractionError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; +import { getTicketPermissionContext } from '../../utils/ticketPermissions.js'; + +export default { + data: new SlashCommandBuilder() + .setName('add') + .setDescription('Add a user to the current ticket') + .addUserOption(opt => + opt.setName('user') + .setDescription('The user to add to this ticket') + .setRequired(true) + ) + .setDMPermission(false), + category: 'ticket', + + async execute(interaction, config, client) { + try { + const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + if (!deferred) return; + + const permissionContext = await getTicketPermissionContext({ client, interaction }); + if (!permissionContext.ticketData) { + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This command can only be used in a valid ticket channel.' }); + } + + if (!permissionContext.canManageTicket) { + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the Ticket Staff Role to add users to tickets.' }); + } + + const user = interaction.options.getUser('user'); + + if (user.bot) { + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'You cannot add bots to tickets.' }); + } + + // Check if user already has access + const existingPerms = interaction.channel.permissionOverwrites.cache.get(user.id); + if (existingPerms?.allow.has(PermissionFlagsBits.ViewChannel)) { + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `<@${user.id}> already has access to this ticket.` }); + } + + await interaction.channel.permissionOverwrites.edit(user.id, { + ViewChannel: true, + SendMessages: true, + ReadMessageHistory: true, + AttachFiles: true, + }); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('✅ User Added', `<@${user.id}> has been added to this ticket.`)], + }); + + await interaction.channel.send({ + embeds: [successEmbed('👤 User Added', `<@${user.id}> was added to this ticket by <@${interaction.user.id}>.`)], + }); + + logger.info('User added to ticket', { + userId: interaction.user.id, + addedUserId: user.id, + channelId: interaction.channel.id, + guildId: interaction.guildId, + }); + } catch (error) { + logger.error('Add command error:', error); + await handleInteractionError(interaction, error, { subtype: 'add_failed' }); + } + }, +}; diff --git a/src/commands/Ticket/modules/ticket_panels.js b/src/commands/Ticket/modules/ticket_panels.js new file mode 100644 index 000000000..120d2b83f --- /dev/null +++ b/src/commands/Ticket/modules/ticket_panels.js @@ -0,0 +1,221 @@ +// src/commands/Ticket/modules/ticket_panels.js +// Multi-panel management module + +import { + SlashCommandBuilder, + PermissionFlagsBits, + ChannelType, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + MessageFlags, +} from 'discord.js'; +import { getColor } from '../../../config/bot.js'; +import { createEmbed, successEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { replyUserError, ErrorTypes, TitanBotError } from '../../../utils/errorHandler.js'; +import { getFromDb, setInDb } from '../../../utils/database.js'; + +const PANELS_KEY = (guildId) => `ticket_panels_${guildId}`; + +export async function getPanels(guildId) { + return await getFromDb(PANELS_KEY(guildId), []); +} + +export async function savePanels(guildId, panels) { + await setInDb(PANELS_KEY(guildId), panels); +} + +export function generatePanelId() { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + return Array.from({ length: 8 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); +} + +export async function handlePanelAdd(interaction, client) { + try { + const panelChannel = interaction.options.getChannel('panel_channel'); + const panelMessage = interaction.options.getString('panel_message'); + const buttonLabel = interaction.options.getString('button_label') || 'Create Ticket'; + const panelTitle = interaction.options.getString('panel_title') || 'Support Tickets'; + const category = interaction.options.getChannel('category'); + const closedCategory = interaction.options.getChannel('closed_category'); + const staffRole = interaction.options.getRole('staff_role'); + const maxTickets = interaction.options.getInteger('max_tickets_per_user') || 3; + const dmOnClose = interaction.options.getBoolean('dm_on_close') !== false; + + const panelId = generatePanelId(); + + // Build and send the panel embed + const embed = new EmbedBuilder() + .setTitle(panelTitle) + .setDescription(panelMessage) + .setColor(getColor('info')); + + const button = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`create_ticket_${panelId}`) + .setLabel(buttonLabel) + .setStyle(ButtonStyle.Primary) + .setEmoji('📩'), + ); + + const sentPanel = await panelChannel.send({ embeds: [embed], components: [button] }); + + // Save panel config + const panels = await getPanels(interaction.guildId); + panels.push({ + panelId, + panelTitle, + panelMessage, + buttonLabel, + channelId: panelChannel.id, + messageId: sentPanel.id, + categoryId: category?.id || null, + closedCategoryId: closedCategory?.id || null, + staffRoleId: staffRole?.id || null, + maxTicketsPerUser: maxTickets, + dmOnClose, + createdBy: interaction.user.id, + createdAt: new Date().toISOString(), + }); + await savePanels(interaction.guildId, panels); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + successEmbed( + '✅ Panel Created', + `Panel \`${panelId}\` has been posted in <#${panelChannel.id}>.\n\n` + + `**Title:** ${panelTitle}\n` + + `**Button:** ${buttonLabel}\n` + + `**Category:** ${category ? category.name : 'Not set'}\n` + + `**Staff Role:** ${staffRole ? staffRole.name : 'Not set'}\n` + + `**Max Tickets/User:** ${maxTickets}\n` + + `**DM on Close:** ${dmOnClose ? 'Enabled' : 'Disabled'}` + ), + ], + }); + + logger.info('Ticket panel created', { + panelId, + guildId: interaction.guildId, + channelId: panelChannel.id, + userId: interaction.user.id, + }); + } catch (error) { + logger.error('Error creating ticket panel:', { + error: error.message, + stack: error.stack, + guildId: interaction.guildId, + userId: interaction.user.id, + }); + await replyUserError(interaction, { + type: ErrorTypes.UNKNOWN, + message: `Failed to create panel: ${error.message}` + }); + } +} + +export async function handlePanelList(interaction, client) { + try { + const panels = await getPanels(interaction.guildId); + + if (panels.length === 0) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [ + new EmbedBuilder() + .setColor(getColor('info')) + .setTitle('🎫 Ticket Panels') + .setDescription('No panels have been created yet.\nUse `/ticket panel add` to create one.'), + ], + }); + } + + const embed = new EmbedBuilder() + .setColor(getColor('info')) + .setTitle('🎫 Ticket Panels') + .setDescription(`**${panels.length}** panel(s) configured for this server.`) + .setTimestamp(); + + for (const panel of panels) { + embed.addFields({ + name: `${panel.panelTitle} — \`${panel.panelId}\``, + value: [ + `**Channel:** <#${panel.channelId}>`, + `**Button:** ${panel.buttonLabel}`, + `**Category:** ${panel.categoryId ? `<#${panel.categoryId}>` : 'Not set'}`, + `**Staff Role:** ${panel.staffRoleId ? `<@&${panel.staffRoleId}>` : 'Not set'}`, + `**Max Tickets:** ${panel.maxTicketsPerUser}`, + ].join('\n'), + inline: false, + }); + } + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + } catch (error) { + logger.error('Error listing ticket panels:', { + error: error.message, + stack: error.stack, + guildId: interaction.guildId, + userId: interaction.user.id, + }); + await replyUserError(interaction, { + type: ErrorTypes.UNKNOWN, + message: `Failed to list panels: ${error.message}` + }); + } +} + +export async function handlePanelDelete(interaction, client) { + try { + const panelId = interaction.options.getString('panel_id'); + const panels = await getPanels(interaction.guildId); + const index = panels.findIndex(p => p.panelId === panelId); + + if (index === -1) { + return replyUserError(interaction, { + type: ErrorTypes.UNKNOWN, + message: `No panel found with ID \`${panelId}\`. Use \`/ticket panel list\` to see all panels.`, + }); + } + + const panel = panels[index]; + + // Try to delete the panel message + try { + const channel = interaction.guild.channels.cache.get(panel.channelId) + || await interaction.guild.channels.fetch(panel.channelId).catch(() => null); + if (channel && panel.messageId) { + const msg = await channel.messages.fetch(panel.messageId).catch(() => null); + if (msg) await msg.delete().catch(() => {}); + } + } catch (err) { + logger.warn(`Could not delete panel message for ${panelId}:`, err.message); + } + + panels.splice(index, 1); + await savePanels(interaction.guildId, panels); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('✅ Panel Deleted', `Panel \`${panelId}\` (${panel.panelTitle}) has been removed.`)], + }); + + logger.info('Ticket panel deleted', { + panelId, + guildId: interaction.guildId, + userId: interaction.user.id, + }); + } catch (error) { + logger.error('Error deleting ticket panel:', { + error: error.message, + stack: error.stack, + guildId: interaction.guildId, + userId: interaction.user.id, + }); + await replyUserError(interaction, { + type: ErrorTypes.UNKNOWN, + message: `Failed to delete panel: ${error.message}` + }); + } +} diff --git a/src/commands/Ticket/remove.js b/src/commands/Ticket/remove.js new file mode 100644 index 000000000..3884af5a4 --- /dev/null +++ b/src/commands/Ticket/remove.js @@ -0,0 +1,74 @@ +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; +import { successEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { handleInteractionError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; +import { getTicketPermissionContext } from '../../utils/ticketPermissions.js'; + +export default { + data: new SlashCommandBuilder() + .setName('remove') + .setDescription('Remove a user from the current ticket') + .addUserOption(opt => + opt.setName('user') + .setDescription('The user to remove from this ticket') + .setRequired(true) + ) + .setDMPermission(false), + category: 'ticket', + + async execute(interaction, config, client) { + try { + const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + if (!deferred) return; + + const permissionContext = await getTicketPermissionContext({ client, interaction }); + if (!permissionContext.ticketData) { + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This command can only be used in a valid ticket channel.' }); + } + + if (!permissionContext.canManageTicket) { + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the Ticket Staff Role to remove users from tickets.' }); + } + + const user = interaction.options.getUser('user'); + + // Prevent removing the ticket creator + const ticketCreatorId = permissionContext.ticketData?.userId; + if (user.id === ticketCreatorId) { + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'You cannot remove the ticket creator from their own ticket.' }); + } + + // Prevent removing yourself + if (user.id === interaction.user.id) { + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'You cannot remove yourself from a ticket.' }); + } + + // Check if user actually has access + const existingPerms = interaction.channel.permissionOverwrites.cache.get(user.id); + if (!existingPerms) { + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `<@${user.id}> doesn't have explicit access to this ticket.` }); + } + + await interaction.channel.permissionOverwrites.delete(user.id); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('✅ User Removed', `<@${user.id}> has been removed from this ticket.`)], + }); + + await interaction.channel.send({ + embeds: [successEmbed('👤 User Removed', `<@${user.id}> was removed from this ticket by <@${interaction.user.id}>.`)], + }); + + logger.info('User removed from ticket', { + userId: interaction.user.id, + removedUserId: user.id, + channelId: interaction.channel.id, + guildId: interaction.guildId, + }); + } catch (error) { + logger.error('Remove command error:', error); + await handleInteractionError(interaction, error, { subtype: 'remove_failed' }); + } + }, +}; diff --git a/src/commands/Ticket/rename.js b/src/commands/Ticket/rename.js new file mode 100644 index 000000000..7dda718eb --- /dev/null +++ b/src/commands/Ticket/rename.js @@ -0,0 +1,66 @@ +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; +import { successEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { handleInteractionError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; +import { getTicketPermissionContext } from '../../utils/ticketPermissions.js'; + +export default { + data: new SlashCommandBuilder() + .setName('rename') + .setDescription('Rename the current ticket channel') + .addStringOption(opt => + opt.setName('name') + .setDescription('New name for the ticket channel') + .setRequired(true) + ) + .setDMPermission(false), + category: 'ticket', + + async execute(interaction, config, client) { + try { + const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + if (!deferred) return; + + const permissionContext = await getTicketPermissionContext({ client, interaction }); + if (!permissionContext.ticketData) { + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This command can only be used in a valid ticket channel.' }); + } + + if (!permissionContext.canManageTicket) { + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the Ticket Staff Role to rename tickets.' }); + } + + const newName = interaction.options.getString('name') + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, ''); + + if (!newName) { + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Invalid channel name. Use letters, numbers, and hyphens only.' }); + } + + const oldName = interaction.channel.name; + await interaction.channel.setName(newName); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('✅ Ticket Renamed', `Channel renamed from \`${oldName}\` to \`${newName}\`.`)], + }); + + await interaction.channel.send({ + embeds: [successEmbed('📝 Ticket Renamed', `This ticket was renamed to \`${newName}\` by <@${interaction.user.id}>.`)], + }); + + logger.info('Ticket renamed', { + userId: interaction.user.id, + channelId: interaction.channel.id, + oldName, + newName, + guildId: interaction.guildId, + }); + } catch (error) { + logger.error('Rename command error:', error); + await handleInteractionError(interaction, error, { subtype: 'rename_failed' }); + } + }, +}; diff --git a/src/commands/Ticket/ticket.js b/src/commands/Ticket/ticket.js index d7f4e9a2c..94c6e1ad5 100644 --- a/src/commands/Ticket/ticket.js +++ b/src/commands/Ticket/ticket.js @@ -1,300 +1,135 @@ import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getGuildConfig } from '../../services/guildConfig.js'; +import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, MessageFlags } from 'discord.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; - -import ticketConfig from './modules/ticket_dashboard.js'; +import { handlePanelAdd, handlePanelList, handlePanelDelete } from './modules/ticket_panels.js'; export default { data: new SlashCommandBuilder() .setName("ticket") - .setDescription("Manages the server's ticket system.") + .setDescription("Manages the server's ticket panels.") .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels) - .addSubcommand((subcommand) => - subcommand - .setName("setup") - .setDescription( - "Sets up the ticket creation panel in a specified channel.", - ) - .addChannelOption((option) => - option -.setName("panel_channel") - .setDescription( - "The channel where the ticket panel will be sent.", - ) - .addChannelTypes(ChannelType.GuildText) - .setRequired(true), - ) - .addStringOption((option) => - option - .setName("panel_message") - .setDescription( - "The main message/description for the ticket panel.", + .addSubcommandGroup((group) => + group + .setName("panel") + .setDescription("Manage ticket panels") + + .addSubcommand((sub) => + sub.setName("add") + .setDescription("Create a new ticket panel in a channel") + .addChannelOption((opt) => + opt.setName("panel_channel") + .setDescription("Channel to post the panel in") + .addChannelTypes(ChannelType.GuildText) + .setRequired(true), ) - .setRequired(true), - ) - .addStringOption((option) => - option - .setName("button_label") - .setDescription( - "The label for the ticket creation button (default: Create Ticket)", + .addStringOption((opt) => + opt.setName("panel_message") + .setDescription("Description shown on the panel") + .setRequired(true), ) - .setRequired(false), - ) - .addChannelOption((option) => - option - .setName("category") - .setDescription( - "The category where new tickets will be created (optional).", + .addStringOption((opt) => + opt.setName("panel_title") + .setDescription("Title of the panel embed (default: Support Tickets)") + .setRequired(false), ) - .addChannelTypes(ChannelType.GuildCategory) - .setRequired(false), - ) - .addChannelOption((option) => - option - .setName("closed_category") - .setDescription( - "The category where closed tickets will be moved (optional).", + .addStringOption((opt) => + opt.setName("button_label") + .setDescription("Label on the create ticket button (default: Create Ticket)") + .setRequired(false), ) - .addChannelTypes(ChannelType.GuildCategory) - .setRequired(false), - ) - .addRoleOption((option) => - option - .setName("staff_role") - .setDescription( - "The role that can access tickets (optional).", + .addChannelOption((opt) => + opt.setName("category") + .setDescription("Category where tickets from this panel will be created") + .addChannelTypes(ChannelType.GuildCategory) + .setRequired(false), + ) + .addChannelOption((opt) => + opt.setName("closed_category") + .setDescription("Category where closed tickets from this panel will go") + .addChannelTypes(ChannelType.GuildCategory) + .setRequired(false), ) - .setRequired(false), + .addRoleOption((opt) => + opt.setName("staff_role") + .setDescription("Staff role that can access tickets from this panel") + .setRequired(false), + ) + .addIntegerOption((opt) => + opt.setName("max_tickets_per_user") + .setDescription("Max open tickets per user for this panel (default: 3)") + .setMinValue(1) + .setMaxValue(10) + .setRequired(false), + ) + .addBooleanOption((opt) => + opt.setName("dm_on_close") + .setDescription("DM user when ticket closed (default: true)") + .setRequired(false), + ), ) - .addIntegerOption((option) => - option - .setName("max_tickets_per_user") - .setDescription("Maximum number of tickets a user can create (default: 3)") - .setMinValue(1) - .setMaxValue(10) - .setRequired(false), + + .addSubcommand((sub) => + sub.setName("list") + .setDescription("List all ticket panels for this server"), ) - .addBooleanOption((option) => - option - .setName("dm_on_close") - .setDescription("Send DM to user when their ticket is closed (default: true)") - .setRequired(false), + + .addSubcommand((sub) => + sub.setName("delete") + .setDescription("Delete a ticket panel") + .addStringOption((opt) => + opt.setName("panel_id") + .setDescription("The panel ID to delete (get from /ticket panel list)") + .setRequired(true), + ), ), ) - .addSubcommand((subcommand) => - subcommand - .setName("dashboard") - .setDescription("Open the interactive ticket system dashboard"), + + .addSubcommand((sub) => + sub.setName("transcript") + .setDescription("Set the channel where ticket transcripts are sent") + .addChannelOption((opt) => + opt.setName("channel") + .setDescription("The channel to send transcripts to") + .addChannelTypes(ChannelType.GuildText) + .setRequired(true), + ), ), + category: "ticket", async execute(interaction, config, client) { try { - const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - if (!deferred) { - return; - } + if (!deferred) return; - if ( - !interaction.member.permissions.has( - PermissionFlagsBits.ManageChannels, - ) - ) { - logger.warn('Ticket command permission denied', { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'ticket' - }); + if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) { return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the `Manage Channels` permission for this action.' }); } const subcommand = interaction.options.getSubcommand(); + const subcommandGroup = interaction.options.getSubcommandGroup(false); - if (subcommand === "dashboard") { - return ticketConfig.execute(interaction, config, client); - } - - if (subcommand === "setup") { - const existingConfig = await getGuildConfig(client, interaction.guildId); - if (existingConfig?.ticketPanelChannelId) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `This server already has a ticket system set up (panel in <#${existingConfig.ticketPanelChannelId}>).\n\nOnly one ticket system is supported per server. Use \`/ticket dashboard\` to edit or update the existing setup, or select **Delete System** from the dashboard to remove it and start fresh.` }); + if (subcommandGroup === 'panel') { + if (subcommand === 'add') return await handlePanelAdd(interaction, client); + if (subcommand === 'list') return await handlePanelList(interaction, client); + if (subcommand === 'delete') return await handlePanelDelete(interaction, client); } - const panelChannel = - interaction.options.getChannel("panel_channel"); - const categoryChannel = interaction.options.getChannel("category"); - const closedCategoryChannel = interaction.options.getChannel("closed_category"); - const staffRole = interaction.options.getRole("staff_role"); -const panelMessage = interaction.options.getString("panel_message") || "Click the button below to create a support ticket."; - const buttonLabel = - interaction.options.getString("button_label") || -"Create Ticket"; - const maxTicketsPerUser = interaction.options.getInteger("max_tickets_per_user") || 3; -const dmOnClose = interaction.options.getBoolean("dm_on_close") !== false; - - const setupEmbed = createEmbed({ - title: "Support Tickets", -description: panelMessage, - color: getColor('info') - }); - - const ticketButton = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId("create_ticket") -.setLabel(buttonLabel) - .setStyle(ButtonStyle.Primary) - .setEmoji("📩"), - ); - - try { - const sentPanel = await panelChannel.send({ - embeds: [setupEmbed], - components: [ticketButton], - }); - - if (client.db && interaction.guildId) { - const currentConfig = existingConfig; - currentConfig.ticketCategoryId = categoryChannel ? categoryChannel.id : null; - currentConfig.ticketClosedCategoryId = closedCategoryChannel ? closedCategoryChannel.id : null; - currentConfig.ticketStaffRoleId = staffRole ? staffRole.id : null; - currentConfig.ticketPanelChannelId = panelChannel.id; - currentConfig.ticketPanelMessageId = sentPanel?.id || null; - currentConfig.ticketPanelMessage = panelMessage; - currentConfig.ticketButtonLabel = buttonLabel; - currentConfig.maxTicketsPerUser = maxTicketsPerUser; - currentConfig.dmOnClose = dmOnClose; - + if (subcommand === 'transcript') { + const channel = interaction.options.getChannel('channel'); + const { getGuildConfig } = await import('../../services/guildConfig.js'); const { getGuildConfigKey } = await import('../../utils/database.js'); - const configKey = getGuildConfigKey(interaction.guildId); - await client.db.set(configKey, currentConfig); - logger.info('Ticket configuration saved', { - guildId: interaction.guildId, - categoryId: categoryChannel?.id, - closedCategoryId: closedCategoryChannel?.id, - staffRoleId: staffRole?.id, - maxTickets: maxTicketsPerUser, - dmOnClose: dmOnClose + const guildConfig = await getGuildConfig(client, interaction.guildId); + guildConfig.ticketTranscriptChannelId = channel.id; + await client.db.set(getGuildConfigKey(interaction.guildId), guildConfig); + const { successEmbed } = await import('../../utils/embeds.js'); + return await InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('✅ Transcript Channel Set', `Ticket transcripts will be sent to ${channel}.`)], }); } - - let successMessage = `The ticket creation panel has been sent to ${panelChannel}.`; - - if (categoryChannel) { - successMessage += `New tickets will be created in the **${categoryChannel.name}** category.`; - } else { - successMessage += 'New tickets will be created in a new "Tickets" category.'; - } - - if (closedCategoryChannel) { - successMessage += `Closed tickets will be moved to **${closedCategoryChannel.name}**.`; - } - - if (staffRole) { - successMessage += `**${staffRole.name}** role will have access to tickets.`; - } - - successMessage += `\n\n**Max Tickets Per User:** ${maxTicketsPerUser}\n**DM on Close:** ${dmOnClose ? 'Enabled' : 'Disabled'}`; - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "Ticket Panel Set Up", - successMessage, - ), - ], - }); - - logger.info('Ticket panel setup completed', { - userId: interaction.user.id, - userTag: interaction.user.tag, - guildId: interaction.guildId, - panelChannelId: panelChannel.id, - categoryId: categoryChannel?.id, - closedCategoryId: closedCategoryChannel?.id, - staffRoleId: staffRole?.id, - maxTickets: maxTicketsPerUser, - dmOnClose: dmOnClose, - commandName: 'ticket_setup' - }); - - const logEmbed = createEmbed({ - title: "Ticket System Setup (Configuration Log)", - description: `The ticket panel was set up in ${panelChannel} by ${interaction.user}.`, - color: getColor('warning') - }) - .addFields( - { - name: "Panel Channel", - value: panelChannel.toString(), - inline: true, - }, - { - name: "Ticket Category", - value: categoryChannel - ? categoryChannel.toString() - : "None specified.", - inline: true, - }, - { - name: "Closed Category", - value: closedCategoryChannel - ? closedCategoryChannel.toString() - : "None specified.", - inline: true, - }, - { - name: "Staff Role", - value: staffRole - ? staffRole.toString() - : "None specified.", - inline: true, - }, - { - name: "Max Tickets Per User", - value: maxTicketsPerUser.toString(), - inline: true, - }, - { - name: "DM on Close", - value: dmOnClose ? 'Enabled' : 'Disabled', - inline: true, - }, - { - name: "Moderator", - value: `${interaction.user.tag} (${interaction.user.id})`, - inline: false, - }, - ); - - } catch (error) { - logger.error('Ticket setup error', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'ticket_setup' - }); - if (interaction.deferred || interaction.replied) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Could not send the ticket panel or save configuration. Check the bot\'s permissions (especially the ability to send messages in the target channel) and database connection.' }).catch(err => { - logger.error('Failed to send error reply', { - error: err.message, - guildId: interaction.guildId - }); - }); - } else { - await handleInteractionError(interaction, error, { - commandName: 'ticket_setup', - source: 'ticket_setup_command' - }); - } - } - } } catch (error) { logger.error('Error executing ticket command', { error: error.message, @@ -309,4 +144,4 @@ description: panelMessage, }); } } -}; \ No newline at end of file +}; diff --git a/src/commands/Tools/calculate.js b/src/commands/Tools/calculate.js deleted file mode 100644 index 730a00cc6..000000000 --- a/src/commands/Tools/calculate.js +++ /dev/null @@ -1,333 +0,0 @@ -import { SlashCommandBuilder, ButtonBuilder, ButtonStyle, ActionRowBuilder } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { evaluateMathExpression } from '../../utils/safeMathParser.js'; - -const calculationContexts = new Map(); - -function evaluate(expression) { - return evaluateMathExpression(expression); -} - -const calculationHistory = new Map(); -const MAX_HISTORY = 5; - -export { calculationContexts }; - -export default { - data: new SlashCommandBuilder() - .setName("calculate") - .setDescription("Evaluate a mathematical expression") - .addStringOption((option) => - option - .setName("expression") - .setDescription( - "The mathematical expression to evaluate (e.g., 2+2*3, sin(45 deg), 16^0.5)", - ) - .setRequired(true), - ), - - async execute(interaction) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Calculate interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'calculate' - }); - return; - } - -try { - - const expression = interaction.options.getString("expression"); - - if ( - !/^[0-9+\-*/.()^%! ,<>=&|~?:\[\]{}a-z√π∞°]+$/i.test(expression) - ) { - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: '"**Contains unsupported characters.**\\n\\n" +\n "✅ Supported: Numbers, decimals, + - * / ^ %, sin cos tan sqrt abs log exp, pi e, ()\\n" +\n "❌ Not supported: Brackets, curly braces, and other symbols"' }); - } - - const dangerousPatterns = [ - /\b(?:import|require|process|fs|child_process|exec|eval|Function|setTimeout|setInterval|new\s+Function)\s*\(/i, - /`/g, -/\$\{.*\}/, - /\b(?:localStorage|document|window|fetch|XMLHttpRequest)\b/, - /\b(?:while|for)\s*\([^)]*\)\s*\{/, - /\b(?:function\*|yield|await|async)\b/, - ]; - - for (const pattern of dangerousPatterns) { - if (pattern.test(expression)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: '"**Contains blocked code patterns.**\\n\\n" +\n "🚫 **Blocked:** import, require, eval, Function, setTimeout, setInterval, process, fs, document, window, fetch, loops, async/await\\n\\n" +\n "Code-like syntax is not allowed in calculations."' }); - } - } - - let result; - try { - result = evaluate(expression); - - let formattedResult; - if (typeof result === "number") { - formattedResult = result.toLocaleString("en-US", { - maximumFractionDigits: 10, - }); - - if ( - Math.abs(result) > 0 && - (Math.abs(result) >= 1e10 || Math.abs(result) < 1e-3) - ) { - formattedResult = result.toExponential(6); - } - } else if (typeof result === "boolean") { - formattedResult = result ? "true" : "false"; - } else if (result === null || result === undefined) { - formattedResult = "No result"; - } else if ( - Array.isArray(result) || - typeof result === "object" - ) { - formattedResult = - "```json\n" + JSON.stringify(result, null, 2) + "\n```"; - } else { - formattedResult = String(result); - } - - const userId = interaction.user.id; - if (!calculationHistory.has(userId)) { - calculationHistory.set(userId, []); - } - - const history = calculationHistory.get(userId); - history.unshift({ - expression, - result: formattedResult, - timestamp: Date.now(), - }); - - if (history.length > MAX_HISTORY) { - history.pop(); - } - - const row = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`calc_${interaction.id}_add`) - .setLabel("+") - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId(`calc_${interaction.id}_subtract`) - .setLabel("-") - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId(`calc_${interaction.id}_multiply`) - .setLabel("×") - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId(`calc_${interaction.id}_divide`) - .setLabel("÷") - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId(`calc_${interaction.id}_history`) - .setLabel("History") - .setStyle(ButtonStyle.Secondary), - ); - - const embed = successEmbed( - "🧮 Calculation Result", - `**Expression:** \`${expression.replace(/`/g, "\`")}\`\n` + - `**Result:** \`${formattedResult}\`\n\n` + - `*Use the buttons below to perform operations with the result.*`, - ); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - components: [row], - }); - - const filter = (i) => - i.customId.startsWith(`calc_${interaction.id}`) && - i.user.id === interaction.user.id; -const BUTTON_TIMEOUT = 300000; - const collector = - interaction.channel.createMessageComponentCollector({ - filter, - time: BUTTON_TIMEOUT, - }); - - collector.on("collect", async (i) => { - try { - const operation = i.customId.split("_")[2]; - - if (operation === "history") { - if (!i.deferred && !i.replied) { - await i.deferUpdate().catch(console.error); - } - - const userHistory = - calculationHistory.get(userId) || []; - - if (userHistory.length === 0) { - await i.followUp({ - content: "No calculation history found.", - flags: ["Ephemeral"], - }); - return; - } - - const historyText = userHistory - .map( - (item, index) => - `${index + 1}. **${item.expression}** = \`${item.result}\`\n` + - ``, - ) - .join("\n\n"); - - await i.followUp({ - content: `📜 **Your Calculation History**\n\n${historyText}`, - flags: ["Ephemeral"], - }); - return; - } - - let operator = ""; - - switch (operation) { - case "add": - operator = "+"; - break; - case "subtract": - operator = "-"; - break; - case "multiply": - operator = "*"; - break; - case "divide": - operator = "/"; - break; - } - - try { - const contextKey = `${i.user.id}_${operation}`; - calculationContexts.set(contextKey, { - expression, - formattedResult, - operator, - messageId: interaction.message?.id, - channelId: interaction.channelId, - userId: i.user.id - }); - - await i.showModal({ - customId: `calc_modal:${operation}`, - title: `Enter a number to ${operation}`, - components: [ - { - type: 1, - components: [ - { - type: 4, - customId: `operand:${contextKey}`, - label: `Number to ${operator} with ${formattedResult}`, - placeholder: "Enter a number...", - style: 1, - required: true, - maxLength: 50, - }, - ], - }, - ], - }); - } catch (modalError) { - logger.error("Failed to show modal:", modalError); - if (!i.replied && !i.deferred) { - await i.reply({ - content: "Failed to open calculator. Please try again.", - flags: ["Ephemeral"], - }).catch(console.error); - } - return; - } - - } catch (error) { - logger.error("Button interaction error:", error); - if (!i.deferred && !i.replied) { - await i.followUp({ - content: "An error occurred while processing your request.", - flags: ["Ephemeral"], - }).catch(console.error); - } - } - }); - - collector.on("end", (collected, reason) => { - if (reason === "timeout") { - const disabledRow = - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId( - `calc_${interaction.id}_expired`, - ) - .setLabel("Calculator Expired") - .setStyle(ButtonStyle.Secondary) - .setDisabled(true), - ); - - interaction - .editReply({ - components: [disabledRow], - content: - "⏱️ This calculator has expired. Use the command again to perform more calculations.", - }) - .catch(console.error); - } else { - const disabledRow = ActionRowBuilder.from( - row, - ).setComponents( - row.components.map((component) => - ButtonBuilder.from(component).setDisabled(true), - ), - ); - - interaction - .editReply({ components: [disabledRow] }) - .catch(console.error); - } - }); - } catch (error) { - logger.error('Calculation error:', error); - - let errorMessage = 'Failed to evaluate the expression.'; - - if (error.message.includes('Unexpected type')) { - errorMessage += - 'The expression contains an unsupported operation or function.'; - } else if (error.message.includes('Undefined symbol')) { - errorMessage += - 'The expression contains an undefined variable or function.'; - } else if (error.message.includes('Brackets not balanced')) { - errorMessage += 'The expression has unbalanced brackets.'; - } else if ( - error.message.includes('Unexpected operator') || - error.message.includes('Unexpected character') - ) { - errorMessage += - 'The expression contains an invalid operator or character.'; - } else { - errorMessage += 'Please check the syntax and try again.'; - } - - await replyUserError(interaction, { - type: ErrorTypes.VALIDATION, - message: errorMessage, - }); - } - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'calculate' - }); - } - }, -}; \ No newline at end of file diff --git a/src/commands/Tools/countdown.js b/src/commands/Tools/countdown.js deleted file mode 100644 index 80a85ecf9..000000000 --- a/src/commands/Tools/countdown.js +++ /dev/null @@ -1,104 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { createControlButtons, formatTime, startCountdown } from '../../handlers/countdownButtons.js'; - -const activeCountdowns = new Map(); - -export { activeCountdowns }; - -export default { - data: new SlashCommandBuilder() - .setName("countdown") - .setDescription("Start a countdown timer") - .addIntegerOption((option) => - option - .setName("minutes") - .setDescription("Number of minutes to count down (0-1440)") - .setMinValue(0) - .setMaxValue(1440) - .setRequired(false), - ) - .addIntegerOption((option) => - option - .setName("seconds") - .setDescription("Number of seconds to count down (0-59)") - .setMinValue(0) - .setMaxValue(59) - .setRequired(false), - ) - .addStringOption((option) => - option - .setName("title") - .setDescription("Optional title for the countdown") - .setRequired(false), - ), - - async execute(interaction) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Countdown interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'countdown' - }); - return; - } - - try { - const minutes = interaction.options.getInteger("minutes") || 0; - const seconds = interaction.options.getInteger("seconds") || 0; - const title = interaction.options.getString("title") || "Countdown Timer"; - - const totalSeconds = minutes * 60 + seconds; - - if (totalSeconds <= 0) { - throw new Error("Please specify a duration of at least 1 second."); - } - - if (totalSeconds > 86400) { - throw new Error("Countdown cannot be longer than 24 hours."); - } - - const endTime = Date.now() + totalSeconds * 1000; - const countdownId = `${interaction.channelId}-${Date.now()}`; - - const row = createControlButtons(countdownId); - - const initialEmbed = successEmbed( - `⏱️ ${title}`, - `Time remaining: **${formatTime(totalSeconds)}**`, - ); - - const message = await interaction.channel.send({ - embeds: [initialEmbed], - components: [row], - }); - - const countdownData = { - message, - endTime, - remainingTime: totalSeconds * 1000, - isPaused: false, - title, - lastUpdate: Date.now(), - interval: null, - }; - - activeCountdowns.set(countdownId, countdownData); - startCountdown(countdownId, countdownData, activeCountdowns); - - await InteractionHelper.safeEditReply(interaction, { - content: "✅ Countdown started!", - flags: MessageFlags.Ephemeral, - }); - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'countdown' - }); - } - }, -}; \ No newline at end of file diff --git a/src/commands/Utility/sticky.js b/src/commands/Utility/sticky.js new file mode 100644 index 000000000..8a52801c9 --- /dev/null +++ b/src/commands/Utility/sticky.js @@ -0,0 +1,179 @@ +import { + SlashCommandBuilder, + PermissionFlagsBits, + EmbedBuilder, + MessageFlags, +} from 'discord.js'; +import { successEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { getFromDb, setInDb } from '../../utils/database.js'; + +const STICKY_KEY = (guildId, channelId) => `sticky_${guildId}_${channelId}`; + +export async function getSticky(guildId, channelId) { + return await getFromDb(STICKY_KEY(guildId, channelId), null); +} + +export async function saveSticky(guildId, channelId, data) { + await setInDb(STICKY_KEY(guildId, channelId), data); +} + +export async function deleteSticky(guildId, channelId) { + await setInDb(STICKY_KEY(guildId, channelId), null); +} + +export default { + data: new SlashCommandBuilder() + .setName('sticky') + .setDescription('Manage sticky messages in channels') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) + + .addSubcommand(sub => + sub.setName('set') + .setDescription('Set a sticky message in a channel') + .addChannelOption(opt => + opt.setName('channel') + .setDescription('The channel to set the sticky in') + .setRequired(true) + ) + .addStringOption(opt => + opt.setName('message') + .setDescription('The sticky message content') + .setRequired(true) + ) + .addStringOption(opt => + opt.setName('title') + .setDescription('Optional title for the sticky embed') + .setRequired(false) + ) + .addStringOption(opt => + opt.setName('color') + .setDescription('Embed color') + .setRequired(false) + .addChoices( + { name: 'Yellow (default)', value: '0xF1C40F' }, + { name: 'Blue', value: '0x3498DB' }, + { name: 'Green', value: '0x2ECC71' }, + { name: 'Red', value: '0xE74C3C' }, + { name: 'Purple', value: '0x9B59B6' }, + { name: 'White', value: '0xFFFFFF' }, + ) + ) + ) + + .addSubcommand(sub => + sub.setName('remove') + .setDescription('Remove the sticky message from a channel') + .addChannelOption(opt => + opt.setName('channel') + .setDescription('The channel to remove the sticky from') + .setRequired(true) + ) + ) + + .addSubcommand(sub => + sub.setName('list') + .setDescription('List all sticky messages in this server') + ), + + category: 'utility', + + async execute(interaction, config, client) { + try { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const sub = interaction.options.getSubcommand(); + + if (sub === 'set') { + const channel = interaction.options.getChannel('channel'); + const message = interaction.options.getString('message'); + const title = interaction.options.getString('title') || '📌 Sticky Message'; + const colorStr = interaction.options.getString('color') || '0xF1C40F'; + + // Check if sticky already exists and delete old message + const existing = await getSticky(interaction.guild.id, channel.id); + if (existing?.messageId) { + const oldMsg = await channel.messages.fetch(existing.messageId).catch(() => null); + if (oldMsg) await oldMsg.delete().catch(() => {}); + } + + // Send the sticky + const embed = new EmbedBuilder() + .setTitle(title) + .setDescription(message) + .setColor(parseInt(colorStr, 16)) + .setFooter({ text: '📌 Sticky Message' }) + .setTimestamp(); + + const sentMsg = await channel.send({ embeds: [embed] }); + + // Save sticky config + await saveSticky(interaction.guild.id, channel.id, { + channelId: channel.id, + guildId: interaction.guild.id, + messageId: sentMsg.id, + message, + title, + color: colorStr, + setBy: interaction.user.id, + setAt: new Date().toISOString(), + }); + + await InteractionHelper.universalReply(interaction, { + embeds: [successEmbed('📌 Sticky Set', `Sticky message has been set in <#${channel.id}>.`)], + }); + + } else if (sub === 'remove') { + const channel = interaction.options.getChannel('channel'); + const sticky = await getSticky(interaction.guild.id, channel.id); + + if (!sticky) { + throw new TitanBotError('No sticky', ErrorTypes.USER_INPUT, `There is no sticky message in <#${channel.id}>.`, { subtype: 'not_found' }); + } + + // Delete the sticky message + if (sticky.messageId) { + const msg = await channel.messages.fetch(sticky.messageId).catch(() => null); + if (msg) await msg.delete().catch(() => {}); + } + + await deleteSticky(interaction.guild.id, channel.id); + + await InteractionHelper.universalReply(interaction, { + embeds: [successEmbed('🗑️ Sticky Removed', `Sticky message has been removed from <#${channel.id}>.`)], + }); + + } else if (sub === 'list') { + // Scan all channels for stickies + const stickies = []; + for (const [, channel] of interaction.guild.channels.cache) { + const sticky = await getSticky(interaction.guild.id, channel.id); + if (sticky) stickies.push(sticky); + } + + if (stickies.length === 0) { + return InteractionHelper.universalReply(interaction, { + embeds: [new EmbedBuilder().setColor(0x3498DB).setDescription('No sticky messages are set in this server.')], + }); + } + + const embed = new EmbedBuilder() + .setColor(0xF1C40F) + .setTitle('📌 Sticky Messages') + .setDescription(stickies.map(s => + `<#${s.channelId}> — **${s.title}**\n> ${s.message.length > 80 ? s.message.slice(0, 80) + '…' : s.message}` + ).join('\n\n')) + .setFooter({ text: `${stickies.length} sticky message(s)` }) + .setTimestamp(); + + await InteractionHelper.universalReply(interaction, { embeds: [embed] }); + } + + } catch (error) { + logger.error('Sticky command error:', error); + await handleInteractionError(interaction, error, { subtype: 'sticky_failed' }); + } + }, +}; diff --git a/src/commands/Utility/weather.js b/src/commands/Utility/weather.js deleted file mode 100644 index 1e542e183..000000000 --- a/src/commands/Utility/weather.js +++ /dev/null @@ -1,137 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -const GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search"; -const WEATHER_URL = "https://api.open-meteo.com/v1/forecast"; - -export default { - data: new SlashCommandBuilder() - .setName("weather") - .setDescription("Get real-time weather information for a location") - .addStringOption((option) => - option - .setName("city") - .setDescription("The city name, e.g., 'London' or 'Tokyo'") - .setRequired(true), - ), - - async execute(interaction) { - try { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Weather interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'weather' - }); - return; - } - - const city = interaction.options.getString("city"); - - const geoResponse = await fetch( - `${GEOCODING_URL}?name=${encodeURIComponent(city)}`, - ); - const geoData = await geoResponse.json(); - - if (!geoData.results || geoData.results.length === 0) { - logger.info(`Weather command - city not found`, { - userId: interaction.user.id, - city: city, - guildId: interaction.guildId - }); - await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Could not find a location for **${city}**. Please check the spelling.' }); - return; - } - - const { latitude, longitude, name, country } = geoData.results[0]; - const cityDisplay = name; - - const weatherResponse = await fetch( - `${WEATHER_URL}?latitude=${latitude}&longitude=${longitude}¤t_weather=true`, - ); - const weatherData = await weatherResponse.json(); - - if (weatherData.error) { - logger.error(`Weather API error`, { - error: weatherData.reason, - city: city, - userId: interaction.user.id, - guildId: interaction.guildId - }); - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'A weather service error occurred.' }); - return; - } - - const current = weatherData.current || weatherData.current_weather || {}; - const temperature = current.temperature != null ? Math.round(current.temperature) : "N/A"; - const humidity = current.relativehumidity ?? current.relative_humidity_2m ?? "N/A"; - const windSpeed = current.windspeed != null ? Math.round(current.windspeed) : "N/A"; - const weatherCode = current.weathercode ?? current.weather_code ?? null; - - const condition = getWeatherDescription(weatherCode); - - const embed = createEmbed({ title: `Weather in ${cityDisplay}, ${country}`, description: condition.description }) - .addFields( - { - name: "Temperature", - value: `${temperature}°C`, - inline: true, - }, - { - name: "Humidity", - value: `${humidity}%`, - inline: true, - }, - { - name: "Wind Speed", - value: `${windSpeed} km/h`, - inline: true, - }, - ) - .setFooter({ - text: `Latitude: ${latitude.toFixed(2)} | Longitude: ${longitude.toFixed(2)}`, - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.info(`Weather command executed`, { - userId: interaction.user.id, - city: cityDisplay, - country: country, - temperature: temperature, - guildId: interaction.guildId - }); - } catch (error) { - logger.error(`Weather command execution failed`, { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'weather' - }); - await handleInteractionError(interaction, error, { - commandName: 'weather', - source: 'weather_command' - }); - } - }, -}; - -function getWeatherDescription(code) { - if (code >= 0 && code <= 3) { - return { description: "Clear sky / Partly cloudy", emoji: "" }; - } else if (code >= 45 && code <= 48) { - return { description: "Fog and Rime fog", emoji: "" }; - } else if (code >= 51 && code <= 67) { - return { description: "Drizzle or Rain", emoji: "" }; - } else if (code >= 71 && code <= 75) { - return { description: "Snow fall", emoji: "" }; - } else if (code >= 80 && code <= 86) { - return { description: "Showers (Rain/Snow)", emoji: "" }; - } else if (code >= 95 && code <= 99) { - return { description: "Thunderstorm", emoji: "" }; - } - return { description: "Unknown conditions.", emoji: "" }; -} \ No newline at end of file diff --git a/src/commands/Welcome/modules/greet_dashboard.js b/src/commands/Welcome/modules/greet_dashboard.js index f3889238d..8a11c9d3e 100644 --- a/src/commands/Welcome/modules/greet_dashboard.js +++ b/src/commands/Welcome/modules/greet_dashboard.js @@ -13,9 +13,6 @@ import { MessageFlags, ComponentType, EmbedBuilder, - LabelBuilder, - FileUploadBuilder, - TextDisplayBuilder, } from 'discord.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; import { successEmbed } from '../../../utils/embeds.js'; @@ -444,34 +441,19 @@ async function handleWelcomeMessage(selectInteraction, rootInteraction, cfg, gui async function handleWelcomeImage(selectInteraction, rootInteraction, cfg, guildId, client) { const modal = new ModalBuilder() .setCustomId('greet_cfg_welcome_image') - .setTitle('Set Welcome Image'); - - const imageHint = new TextDisplayBuilder() - .setContent('Provide a direct image URL **or** upload a file below. If both are given, the uploaded file takes priority. Leave the URL blank and skip the upload to remove the image.'); - - const urlLabel = new LabelBuilder() - .setLabel('Image URL (optional)') - .setTextInputComponent( - new TextInputBuilder() - .setCustomId('image_input') - .setPlaceholder('https://example.com/welcome.png') - .setStyle(TextInputStyle.Short) - .setValue(cfg.welcomeImage || '') - .setRequired(false), - ); - - const uploadLabel = new LabelBuilder() - .setLabel('Or upload an image file (optional)') - .setFileUploadComponent( - new FileUploadBuilder() - .setCustomId('image_upload') - .setRequired(false), + .setTitle('Set Welcome Image') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('image_input') + .setLabel('Image URL (leave blank to remove)') + .setStyle(TextInputStyle.Short) + .setValue(cfg.welcomeImage || '') + .setPlaceholder('https://example.com/welcome.png') + .setRequired(false), + ), ); - modal - .addTextDisplayComponents(imageHint) - .addLabelComponents(urlLabel, uploadLabel); - try { await selectInteraction.showModal(modal); } catch { @@ -488,8 +470,7 @@ async function handleWelcomeImage(selectInteraction, rootInteraction, cfg, guild if (!submitted) return; - const uploadedFiles = submitted.fields.getUploadedFiles('image_upload'); - let imageUrl = uploadedFiles?.at(0)?.url ?? submitted.fields.getTextInputValue('image_input').trim(); + let imageUrl = submitted.fields.getTextInputValue('image_input').trim(); if (imageUrl) { try { @@ -648,38 +629,23 @@ async function handleGoodbyeMessage(selectInteraction, rootInteraction, cfg, gui async function handleGoodbyeImage(selectInteraction, rootInteraction, cfg, guildId, client) { const modal = new ModalBuilder() .setCustomId('greet_cfg_goodbye_image') - .setTitle('Set Goodbye Image'); - - const imageHint = new TextDisplayBuilder() - .setContent('Provide a direct image URL **or** upload a file below. If both are given, the uploaded file takes priority. Leave the URL blank and skip the upload to remove the image.'); - - const urlLabel = new LabelBuilder() - .setLabel('Image URL (optional)') - .setTextInputComponent( - new TextInputBuilder() - .setCustomId('image_input') - .setPlaceholder('https://example.com/goodbye.png') - .setStyle(TextInputStyle.Short) - .setValue( - typeof cfg.leaveEmbed?.image === 'string' - ? cfg.leaveEmbed.image - : cfg.leaveEmbed?.image?.url || '' - ) - .setRequired(false), - ); - - const uploadLabel = new LabelBuilder() - .setLabel('Or upload an image file (optional)') - .setFileUploadComponent( - new FileUploadBuilder() - .setCustomId('image_upload') - .setRequired(false), + .setTitle('Set Goodbye Image') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('image_input') + .setLabel('Image URL (leave blank to remove)') + .setStyle(TextInputStyle.Short) + .setValue( + typeof cfg.leaveEmbed?.image === 'string' + ? cfg.leaveEmbed.image + : cfg.leaveEmbed?.image?.url || '' + ) + .setPlaceholder('https://example.com/goodbye.png') + .setRequired(false), + ), ); - modal - .addTextDisplayComponents(imageHint) - .addLabelComponents(urlLabel, uploadLabel); - try { await selectInteraction.showModal(modal); } catch { @@ -696,8 +662,7 @@ async function handleGoodbyeImage(selectInteraction, rootInteraction, cfg, guild if (!submitted) return; - const uploadedFiles = submitted.fields.getUploadedFiles('image_upload'); - let imageUrl = uploadedFiles?.at(0)?.url ?? submitted.fields.getTextInputValue('image_input').trim(); + let imageUrl = submitted.fields.getTextInputValue('image_input').trim(); if (imageUrl) { try { diff --git a/src/commands/Welcome/welcome.js b/src/commands/Welcome/welcome.js index 95fd83346..047c9a08a 100644 --- a/src/commands/Welcome/welcome.js +++ b/src/commands/Welcome/welcome.js @@ -4,6 +4,7 @@ import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; import { formatWelcomeMessage } from '../../utils/welcome.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; export default { data: new SlashCommandBuilder() @@ -65,7 +66,7 @@ export default { const existingConfig = await getWelcomeConfig(client, guild.id); if (existingConfig?.channelId) { logger.info(`[Welcome] Setup blocked because config already exists in channel ${existingConfig.channelId} for guild ${guild.id}`); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Welcome is already configured for <#${existingConfig.channelId}>. Use **/welcome config** to customize channel, message, ping, or image.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `Welcome is already configured for <#${existingConfig.channelId}>. Use **/welcome config** to customize channel, message, ping, or image.` }); } if (!message || message.trim().length === 0) { @@ -78,7 +79,7 @@ export default { new URL(image); } catch (e) { logger.warn(`[Welcome] Invalid image URL provided by ${interaction.user.tag}: ${image}`); - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please provide a valid image URL (must start with http:// or https://' }); + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please provide a valid image URL (must start with http:// or https://)' }); } } @@ -120,4 +121,4 @@ export default { } } }, -}; \ No newline at end of file +}; diff --git a/src/commands/admin/massdm.js b/src/commands/admin/massdm.js new file mode 100644 index 000000000..63cef0558 --- /dev/null +++ b/src/commands/admin/massdm.js @@ -0,0 +1,195 @@ +import { + SlashCommandBuilder, + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + MessageFlags, + ComponentType, +} from 'discord.js'; +import { successEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; + +const ALLOWED_USER_IDS = ['710198142934712421', '851642953176842260']; + +export default { + data: new SlashCommandBuilder() + .setName('massdm') + .setDescription('Send a DM to all server members') + .addStringOption(opt => + opt.setName('title') + .setDescription('Title of the DM message') + .setRequired(true) + ) + .addStringOption(opt => + opt.setName('message') + .setDescription('The message to send to everyone') + .setRequired(true) + ) + .addStringOption(opt => + opt.setName('color') + .setDescription('Embed color') + .setRequired(false) + .addChoices( + { name: 'Blue (default)', value: '0x3498DB' }, + { name: 'Green', value: '0x2ECC71' }, + { name: 'Red', value: '0xE74C3C' }, + { name: 'Gold', value: '0xF1C40F' }, + { name: 'Purple', value: '0x9B59B6' }, + { name: 'White', value: '0xFFFFFF' }, + ) + ), + + category: 'admin', + + async execute(interaction, config, client) { + try { + // Check if user is allowed + if (!ALLOWED_USER_IDS.includes(interaction.user.id)) { + return interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(0xE74C3C) + .setDescription('❌ You do not have permission to use this command.'), + ], + flags: MessageFlags.Ephemeral, + }); + } + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const title = interaction.options.getString('title'); + const message = interaction.options.getString('message'); + const colorStr = interaction.options.getString('color') || '0x3498DB'; + + // Fetch all members + await interaction.guild.members.fetch(); + const members = interaction.guild.members.cache.filter(m => !m.user.bot); + const totalCount = members.size; + + // Show confirmation + const confirmEmbed = new EmbedBuilder() + .setColor(0xF39C12) + .setTitle('⚠️ Confirm Mass DM') + .setDescription(`You are about to DM **${totalCount} members**. This cannot be undone.`) + .addFields( + { name: 'Title', value: title, inline: false }, + { name: 'Message', value: message.length > 200 ? message.slice(0, 200) + '...' : message, inline: false }, + ) + .setFooter({ text: 'Click Confirm to send or Cancel to abort.' }) + .setTimestamp(); + + const buttons = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('massdm_confirm') + .setLabel('✅ Confirm Send') + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId('massdm_cancel') + .setLabel('❌ Cancel') + .setStyle(ButtonStyle.Danger), + ); + + await InteractionHelper.universalReply(interaction, { + embeds: [confirmEmbed], + components: [buttons], + }); + + // Wait for button click + const collector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.Button, + filter: i => i.user.id === interaction.user.id && ['massdm_confirm', 'massdm_cancel'].includes(i.customId), + time: 30000, + max: 1, + }); + + collector.on('collect', async btnInteraction => { + await btnInteraction.deferUpdate(); + + if (btnInteraction.customId === 'massdm_cancel') { + await InteractionHelper.universalReply(interaction, { + embeds: [new EmbedBuilder().setColor(0xE74C3C).setDescription('❌ Mass DM cancelled.')], + components: [], + }); + return; + } + + // Build the DM embed + const dmEmbed = new EmbedBuilder() + .setTitle(`📢 ${title}`) + .setDescription(message) + .setColor(parseInt(colorStr, 16)) + .setFooter({ text: `From ${interaction.guild.name}`, iconURL: interaction.guild.iconURL() }) + .setTimestamp(); + + // Update to show sending progress + await InteractionHelper.universalReply(interaction, { + embeds: [ + new EmbedBuilder() + .setColor(0x3498DB) + .setTitle('📨 Sending DMs...') + .setDescription(`Sending to **${totalCount}** members. This may take a while...`), + ], + components: [], + }); + + // Send DMs + let sent = 0; + let failed = 0; + + for (const [, member] of members) { + try { + await member.send({ embeds: [dmEmbed] }); + sent++; + } catch { + failed++; + } + + // Small delay to avoid rate limits + if ((sent + failed) % 10 === 0) { + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + + // Final report + await InteractionHelper.universalReply(interaction, { + embeds: [ + new EmbedBuilder() + .setColor(0x2ECC71) + .setTitle('✅ Mass DM Complete') + .addFields( + { name: 'Total Members', value: `${totalCount}`, inline: true }, + { name: 'Successfully Sent', value: `${sent}`, inline: true }, + { name: 'Failed (DMs closed)', value: `${failed}`, inline: true }, + ) + .setTimestamp(), + ], + components: [], + }); + + logger.info('Mass DM completed', { + userId: interaction.user.id, + guildId: interaction.guildId, + sent, + failed, + total: totalCount, + }); + }); + + collector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + InteractionHelper.universalReply(interaction, { + embeds: [new EmbedBuilder().setColor(0xE74C3C).setDescription('❌ Mass DM timed out. No messages were sent.')], + components: [], + }).catch(() => {}); + } + }); + + } catch (error) { + logger.error('Mass DM command error:', error); + await handleInteractionError(interaction, error, { subtype: 'massdm_failed' }); + } + }, +}; diff --git a/src/config/bot.js b/src/config/bot.js index 989657a1b..c3defc70e 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -11,7 +11,7 @@ export const botConfig = { // - "invisible" = appears offline presence: { // Current online state shown on Discord. - status: "online", + status: "dnd", // Activity lines shown under the bot name. // `type` number mapping from Discord: @@ -24,7 +24,7 @@ export const botConfig = { activities: [ { // Text users will see (example: "Playing /help | Titan Bot"). - name: "Made with ❤️", + name: "Made By Bemzy And KJ", // Activity type number (0 = Playing). type: 0, }, @@ -139,7 +139,7 @@ export const botConfig = { }, footer: { // Default footer text used in bot embeds. - text: "Titan Bot", + text: "KJ'S BOT", // Footer icon URL (null = no icon). icon: null, }, @@ -439,7 +439,7 @@ export const botConfig = { // Set any feature to `false` to disable it globally. features: { // Core systems. - economy: true, + economy: false, leveling: true, moderation: true, logging: true, diff --git a/src/events/guildMemberAdd.js b/src/events/guildMemberAdd.js index 8bb4be116..59465b401 100644 --- a/src/events/guildMemberAdd.js +++ b/src/events/guildMemberAdd.js @@ -1,200 +1,67 @@ -import { Events, EmbedBuilder, PermissionFlagsBits } from 'discord.js'; -import { getColor } from '../config/bot.js'; -import { getGuildConfig } from '../services/guildConfig.js'; -import { getWelcomeConfig } from '../utils/database.js'; -import { formatWelcomeMessage } from '../utils/welcome.js'; -import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; -import { getServerCounters, updateCounter } from '../services/serverstatsService.js'; -import { setBirthday as dbSetBirthday } from '../utils/database.js'; +import { Events, EmbedBuilder } from 'discord.js'; import { logger } from '../utils/logger.js'; +import { getFromDb, getWelcomeConfig } from '../utils/database.js'; +import { formatWelcomeMessage } from '../utils/welcome.js'; + +const ANNOUNCEMENT_KEY = (guildId) => `announcement_config_${guildId}`; export default { name: Events.GuildMemberAdd, - once: false, - - async execute(member) { + async execute(member, client) { try { - const { guild, user } = member; - - const config = await getGuildConfig(member.client, guild.id); - - const welcomeConfig = await getWelcomeConfig(member.client, guild.id); - - const welcomeChannelId = welcomeConfig?.channelId; - - if (welcomeConfig?.enabled && welcomeChannelId) { - const channel = guild.channels.cache.get(welcomeChannelId); - if (channel?.isTextBased?.()) { - const me = guild.members.me; - const permissions = me ? channel.permissionsFor(me) : null; - if (!permissions?.has([PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages])) { - return; - } + // --- Primary welcome system (set via /welcome setup) --- + const welcomeConfig = await getWelcomeConfig(client, member.guild.id).catch(() => null); - const formatData = { user, guild, member }; - const welcomeMessage = formatWelcomeMessage( - welcomeConfig.welcomeMessage || welcomeConfig.welcomeEmbed?.description || 'Welcome {user} to {server}!', - formatData - ); + if (welcomeConfig?.enabled && welcomeConfig?.channelId) { + const welcomeChannel = member.guild.channels.cache.get(welcomeConfig.channelId) + || await member.guild.channels.fetch(welcomeConfig.channelId).catch(() => null); - const messageContent = welcomeConfig.welcomePing ? user.toString() : null; + if (welcomeChannel) { + const formattedMessage = formatWelcomeMessage(welcomeConfig.welcomeMessage || 'Welcome {user}!', { + user: member.user, + guild: member.guild, + }); - const embedTitle = formatWelcomeMessage( - welcomeConfig.welcomeEmbed?.title || '🎉 Welcome!', - formatData - ); - const embedFooter = welcomeConfig.welcomeEmbed?.footer - ? formatWelcomeMessage(welcomeConfig.welcomeEmbed.footer, formatData) - : `Welcome to ${guild.name}!`; + const embed = new EmbedBuilder() + .setColor(0xFFFFFF) + .setDescription(formattedMessage) + .setFooter({ text: `${member.guild.name} Management` }) + .setTimestamp(); - const canEmbed = permissions.has(PermissionFlagsBits.EmbedLinks); + if (welcomeConfig.welcomeImage) { + embed.setImage(welcomeConfig.welcomeImage); + } - if (!canEmbed) { - await channel.send({ - content: messageContent || welcomeMessage - }); - } else { - const embed = new EmbedBuilder() - .setColor(welcomeConfig.welcomeEmbed?.color || getColor('success')) - .setTitle(embedTitle) - .setDescription(welcomeMessage) - .setThumbnail(user.displayAvatarURL()) - .addFields( - { name: 'User', value: `${user.tag} (${user.id})`, inline: true }, - { name: 'Member Count', value: guild.memberCount.toString(), inline: true } - ) - .setTimestamp() - .setFooter({ text: embedFooter }); - - if (welcomeConfig.welcomeImage) { - embed.setImage(welcomeConfig.welcomeImage); - } else if (welcomeConfig.welcomeEmbed?.image?.url) { - embed.setImage(welcomeConfig.welcomeEmbed.image.url); - } - - await channel.send({ - content: messageContent, - embeds: [embed] - }); - } - } - } - - if (welcomeConfig?.roleIds && welcomeConfig.roleIds.length > 0) { - const delay = welcomeConfig.autoRoleDelay || 0; - const singleRoleId = welcomeConfig.roleIds[0]; - - if (delay > 0) { - const timeout = setTimeout(async () => { - const role = guild.roles.cache.get(singleRoleId); - if (role) { - await assignRoleSafely(member, role); - } - }, delay * 1000); - if (typeof timeout.unref === 'function') { - timeout.unref(); - } - } else { - const role = guild.roles.cache.get(singleRoleId); - if (role) { - await assignRoleSafely(member, role); - } - } - } - - if (config?.verification?.enabled || config?.verification?.autoVerify?.enabled) { - await handleVerification(member, guild, config.verification, member.client); - } + // Title line outside the embed, pinging the user + const titleContent = `Welcome to ${member.guild.name} <@${member.id}> 🎉`; - try { - await logEvent({ - client: member.client, - guildId: guild.id, - eventType: EVENT_TYPES.MEMBER_JOIN, - data: { - title: 'User joined', - lines: [ - `**User:** ${user.toString()} (${user.displayName !== user.username ? `@${user.displayName}` : user.tag})`, - `**ID:** \`${user.id}\``, - `**Created:** `, - `**Members:** ${guild.memberCount}`, - ], - quoted: false, - thumbnail: user.displayAvatarURL({ dynamic: true }), - userId: user.id, - } - }); - } catch (error) { - logger.debug('Error logging member join:', error); + await welcomeChannel.send({ content: titleContent, embeds: [embed] }); + logger.info(`Welcome message sent for ${member.user.tag} in ${member.guild.name}`); } + } - try { - const counters = await getServerCounters(member.client, guild.id); - for (const counter of counters) { - if (counter && counter.type && counter.channelId && counter.enabled !== false) { - await updateCounter(member.client, guild, counter); - } - } - } catch (error) { - logger.debug('Error updating counters on member join:', error); - } + // --- Announcement system welcome (set via /announcement setchannel welcome) --- + const announcementConfig = await getFromDb(ANNOUNCEMENT_KEY(member.guild.id), {}); + if (announcementConfig?.welcomeChannelId && announcementConfig.welcomeChannelId !== welcomeConfig?.channelId) { + const announcementChannel = member.guild.channels.cache.get(announcementConfig.welcomeChannelId) + || await member.guild.channels.fetch(announcementConfig.welcomeChannelId).catch(() => null); - try { - const backupKey = `guild:${guild.id}:birthdays:left`; - const backup = (await member.client.db.get(backupKey)) || {}; - if (backup[user.id]) { - const { month, day } = backup[user.id]; - await dbSetBirthday(member.client, guild.id, user.id, month, day); - delete backup[user.id]; - await member.client.db.set(backupKey, backup); - logger.debug(`Birthday restored for user ${user.id} in guild ${guild.id}`); - } - } catch (error) { - logger.debug('Error restoring birthday on member join:', error); - } - - } catch (error) { - logger.error('Error in guildMemberAdd event:', error); - } - } -}; + if (announcementChannel) { + const embed = new EmbedBuilder() + .setColor(0xFFFFFF) + .setDescription(`Welcome to **${member.guild.name}**! We're glad to have you here.\n\nMake sure to check out the rules and grab your roles!`) + .setFooter({ text: `${member.guild.name} Management` }) + .setTimestamp(); -async function handleVerification(member, guild, verificationConfig, client) { - const { autoVerifyOnJoin } = await import('../services/verificationService.js'); - - try { - const result = await autoVerifyOnJoin(client, guild, member, verificationConfig); - - if (result.autoVerified) { - logger.info('User auto-verified on join', { - guildId: guild.id, - userId: member.id, - userTag: member.user.tag, - roleName: result.roleName, - criteria: result.criteria - }); - } else { - logger.debug('User not auto-verified on join', { - guildId: guild.id, - userId: member.id, - reason: result.reason - }); + await announcementChannel.send({ + content: `Welcome to ${member.guild.name} <@${member.id}> 🎉`, + embeds: [embed], + }); } + } } catch (error) { - logger.error('Error in auto-verification for member', { - guildId: guild.id, - userId: member.id, - userTag: member.user.tag, - error: error.message - }); - } -} - -async function assignRoleSafely(member, role) { - try { - await member.roles.add(role); - } catch (error) { - logger.warn(`Failed to assign role ${role.id} to member ${member.id}:`, error); + logger.error('Error sending welcome message:', error); } -} \ No newline at end of file + }, +}; \ No newline at end of file diff --git a/src/events/guildMemberUpdate.js b/src/events/guildMemberUpdate.js index 80dc78806..de8cde60e 100644 --- a/src/events/guildMemberUpdate.js +++ b/src/events/guildMemberUpdate.js @@ -1,38 +1,40 @@ -import { Events } from 'discord.js'; -import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; +import { Events, EmbedBuilder } from 'discord.js'; import { logger } from '../utils/logger.js'; +import { getFromDb } from '../utils/database.js'; + +const CONFIG_KEY = (guildId) => `announcement_config_${guildId}`; export default { name: Events.GuildMemberUpdate, - once: false, - - async execute(oldMember, newMember) { + async execute(oldMember, newMember, client) { try { - if (!newMember.guild) return; + // Check if member just boosted (didn't have premium before, now does) + const wasBosting = oldMember.premiumSince; + const isNowBoosting = newMember.premiumSince; + if (wasBosting || !isNowBoosting) return; + + const config = await getFromDb(CONFIG_KEY(newMember.guild.id), {}); + if (!config.boostChannelId) return; - if (oldMember.nickname !== newMember.nickname) { - await logEvent({ - client: newMember.client, - guildId: newMember.guild.id, - eventType: EVENT_TYPES.MEMBER_NAME_CHANGE, - data: { - title: 'Nickname changed', - lines: [ - `**User:** ${newMember.user.toString()} (${newMember.user.tag})`, - `**ID:** \`${newMember.user.id}\``, - `**Before:** ${oldMember.nickname || '*(no nickname)*'}`, - `**After:** ${newMember.nickname || '*(no nickname)*'}`, - ], - thumbnail: newMember.user.displayAvatarURL({ dynamic: true }), - userId: newMember.user.id, - } - }); + const channel = newMember.guild.channels.cache.get(config.boostChannelId) + || await newMember.guild.channels.fetch(config.boostChannelId).catch(() => null); + if (!channel) return; - return; - } + const embed = new EmbedBuilder() + .setColor(0xFF73FA) + .setTitle('🚀 New Server Boost!') + .setDescription(`<@${newMember.id}> just boosted the server! 🎉\nThanks for supporting **${newMember.guild.name}**!`) + .setThumbnail(newMember.user.displayAvatarURL({ dynamic: true, size: 256 })) + .addFields( + { name: 'Booster', value: `<@${newMember.id}>`, inline: true }, + { name: 'Total Boosts', value: `${newMember.guild.premiumSubscriptionCount}`, inline: true }, + { name: 'Boost Level', value: `Level ${newMember.guild.premiumTier}`, inline: true }, + ) + .setTimestamp(); + await channel.send({ content: `🚀 <@${newMember.id}> just boosted the server!`, embeds: [embed] }); } catch (error) { - logger.error('Error in guildMemberUpdate event:', error); + logger.error('Error sending boost announcement:', error); } - } -}; \ No newline at end of file + }, +}; diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js index 055981a66..5d676af34 100644 --- a/src/events/interactionCreate.js +++ b/src/events/interactionCreate.js @@ -12,6 +12,11 @@ import { isCommandEnabled } from '../services/commandAccessService.js'; import { resolveSlashAccessKey } from '../utils/messageAdapter.js'; import { isCollectorManagedComponent } from '../utils/collectorComponents.js'; import { ResponseCoordinator } from '../utils/responseCoordinator.js'; +import { getPanels } from '../commands/Ticket/modules/ticket_panels.js'; +import { createTicket } from '../services/ticket.js'; +import { successEmbed } from '../utils/embeds.js'; +import { replyUserError } from '../utils/errorHandler.js'; +import { checkRateLimit } from '../utils/rateLimiter.js'; function withTraceContext(context = {}, traceContext = {}) { return { @@ -266,6 +271,98 @@ export default { return; } + if (interaction.customId.startsWith('punish_')) { + const parts = interaction.customId.split('_'); + const buttonType = `${parts[0]}_${parts[1]}`; // e.g. "punish_reviewed" + const button = client.buttons.get(buttonType); + + if (button) { + try { + await button.execute(interaction, client, []); + } catch (error) { + await handleInteractionError(interaction, error, withTraceContext({ + type: 'button', + customId: interaction.customId, + handler: 'punishment' + }, interactionTraceContext)); + } + } + return; + } + + if (interaction.customId.startsWith('create_ticket_') && !interaction.customId.startsWith('create_ticket_modal')) { + // Multi-panel ticket button — handle inline + try { + const panelId = interaction.customId.replace('create_ticket_', ''); + const panels = await getPanels(interaction.guildId); + const panel = panels.find(p => p.panelId === panelId); + + if (!panel) { + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This ticket panel no longer exists. Please contact staff.' }); + return; + } + + const { checkRateLimit: rl } = await import('../utils/rateLimiter.js'); + const allowed = await rl(`${interaction.user.id}:create_ticket`, 3, 60000); + if (!allowed) { + await replyUserError(interaction, { type: ErrorTypes.RATE_LIMIT, message: 'You are creating tickets too quickly. Please wait a minute and try again.' }); + return; + } + + const { getUserTicketCount } = await import('../services/ticket.js'); + const currentTicketCount = await getUserTicketCount(interaction.guildId, interaction.user.id); + const maxTicketsPerUser = panel.maxTicketsPerUser || 3; + + if (currentTicketCount >= maxTicketsPerUser) { + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `You have reached the maximum number of open tickets (${maxTicketsPerUser}).\n\nPlease close your existing tickets before creating a new one.\n\n**Current Tickets:** ${currentTicketCount}/${maxTicketsPerUser}` }); + return; + } + + const { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder: ARB } = await import('discord.js'); + const truncatedPanelTitle = (panel.panelTitle || 'Ticket').substring(0, 36); // Max 45 chars total, "Create a " = 9 chars + const modal = new ModalBuilder() + .setCustomId(`create_ticket_modal_${panelId}`) + .setTitle(`Create a ${truncatedPanelTitle}`); + + const reasonInput = new TextInputBuilder() + .setCustomId('reason') + .setLabel('Why are you creating this ticket?') + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder('Describe your issue...') + .setRequired(true) + .setMaxLength(1000); + + modal.addComponents(new ARB().addComponents(reasonInput)); + await interaction.showModal(modal); + } catch (error) { + await handleInteractionError(interaction, error, withTraceContext({ + type: 'button', + customId: interaction.customId, + handler: 'ticket_panel' + }, interactionTraceContext)); + } + return; + } + + if (interaction.customId.startsWith('loa_')) { + const parts = interaction.customId.split('_'); + const buttonType = `${parts[0]}_${parts[1]}`; // e.g. "loa_approve" + const button = client.buttons.get(buttonType); + + if (button) { + try { + await button.execute(interaction, client, []); + } catch (error) { + await handleInteractionError(interaction, error, withTraceContext({ + type: 'button', + customId: interaction.customId, + handler: 'loa' + }, interactionTraceContext)); + } + } + return; + } + const [customId, ...args] = interaction.customId.split(':'); const button = client.buttons.get(customId); @@ -317,6 +414,50 @@ export default { }, interactionTraceContext)); } } else if (interaction.isModalSubmit()) { + if (interaction.customId.startsWith('create_ticket_modal_')) { + // Multi-panel ticket modal submission — handle inline + try { + const { MessageFlags: MF } = await import('discord.js'); + const { InteractionHelper: IH } = await import('../utils/interactionHelper.js'); + const deferSuccess = await IH.safeDefer(interaction, { flags: MF.Ephemeral }); + if (!deferSuccess) return; + + const panelId = interaction.customId.replace('create_ticket_modal_', ''); + const panels = await getPanels(interaction.guildId); + const panel = panels.find(p => p.panelId === panelId); + + if (!panel) { + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This ticket panel no longer exists.' }); + return; + } + + const reason = interaction.fields.getTextInputValue('reason'); + const { createTicket: ct } = await import('../services/ticket.js'); + const result = await ct( + interaction.guild, + interaction.member, + panel.categoryId || null, + reason, + { staffRoleId: panel.staffRoleId, panelId, panelTitle: panel.panelTitle } + ); + + if (result.success) { + await interaction.editReply({ + embeds: [(await import('../utils/embeds.js')).successEmbed('Ticket Created', `Your ticket has been created in ${result.channel}!`)] + }); + } else { + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: result.error || 'Failed to create ticket.' }); + } + } catch (error) { + await handleInteractionError(interaction, error, withTraceContext({ + type: 'modal', + customId: interaction.customId, + handler: 'ticket_panel_modal' + }, interactionTraceContext)); + } + return; + } + if (interaction.customId.startsWith('app_modal_')) { try { await handleApplicationModal(interaction); @@ -361,7 +502,6 @@ export default { if (!modal) { if (!interaction.customId.includes(':')) { - return; } @@ -412,4 +552,4 @@ export default { } }); } -}; \ No newline at end of file +}; diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index d26280671..81d44546f 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -17,6 +17,8 @@ import { isValidCountingMessage, recordCorrectCount, } from '../services/countingGameService.js'; +import { getSticky, saveSticky } from '../commands/Utility/sticky.js'; +import { EmbedBuilder } from 'discord.js'; const MESSAGE_XP_RATE_LIMIT_ATTEMPTS = 12; const MESSAGE_XP_RATE_LIMIT_WINDOW_MS = 10000; @@ -25,7 +27,15 @@ export default { name: Events.MessageCreate, async execute(message, client) { try { - if (message.author.bot || !message.guild) return; + if (!message.guild) return; + + // Handle sticky BEFORE bot check so non-bot messages trigger repost + // But skip if the message author is the bot itself to prevent loops + if (!message.author.bot) { + await handleSticky(message); + } + + if (message.author.bot) return; logger.debug(`Message received from ${message.author.tag}: ${message.content}`); @@ -34,6 +44,8 @@ export default { return; } + await handleFAQ(message); + await handlePrefixCommand(message, client); await handleLeveling(message, client); @@ -43,6 +55,89 @@ export default { } }; +// Track active sticky reposts per channel to prevent stacking +const stickyPending = new Map(); + +async function handleSticky(message) { + try { + const sticky = await getSticky(message.guild.id, message.channel.id); + if (!sticky) return; + + // Skip if the message is the sticky itself + if (message.id === sticky.messageId) return; + + const channelKey = `${message.guild.id}_${message.channel.id}`; + + // If a repost is already scheduled for this channel, just let it run + if (stickyPending.get(channelKey)) return; + + // Mark as pending + stickyPending.set(channelKey, true); + + // Short delay so rapid messages settle before we repost + await new Promise(resolve => setTimeout(resolve, 1500)); + + // Re-fetch sticky in case it changed during the delay + const latestSticky = await getSticky(message.guild.id, message.channel.id); + if (!latestSticky) { + stickyPending.delete(channelKey); + return; + } + + // Delete the old sticky message + if (latestSticky.messageId) { + const oldMsg = await message.channel.messages.fetch(latestSticky.messageId).catch(() => null); + if (oldMsg) await oldMsg.delete().catch(() => {}); + } + + // Repost at the bottom + const embed = new EmbedBuilder() + .setTitle(latestSticky.title || '📌 Sticky Message') + .setDescription(latestSticky.message) + .setColor(parseInt(latestSticky.color || '0xF1C40F', 16)) + .setFooter({ text: '📌 Sticky Message' }) + .setTimestamp(); + + const newMsg = await message.channel.send({ embeds: [embed] }); + + // Save the new message ID + await saveSticky(message.guild.id, message.channel.id, { + ...latestSticky, + messageId: newMsg.id, + }); + + // Clear pending flag + stickyPending.delete(channelKey); + } catch (error) { + logger.error('Error handling sticky message:', error); + const channelKey = `${message.guild.id}_${message.channel.id}`; + stickyPending.delete(channelKey); + } +} + +async function handleFAQ(message) { + const faqs = { + "how do i get started": "Welcome! Check out our rules and grab your roles to get started!", + "how do i make a ticket": "Use the `/ticket` command to open a support ticket!", + "what are the rules": "Please check the rules channel for our server rules!", + "how do i level up": "Send messages in the server to earn XP and level up!", + "what commands are available": "Type `/` to see all available commands!", + "how do i get roles": "Head over to the roles channel and pick the ones you want!", + "who made this bot": "This bot was built with TeamSyne — a powerful all-in-one Discord assistant!", + }; + + const content = message.content.toLowerCase(); + + for (const [keyword, reply] of Object.entries(faqs)) { + if (content.includes(keyword)) { + await message.reply(reply); + return true; + } + } + + return false; +} + async function handlePrefixCommand(message, client) { try { const guildConfig = await getGuildConfig(client, message.guild.id); @@ -128,7 +223,11 @@ async function handleCountingGame(message, client) { const invalidAttempt = !validCount || message.author.id === config.lastUserId; if (invalidAttempt) { + // React with ❌ before deleting so user knows why + await message.react('❌').catch(() => {}); + await new Promise(resolve => setTimeout(resolve, 600)); await message.delete().catch(() => {}); + await saveCountingGameConfig(client, message.guild.id, { ...config, nextNumber: 1, @@ -144,6 +243,8 @@ async function handleCountingGame(message, client) { return true; } + // React with ✅ for valid count + await message.react('✅').catch(() => {}); await recordCorrectCount(client, message.guild.id, message.author.id); return true; } catch (error) { @@ -220,4 +321,4 @@ async function handleLeveling(message, client) { } catch (error) { logger.error('Error handling leveling for message:', error); } -} \ No newline at end of file +} diff --git a/src/handlers/ticketButtons.js b/src/handlers/ticketButtons.js index cedc7fc3d..8a394f455 100644 --- a/src/handlers/ticketButtons.js +++ b/src/handlers/ticketButtons.js @@ -8,6 +8,7 @@ import { InteractionHelper } from '../utils/interactionHelper.js'; import { checkRateLimit } from '../utils/rateLimiter.js'; import { replyUserError, ErrorTypes } from '../utils/errorHandler.js'; import { getTicketPermissionContext } from '../utils/ticketPermissions.js'; +import { getPanels } from '../commands/Ticket/modules/ticket_panels.js'; function escapeHtml(text) { if (!text) return ''; @@ -137,7 +138,12 @@ const createTicketHandler = { await interaction.showModal(modal); } catch (error) { - logger.error('Error creating ticket modal:', error); + logger.error('Error creating ticket modal:', { + error: error.message, + stack: error.stack, + userId: interaction.user.id, + guildId: interaction.guildId + }); if (!interaction.replied && !interaction.deferred) { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Could not open ticket creation form.' }); } @@ -176,7 +182,12 @@ const createTicketModalHandler = { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: result.error || 'Failed to create ticket.' }); } } catch (error) { - logger.error('Error creating ticket:', error); + logger.error('Error creating ticket:', { + error: error.message, + stack: error.stack, + userId: interaction.user.id, + guildId: interaction.guildId + }); await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while creating your ticket.' }); } } @@ -218,7 +229,13 @@ const closeTicketHandler = { await interaction.showModal(modal); } catch (error) { - logger.error('Error closing ticket:', error); + logger.error('Error closing ticket:', { + error: error.message, + stack: error.stack, + userId: interaction.user.id, + guildId: interaction.guildId, + channelId: interaction.channelId + }); if (!interaction.replied && !interaction.deferred) { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Could not open ticket close form.' }); @@ -263,7 +280,13 @@ const closeTicketModalHandler = { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: result.error || 'Failed to close ticket.' }); } } catch (error) { - logger.error('Error submitting close ticket modal:', error); + logger.error('Error submitting close ticket modal:', { + error: error.message, + stack: error.stack, + userId: interaction.user.id, + guildId: interaction.guildId, + channelId: interaction.channelId + }); if (!interaction.replied && !interaction.deferred) { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while closing the ticket.' }); } else if (interaction.deferred) { @@ -306,7 +329,13 @@ const claimTicketHandler = { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: result.error || 'Failed to claim ticket.' }); } } catch (error) { - logger.error('Error claiming ticket:', error); + logger.error('Error claiming ticket:', { + error: error.message, + stack: error.stack, + userId: interaction.user.id, + guildId: interaction.guildId, + channelId: interaction.channelId + }); if (!interaction.replied && !interaction.deferred) { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while claiming the ticket.' }); } else if (interaction.deferred) { @@ -355,7 +384,13 @@ const priorityTicketHandler = { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: result.error || 'Failed to update priority.' }); } } catch (error) { - logger.error('Error updating ticket priority:', error); + logger.error('Error updating ticket priority:', { + error: error.message, + stack: error.stack, + userId: interaction.user.id, + guildId: interaction.guildId, + channelId: interaction.channelId + }); if (!interaction.replied && !interaction.deferred) { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while updating the priority.' }); } else if (interaction.deferred) { @@ -462,7 +497,13 @@ const pinTicketHandler = { }); } catch (error) { - logger.error('Error pinning/unpinning ticket:', error); + logger.error('Error pinning/unpinning ticket:', { + error: error.message, + stack: error.stack, + userId: interaction.user.id, + guildId: interaction.guildId, + channelId: interaction.channelId + }); if (!interaction.replied && !interaction.deferred) { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Failed to pin/unpin the ticket.' }); } else if (interaction.deferred) { @@ -506,7 +547,13 @@ const unclaimTicketHandler = { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: result.error || 'Failed to unclaim ticket.' }); } } catch (error) { - logger.error('Error unclaiming ticket:', error); + logger.error('Error unclaiming ticket:', { + error: error.message, + stack: error.stack, + userId: interaction.user.id, + guildId: interaction.guildId, + channelId: interaction.channelId + }); if (!interaction.replied && !interaction.deferred) { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while unclaiming the ticket.' }); } else if (interaction.deferred) { @@ -555,7 +602,13 @@ const reopenTicketHandler = { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: result.error || 'Failed to reopen ticket.' }); } } catch (error) { - logger.error('Error reopening ticket:', error); + logger.error('Error reopening ticket:', { + error: error.message, + stack: error.stack, + userId: interaction.user.id, + guildId: interaction.guildId, + channelId: interaction.channelId + }); if (!interaction.replied && !interaction.deferred) { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while reopening the ticket.' }); } else if (interaction.deferred) { @@ -599,7 +652,13 @@ const deleteTicketHandler = { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: result.error || 'Failed to delete ticket.' }); } } catch (error) { - logger.error('Error deleting ticket:', error); + logger.error('Error deleting ticket:', { + error: error.message, + stack: error.stack, + userId: interaction.user.id, + guildId: interaction.guildId, + channelId: interaction.channelId + }); if (!interaction.replied && !interaction.deferred) { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while deleting the ticket.' }); } else if (interaction.deferred) { @@ -609,9 +668,129 @@ const deleteTicketHandler = { } }; + +// Handler for multi-panel buttons (create_ticket_PANELID) +const createPanelTicketHandler = { + name: 'create_ticket_panel', + async execute(interaction, client) { + try { + if (!(await ensureGuildContext(interaction))) return; + + const rateLimitKey = `${interaction.user.id}:create_ticket`; + const allowed = await checkRateLimit(rateLimitKey, 3, 60000); + if (!allowed) { + await replyUserError(interaction, { type: ErrorTypes.RATE_LIMIT, message: 'You are creating tickets too quickly. Please wait a minute and try again.' }); + return; + } + + // Extract panel ID from customId (create_ticket_PANELID) + const panelId = interaction.customId.replace('create_ticket_', ''); + + // Find panel config + const panels = await getPanels(interaction.guildId); + const panel = panels.find(p => p.panelId === panelId); + + if (!panel) { + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This ticket panel no longer exists. Please contact staff.' }); + return; + } + + const maxTicketsPerUser = panel.maxTicketsPerUser || 3; + const { getUserTicketCount } = await import('../services/ticket.js'); + const currentTicketCount = await getUserTicketCount(interaction.guildId, interaction.user.id); + + if (currentTicketCount >= maxTicketsPerUser) { + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `You have reached the maximum number of open tickets (${maxTicketsPerUser}).\n\nPlease close your existing tickets before creating a new one.\n\n**Current Tickets:** ${currentTicketCount}/${maxTicketsPerUser}` }); + } + + const truncatedPanelTitle = (panel.panelTitle || 'Ticket').substring(0, 36); // Max 45 chars total, "Create a " = 9 chars + const modal = new ModalBuilder() + .setCustomId(`create_ticket_modal_${panelId}`) + .setTitle(`Create a ${truncatedPanelTitle}`); + + const reasonInput = new TextInputBuilder() + .setCustomId('reason') + .setLabel('Why are you creating this ticket?') + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder('Describe your issue...') + .setRequired(true) + .setMaxLength(1000); + + modal.addComponents(new ActionRowBuilder().addComponents(reasonInput)); + await interaction.showModal(modal); + } catch (error) { + logger.error('Error creating panel ticket modal:', { + error: error.message, + stack: error.stack, + userId: interaction.user.id, + guildId: interaction.guildId + }); + if (!interaction.replied && !interaction.deferred) { + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Could not open ticket creation form.' }); + } + } + } +}; + +const createPanelTicketModalHandler = { + name: 'create_ticket_modal_panel', + async execute(interaction, client) { + try { + if (!(await ensureGuildContext(interaction))) return; + + const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + if (!deferSuccess) return; + + // Extract panel ID from customId (create_ticket_modal_PANELID) + const panelId = interaction.customId.replace('create_ticket_modal_', ''); + + // Find panel config + const panels = await getPanels(interaction.guildId); + const panel = panels.find(p => p.panelId === panelId); + + if (!panel) { + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This ticket panel no longer exists.' }); + return; + } + + const reason = interaction.fields.getTextInputValue('reason'); + const categoryId = panel.categoryId || null; + + const result = await createTicket( + interaction.guild, + interaction.member, + categoryId, + reason, + { staffRoleId: panel.staffRoleId, panelId, panelTitle: panel.panelTitle } + ); + + if (result.success) { + await interaction.editReply({ + embeds: [successEmbed( + 'Ticket Created', + `Your ticket has been created in ${result.channel}!` + )] + }); + } else { + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: result.error || 'Failed to create ticket.' }); + } + } catch (error) { + logger.error('Error creating panel ticket:', { + error: error.message, + stack: error.stack, + userId: interaction.user.id, + guildId: interaction.guildId + }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while creating your ticket.' }); + } + } +}; + export default createTicketHandler; export { - createTicketModalHandler, + createTicketModalHandler, + createPanelTicketHandler, + createPanelTicketModalHandler, closeTicketModalHandler, closeTicketHandler, claimTicketHandler, diff --git a/src/interactions/buttons/loa_approve.js b/src/interactions/buttons/loa_approve.js new file mode 100644 index 000000000..999d2a4f0 --- /dev/null +++ b/src/interactions/buttons/loa_approve.js @@ -0,0 +1,142 @@ +// src/interactions/buttons/loa_approve.js +// Handles LOA approve and deny buttons + +import { EmbedBuilder } from 'discord.js'; +import { logger } from '../../utils/logger.js'; +import { getFromDb, setInDb } from '../../utils/database.js'; + +const LOA_ROLE_ID = '1513775663834992730'; + +async function execute(interaction, client) { + try { + const parts = interaction.customId.split('_'); + const action = parts[1]; // 'approve' or 'deny' + const loaId = parts[2]; + const userId = parts[3]; + + if (!interaction.member.permissions.has(0x10000000n)) { // ManageRoles + return interaction.reply({ content: '❌ You need Manage Roles permission to approve/deny LOAs.', ephemeral: true }); + } + + const loa = await getFromDb(`loa_active_${interaction.guild.id}_${userId}`, null); + if (!loa) { + return interaction.reply({ content: '❌ This LOA request no longer exists or has already been processed.', ephemeral: true }); + } + + if (loa.status !== 'pending') { + return interaction.reply({ content: `❌ This LOA has already been **${loa.status}**.`, ephemeral: true }); + } + + const originalEmbed = interaction.message.embeds[0]; + const updatedEmbed = EmbedBuilder.from(originalEmbed); + + const member = await interaction.guild.members.fetch(userId).catch(() => null); + + if (action === 'approve') { + // Update status + loa.status = 'approved'; + loa.approvedBy = interaction.user.id; + loa.approvedAt = new Date().toISOString(); + await setInDb(`loa_active_${interaction.guild.id}_${userId}`, loa); + + // Give LOA role + if (member) { + await member.roles.add(LOA_ROLE_ID).catch(() => {}); + } + + // Update embed status field + const fields = updatedEmbed.data.fields || []; + const statusField = fields.find(f => f.name === 'Status'); + if (statusField) statusField.value = '🟢 **Approved**'; + + updatedEmbed.setColor(0x2ECC71); + updatedEmbed.addFields({ + name: 'Approved By', + value: `<@${interaction.user.id}> • `, + inline: false, + }); + + // Disable buttons + await interaction.message.edit({ embeds: [updatedEmbed], components: [] }); + + // Notify user via DM + if (member) { + await member.send({ + embeds: [ + new EmbedBuilder() + .setColor(0x2ECC71) + .setTitle('✅ LOA Approved') + .setDescription(`Your LOA request (\`${loaId}\`) in **${interaction.guild.name}** has been **approved**!\n\nEnjoy your time off. When you return, use \`/loa return\` to mark yourself back.`) + .addFields( + { name: 'Start Date', value: loa.startDate, inline: true }, + { name: 'Return Date', value: loa.endDate, inline: true }, + ) + .setTimestamp(), + ], + }).catch(() => {}); + } + + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(0x2ECC71) + .setDescription(`✅ Approved LOA \`${loaId}\` for <@${userId}>. They have been given the LOA role.`) + ], + ephemeral: true, + }); + + } else if (action === 'deny') { + // Update status + loa.status = 'denied'; + loa.deniedBy = interaction.user.id; + loa.deniedAt = new Date().toISOString(); + await setInDb(`loa_active_${interaction.guild.id}_${userId}`, null); + await setInDb(`loa_denied_${interaction.guild.id}_${userId}_${loaId}`, loa); + + // Update embed + const fields = updatedEmbed.data.fields || []; + const statusField = fields.find(f => f.name === 'Status'); + if (statusField) statusField.value = '🔴 **Denied**'; + + updatedEmbed.setColor(0xE74C3C); + updatedEmbed.addFields({ + name: 'Denied By', + value: `<@${interaction.user.id}> • `, + inline: false, + }); + + await interaction.message.edit({ embeds: [updatedEmbed], components: [] }); + + // Notify user via DM + if (member) { + await member.send({ + embeds: [ + new EmbedBuilder() + .setColor(0xE74C3C) + .setTitle('❌ LOA Denied') + .setDescription(`Your LOA request (\`${loaId}\`) in **${interaction.guild.name}** has been **denied**.\n\nIf you have questions, please contact staff.`) + .setTimestamp(), + ], + }).catch(() => {}); + } + + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(0xE74C3C) + .setDescription(`❌ Denied LOA \`${loaId}\` for <@${userId}>.`) + ], + ephemeral: true, + }); + } + + } catch (error) { + logger.error('Error handling LOA button:', error); + await interaction.reply({ content: 'An error occurred while processing this LOA.', ephemeral: true }).catch(() => {}); + } +} + +export default [ + { name: 'loa_approve', execute }, + { name: 'loa_deny', execute }, +]; diff --git a/src/interactions/buttons/punish_processed.js b/src/interactions/buttons/punish_processed.js new file mode 100644 index 000000000..94679cfa1 --- /dev/null +++ b/src/interactions/buttons/punish_processed.js @@ -0,0 +1,50 @@ +import { EmbedBuilder } from 'discord.js'; +import { logger } from '../../utils/logger.js'; + +const BUTTON_LABELS = { + punish_reviewed: '✅ Reviewed Management', +}; + +async function execute(interaction, client) { + try { + const customId = interaction.customId; + const buttonType = Object.keys(BUTTON_LABELS).find(key => customId.startsWith(key)); + if (!buttonType) return; + + const caseCode = customId.slice(buttonType.length + 1); + const label = BUTTON_LABELS[buttonType]; + + const originalEmbed = interaction.message.embeds[0]; + if (!originalEmbed) return; + + const updatedEmbed = EmbedBuilder.from(originalEmbed); + + const alreadySet = (updatedEmbed.data.fields || []).some(f => f.name === label); + if (alreadySet) { + await interaction.reply({ content: 'This status has already been marked.', flags: 64 }); + return; + } + + const timestamp = new Date().toLocaleDateString('en-US', { + month: 'short', day: 'numeric', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }); + + updatedEmbed.addFields({ + name: label, + value: `By <@${interaction.user.id}> • ${timestamp}`, + inline: false, + }); + + await interaction.message.edit({ embeds: [updatedEmbed] }); + await interaction.reply({ + content: `✅ Marked **${label}** for case \`${caseCode}\`.`, + flags: 64, + }); + } catch (error) { + logger.error('Error handling punishment button:', error); + await interaction.reply({ content: 'An error occurred.', flags: 64 }).catch(() => {}); + } +} + +export default { name: 'punish_processed', execute }; diff --git a/src/interactions/buttons/punish_reviewed.js b/src/interactions/buttons/punish_reviewed.js new file mode 100644 index 000000000..603fe496c --- /dev/null +++ b/src/interactions/buttons/punish_reviewed.js @@ -0,0 +1,55 @@ +import { EmbedBuilder } from 'discord.js'; +import { logger } from '../../utils/logger.js'; + +const BUTTON_LABELS = { + punish_reviewed: '✅ Reviewed by Management', +}; + +async function execute(interaction, client) { + try { + const customId = interaction.customId; + const buttonType = Object.keys(BUTTON_LABELS).find(key => customId.startsWith(key)); + if (!buttonType) return; + + const caseCode = customId.slice(buttonType.length + 1); + const label = BUTTON_LABELS[buttonType]; + + const originalEmbed = interaction.message.embeds[0]; + if (!originalEmbed) return; + + const updatedEmbed = EmbedBuilder.from(originalEmbed); + + const alreadySet = (updatedEmbed.data.fields || []).some(f => f.name === label); + if (alreadySet) { + await interaction.reply({ content: 'This status has already been marked.', flags: 64 }); + return; + } + + const timestamp = new Date().toLocaleDateString('en-US', { + month: 'short', day: 'numeric', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }); + + updatedEmbed.addFields({ + name: label, + value: `By <@${interaction.user.id}> • ${timestamp}`, + inline: false, + }); + + await interaction.message.edit({ embeds: [updatedEmbed] }); + await interaction.reply({ + content: `✅ Marked **${label}** for case \`${caseCode}\`.`, + flags: 64, + }); + } catch (error) { + logger.error('Error handling punishment button:', error); + await interaction.reply({ content: 'An error occurred.', flags: 64 }).catch(() => {}); + } +} + +export default [ + { name: 'punish_reviewed', execute }, + { name: 'punish_processed', execute }, + { name: 'punish_roster', execute }, + { name: 'punish_rosterlink', execute }, +]; \ No newline at end of file diff --git a/src/interactions/buttons/punish_roster.js b/src/interactions/buttons/punish_roster.js new file mode 100644 index 000000000..d5ce028ff --- /dev/null +++ b/src/interactions/buttons/punish_roster.js @@ -0,0 +1,50 @@ +import { EmbedBuilder } from 'discord.js'; +import { logger } from '../../utils/logger.js'; + +const BUTTON_LABELS = { + punish_reviewed: '✅ Reviewed by Management', +}; + +async function execute(interaction, client) { + try { + const customId = interaction.customId; + const buttonType = Object.keys(BUTTON_LABELS).find(key => customId.startsWith(key)); + if (!buttonType) return; + + const caseCode = customId.slice(buttonType.length + 1); + const label = BUTTON_LABELS[buttonType]; + + const originalEmbed = interaction.message.embeds[0]; + if (!originalEmbed) return; + + const updatedEmbed = EmbedBuilder.from(originalEmbed); + + const alreadySet = (updatedEmbed.data.fields || []).some(f => f.name === label); + if (alreadySet) { + await interaction.reply({ content: 'This status has already been marked.', flags: 64 }); + return; + } + + const timestamp = new Date().toLocaleDateString('en-US', { + month: 'short', day: 'numeric', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }); + + updatedEmbed.addFields({ + name: label, + value: `By <@${interaction.user.id}> • ${timestamp}`, + inline: false, + }); + + await interaction.message.edit({ embeds: [updatedEmbed] }); + await interaction.reply({ + content: `✅ Marked **${label}** for case \`${caseCode}\`.`, + flags: 64, + }); + } catch (error) { + logger.error('Error handling punishment button:', error); + await interaction.reply({ content: 'An error occurred.', flags: 64 }).catch(() => {}); + } +} + +export default { name: 'punish_roster', execute }; diff --git a/src/interactions/buttons/punish_rosterlink.js b/src/interactions/buttons/punish_rosterlink.js new file mode 100644 index 000000000..f57955c85 --- /dev/null +++ b/src/interactions/buttons/punish_rosterlink.js @@ -0,0 +1,50 @@ +import { EmbedBuilder } from 'discord.js'; +import { logger } from '../../utils/logger.js'; + +const BUTTON_LABELS = { + punish_reviewed: '✅ Reviewed by Management', +}; + +async function execute(interaction, client) { + try { + const customId = interaction.customId; + const buttonType = Object.keys(BUTTON_LABELS).find(key => customId.startsWith(key)); + if (!buttonType) return; + + const caseCode = customId.slice(buttonType.length + 1); + const label = BUTTON_LABELS[buttonType]; + + const originalEmbed = interaction.message.embeds[0]; + if (!originalEmbed) return; + + const updatedEmbed = EmbedBuilder.from(originalEmbed); + + const alreadySet = (updatedEmbed.data.fields || []).some(f => f.name === label); + if (alreadySet) { + await interaction.reply({ content: 'This status has already been marked.', flags: 64 }); + return; + } + + const timestamp = new Date().toLocaleDateString('en-US', { + month: 'short', day: 'numeric', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }); + + updatedEmbed.addFields({ + name: label, + value: `By <@${interaction.user.id}> • ${timestamp}`, + inline: false, + }); + + await interaction.message.edit({ embeds: [updatedEmbed] }); + await interaction.reply({ + content: `✅ Marked **${label}** for case \`${caseCode}\`.`, + flags: 64, + }); + } catch (error) { + logger.error('Error handling punishment button:', error); + await interaction.reply({ content: 'An error occurred.', flags: 64 }).catch(() => {}); + } +} + +export default { name: 'punish_rosterlink', execute }; diff --git a/src/interactions/buttons/ticket.js b/src/interactions/buttons/ticket.js index bcc2521f7..1049944aa 100644 --- a/src/interactions/buttons/ticket.js +++ b/src/interactions/buttons/ticket.js @@ -6,6 +6,8 @@ import createTicketHandler, { unclaimTicketHandler, reopenTicketHandler, deleteTicketHandler, + createPanelTicketHandler, + createPanelTicketModalHandler, } from '../../handlers/ticketButtons.js'; export default [ @@ -17,4 +19,6 @@ export default [ unclaimTicketHandler, reopenTicketHandler, deleteTicketHandler, -]; \ No newline at end of file + createPanelTicketHandler, + createPanelTicketModalHandler, +]; diff --git a/src/services/punishmentScheduler.js b/src/services/punishmentScheduler.js new file mode 100644 index 000000000..89b7c1744 --- /dev/null +++ b/src/services/punishmentScheduler.js @@ -0,0 +1,68 @@ +// src/services/punishmentScheduler.js +// Handles scheduled role removals that persist through bot restarts + +import { logger } from '../utils/logger.js'; +import { getFromDb, setInDb } from '../utils/database.js'; + +const SCHEDULED_KEY = (guildId) => `scheduled_punishments_${guildId}`; + +export async function scheduleRoleRemoval(guildId, userId, roleId, removeAt, caseCode) { + const existing = await getFromDb(SCHEDULED_KEY(guildId), []); + + // Remove any existing schedule for same user+role + const filtered = existing.filter(s => !(s.userId === userId && s.roleId === roleId)); + + filtered.push({ + userId, + roleId, + removeAt, + caseCode, + scheduledAt: new Date().toISOString(), + }); + + await setInDb(SCHEDULED_KEY(guildId), filtered); + logger.info(`Scheduled role removal for user ${userId} in guild ${guildId} at ${removeAt}`); +} + +export async function processScheduledRemovals(client) { + try { + const now = Date.now(); + + for (const [guildId, guild] of client.guilds.cache) { + try { + const scheduled = await getFromDb(SCHEDULED_KEY(guildId), []); + if (!scheduled.length) continue; + + const remaining = []; + let changed = false; + + for (const task of scheduled) { + if (now >= new Date(task.removeAt).getTime()) { + // Time to remove the role + try { + const member = await guild.members.fetch(task.userId).catch(() => null); + if (member) { + await member.roles.remove(task.roleId).catch(() => {}); + logger.info(`Removed scheduled role ${task.roleId} from ${task.userId} in guild ${guildId} (case ${task.caseCode})`); + } + changed = true; + } catch (err) { + logger.error(`Failed to remove scheduled role for ${task.userId}:`, err); + remaining.push(task); // Keep it to retry + } + } else { + remaining.push(task); + } + } + + if (changed) { + await setInDb(SCHEDULED_KEY(guildId), remaining); + } + } catch (err) { + logger.error(`Error processing scheduled removals for guild ${guildId}:`, err); + } + } + } catch (err) { + logger.error('Error in processScheduledRemovals:', err); + } +} diff --git a/src/services/ticket.js b/src/services/ticket.js index 07a9cc8fa..5f52d4879 100644 --- a/src/services/ticket.js +++ b/src/services/ticket.js @@ -61,11 +61,16 @@ export async function getUserTicketCount(guildId, userId) { } } -export async function createTicket(guild, member, categoryId, reason = 'No reason provided', priority = 'none') { +export async function createTicket(guild, member, categoryId, reason = 'No reason provided', options = {}) { try { const config = await getGuildConfig(guild.client, guild.id); const ticketConfig = config.tickets || {}; + // Handle backwards compatibility: 5th param can be a string (priority) or object (options) + const actualOptions = typeof options === 'string' ? { priority: options } : (options || {}); + const priority = actualOptions.priority || 'none'; + const staffRoleIdFromOptions = actualOptions.staffRoleId || null; + const maxTicketsPerUser = config.maxTicketsPerUser ?? 3; const currentTicketCount = await getUserTicketCount(guild.id, member.id); @@ -125,8 +130,8 @@ export async function createTicket(guild, member, categoryId, reason = 'No reaso PermissionFlagsBits.ReadMessageHistory, ], }, - ...(config.ticketStaffRoleId ? [{ - id: config.ticketStaffRoleId, + ...((staffRoleIdFromOptions || config.ticketStaffRoleId) ? [{ + id: staffRoleIdFromOptions || config.ticketStaffRoleId, allow: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, @@ -151,10 +156,13 @@ export async function createTicket(guild, member, categoryId, reason = 'No reaso await saveTicketData(guild.id, channel.id, ticketData); const priorityInfo = PRIORITY_MAP[priority] || PRIORITY_MAP.none; + const embedTitle = actualOptions.embedTitle || `Ticket #${ticketNumber}`; + const embedDescription = actualOptions.embedDescription || + `${member.toString()}, Please wait while a staff member reviews your ticket!\n\n**Reason:** ${reason}\n**Priority:** ${priorityInfo.emoji} ${priorityInfo.label}`; const embed = createEmbed({ - title: `Ticket #${ticketNumber}`, - description: `${member.toString()}, thanks for creating a ticket!\n\n**Reason:** ${reason}\n**Priority:** ${priorityInfo.emoji} ${priorityInfo.label}`, + title: embedTitle, + description: embedDescription, color: priorityInfo.color, fields: [ { name: 'Status', value: '🟢 Open', inline: true }, @@ -180,7 +188,8 @@ export async function createTicket(guild, member, categoryId, reason = 'No reaso ); } - const staffMention = config.ticketStaffRoleId ? ` <@&${config.ticketStaffRoleId}>` : ''; + const effectiveStaffRoleId = staffRoleIdFromOptions || config.ticketStaffRoleId; + const staffMention = effectiveStaffRoleId ? ` <@&${effectiveStaffRoleId}>` : ''; const messageContent = `${member.toString()}${staffMention}`; const ticketMessage = await channel.send({ @@ -392,7 +401,40 @@ components: [] ); await channel.send({ embeds: [closeEmbed], components: [controlRow] }); - + + // Generate and send transcript on close + try { + const guildConfig = await getGuildConfig(channel.client, channel.guild.id); + if (guildConfig.ticketTranscriptChannelId) { + const transcriptChannel = await channel.client.channels.fetch(guildConfig.ticketTranscriptChannelId).catch(() => null); + if (transcriptChannel?.isSendable()) { + const attachment = await generateTranscript(channel); + if (attachment) { + const transcriptEmbed = buildStandardLogEmbed({ + color: 0x3498db, + title: 'Ticket Transcript', + description: [ + formatLogLine('Ticket', `#${ticketData.id}`), + formatLogLine('Channel', `#${channel.name}`), + formatLogLine('Closed by', `<@${closer.id}>`), + formatLogLine('Generated', ``), + ].join('\n'), + footer: closer?.username + ? { text: `Closed by ${closer.username}`, iconURL: closer.displayAvatarURL?.() } + : undefined, + timestamp: true, + }); + await transcriptChannel.send({ embeds: [transcriptEmbed], files: [attachment] }); + logger.info('Transcript sent on ticket close', { channelId: channel.id, ticketNumber: ticketData.id }); + } + } else { + logger.warn('Transcript channel not sendable or not found on close', { channelId: channel.id }); + } + } + } catch (transcriptError) { + logger.error('Failed to send transcript on close:', { channelId: channel.id, error: transcriptError.message }); + } + await logTicketEvent({ client: channel.client, guildId: channel.guild.id, @@ -804,86 +846,6 @@ export async function deleteTicket(channel, deleter) { ticketId: ticketData.id }); - let attachment = null; - try { - attachment = await generateTranscript(channel); - if (attachment) { - logger.info('Transcript generated successfully, attempting to send', { - channelId: channel.id, - ticketNumber: ticketData.id - }); - } else { - logger.warn('Transcript generation returned null', { - channelId: channel.id, - ticketNumber: ticketData.id - }); - } - } catch (transcriptError) { - logger.error('Error during transcript generation', { - channelId: channel.id, - ticketNumber: ticketData.id, - error: transcriptError.message - }); - } - - if (attachment) { - try { - const guildConfig = await getGuildConfig(channel.client, channel.guild.id); - if (!guildConfig.ticketTranscriptChannelId) { - logger.warn('No transcript channel configured, skipping transcript send', { - channelId: channel.id, - ticketNumber: ticketData.id - }); - } else { - const transcriptChannel = await channel.client.channels.fetch(guildConfig.ticketTranscriptChannelId).catch(() => null); - - if (!transcriptChannel) { - logger.error('Could not fetch transcript channel', { - channelId: channel.id, - transcriptChannelId: guildConfig.ticketTranscriptChannelId - }); - } else if (!transcriptChannel.isSendable()) { - logger.error('Transcript channel exists but is not sendable', { - channelId: channel.id, - transcriptChannelId: transcriptChannel.id - }); - } else { - - const transcriptEmbed = buildStandardLogEmbed({ - color: 0x3498db, - title: 'Ticket Transcript', - description: [ - formatLogLine('Ticket', `#${ticketData.id}`), - formatLogLine('Channel', `#${channel.name}`), - formatLogLine('Generated', ``), - ].join('\n'), - footer: deleter?.username - ? { text: `Deleted by ${deleter.username}`, iconURL: deleter.displayAvatarURL?.() } - : undefined, - timestamp: true, - }); - - await transcriptChannel.send({ - embeds: [transcriptEmbed], - files: [attachment] - }); - - logger.info('✅ Transcript sent successfully', { - channelId: channel.id, - ticketNumber: ticketData.id, - transcriptChannelId: transcriptChannel.id - }); - } - } - } catch (sendError) { - logger.error('Failed to send transcript to channel:', { - channelId: channel.id, - ticketNumber: ticketData.id, - error: sendError.message - }); - } - } - try { await channel.delete('Ticket deleted permanently'); logger.info('✅ Channel deleted', { diff --git a/src/web/index.js b/src/web/index.js new file mode 100644 index 000000000..896e7d66b --- /dev/null +++ b/src/web/index.js @@ -0,0 +1,44 @@ +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import express from 'express'; +import session from 'express-session'; +import authRouter from './routes/auth.js'; +import { createApiRouter } from './routes/api.js'; +import { requireAuthPage } from './middleware/auth.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const publicDir = join(__dirname, 'public'); + +export function mountDashboard(app, client) { + app.set('trust proxy', 1); + + app.use(session({ + secret: process.env.SESSION_SECRET || 'titanbot-dashboard-secret-change-me', + resave: false, + saveUninitialized: false, + cookie: { secure: process.env.NODE_ENV === 'production', maxAge: 86400000 }, + })); + + app.use(express.json()); + + app.use('/auth', authRouter); + app.use('/api', createApiRouter(client)); + app.use('/dashboard/assets', express.static(join(publicDir, 'assets'))); + + app.get('/dashboard/login', (req, res) => { + if (req.session?.user) return res.redirect('/dashboard'); + res.sendFile(join(publicDir, 'login.html')); + }); + + app.get('/dashboard', requireAuthPage, (req, res) => { + res.sendFile(join(publicDir, 'dashboard.html')); + }); + + app.get('/dashboard/guild/:id', requireAuthPage, (req, res) => { + res.sendFile(join(publicDir, 'guild.html')); + }); + + app.get('/dashboard/guild/:id/applications', requireAuthPage, (req, res) => { + res.sendFile(join(publicDir, 'applications.html')); + }); +} diff --git a/src/web/middleware/auth.js b/src/web/middleware/auth.js new file mode 100644 index 000000000..cfae422a9 --- /dev/null +++ b/src/web/middleware/auth.js @@ -0,0 +1,13 @@ +export function requireAuth(req, res, next) { + if (!req.session?.user) { + return res.status(401).json({ error: 'Unauthorized' }); + } + next(); +} + +export function requireAuthPage(req, res, next) { + if (!req.session?.user) { + return res.redirect('/dashboard/login'); + } + next(); +} diff --git a/src/web/public/applications.html b/src/web/public/applications.html new file mode 100644 index 000000000..3dad7d5d6 --- /dev/null +++ b/src/web/public/applications.html @@ -0,0 +1,445 @@ + + + + + + Applications — KJ'S CUSTOMS + + + + +
+ + +
+
+ + +

Loading…

+
+ +
+ + +
+ + + +
+ +
+
+
+ Loading applications… +
+
+
+
+
+ + + + +
+ + + + diff --git a/src/web/public/assets/css/style.css b/src/web/public/assets/css/style.css new file mode 100644 index 000000000..65522c210 --- /dev/null +++ b/src/web/public/assets/css/style.css @@ -0,0 +1,615 @@ +:root { + --bg: #0d0d0f; + --surface: #141417; + --surface2: #1c1c21; + --surface3: #242429; + --border: #2a2a30; + --accent: #c0392b; + --accent-hover: #a93226; + --accent-soft: rgba(192,57,43,0.15); + --accent-glow: rgba(192,57,43,0.3); + --success: #2ecc71; + --warning: #f39c12; + --danger: #e74c3c; + --info: #3498db; + --text: #f0f0f2; + --text-muted: #6b6b78; + --text-dim: #9b9ba8; + --sidebar-w: 220px; + --radius: 12px; + --radius-sm: 8px; + --radius-pill: 999px; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; + -webkit-font-smoothing: antialiased; +} + +a { color: var(--accent); text-decoration: none; } + +/* ── Scrollbar ───────────────────────────────── */ +::-webkit-scrollbar { width: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } + +/* ── Login page ─────────────────────────────── */ +.login-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: radial-gradient(ellipse at 30% 20%, rgba(192,57,43,0.12) 0%, transparent 60%), + radial-gradient(ellipse at 70% 80%, rgba(192,57,43,0.06) 0%, transparent 50%), + var(--bg); +} + +.login-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 20px; + padding: 52px 60px; + text-align: center; + max-width: 440px; + width: 100%; + box-shadow: 0 24px 80px rgba(0,0,0,0.5); +} + +.login-card .logo { + width: 68px; height: 68px; + background: linear-gradient(135deg, var(--accent), #8b1a1a); + border-radius: 18px; + margin: 0 auto 24px; + display: flex; align-items: center; justify-content: center; + font-size: 30px; + box-shadow: 0 8px 24px var(--accent-glow); +} + +.login-card h1 { font-size: 26px; font-weight: 800; margin-bottom: 10px; letter-spacing: -0.5px; } +.login-card p { color: var(--text-dim); margin-bottom: 36px; line-height: 1.6; font-size: 15px; } + +/* ── Buttons ─────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 12px 26px; + border-radius: var(--radius-pill); + border: none; + cursor: pointer; + font-size: 14px; + font-weight: 700; + transition: all 0.15s; + text-decoration: none; + letter-spacing: 0.01em; +} + +.btn:hover { transform: translateY(-1px); text-decoration: none; } +.btn:active { transform: translateY(0); } + +.btn-discord { background: #5865f2; color: #fff; box-shadow: 0 4px 16px rgba(88,101,242,0.3); } +.btn-discord:hover { background: #4752c4; } +.btn-primary { + background: linear-gradient(135deg, var(--accent), #8b1a1a); + color: #fff; + box-shadow: 0 4px 16px var(--accent-glow); +} +.btn-primary:hover { background: linear-gradient(135deg, var(--accent-hover), #7a1515); } +.btn-danger { background: var(--danger); color: #fff; } +.btn-ghost { background: var(--surface2); color: var(--text-dim); border: 1px solid var(--border); } +.btn-ghost:hover { background: var(--surface3); color: var(--text); } +.btn-sm { padding: 8px 18px; font-size: 13px; } + +.error-banner { + background: rgba(231,76,60,0.12); + border: 1px solid rgba(231,76,60,0.3); + color: #ff8b8d; + border-radius: var(--radius-sm); + padding: 12px 16px; + margin-bottom: 20px; + font-size: 14px; +} + +/* ── App shell ──────────────────────────────── */ +.app { display: flex; min-height: 100vh; } + +.sidebar { + width: var(--sidebar-w); + background: var(--surface); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + position: fixed; + top: 0; left: 0; bottom: 0; + z-index: 10; +} + +.sidebar-header { + padding: 22px 16px 18px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 11px; +} + +.sidebar-logo { + width: 38px; height: 38px; + background: linear-gradient(135deg, var(--accent), #8b1a1a); + border-radius: 10px; + display: flex; align-items: center; justify-content: center; + font-size: 18px; flex-shrink: 0; + box-shadow: 0 4px 12px var(--accent-glow); +} + +.sidebar-header h2 { font-size: 14px; font-weight: 800; letter-spacing: -0.2px; line-height: 1.2; } +.sidebar-header span { font-size: 11px; color: var(--text-muted); display: block; margin-top: 1px; } + +.sidebar-nav { flex: 1; padding: 14px 10px; overflow-y: auto; } + +.nav-label { + font-size: 10px; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.1em; + padding: 10px 8px 5px; +} + +.nav-item { + display: flex; + align-items: center; + gap: 9px; + padding: 9px 10px; + border-radius: var(--radius-sm); + color: var(--text-dim); + font-size: 13.5px; + font-weight: 500; + cursor: pointer; + transition: all 0.12s; + border: none; + background: none; + width: 100%; + text-align: left; +} + +.nav-item:hover { background: var(--surface2); color: var(--text); } +.nav-item.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; } +.nav-item .icon { font-size: 15px; width: 18px; text-align: center; flex-shrink: 0; } + +.sidebar-footer { + padding: 12px 10px; + border-top: 1px solid var(--border); +} + +.user-chip { + display: flex; + align-items: center; + gap: 9px; + padding: 9px 10px; + border-radius: var(--radius-sm); + background: var(--surface2); +} + +.user-chip img { width: 30px; height: 30px; border-radius: 50%; } +.user-chip .name { font-size: 13px; font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.user-chip .logout { font-size: 13px; color: var(--text-muted); cursor: pointer; background: none; border: none; padding: 2px 4px; } +.user-chip .logout:hover { color: var(--danger); } + +/* ── Main ────────────────────────────────────── */ +.main { margin-left: var(--sidebar-w); flex: 1; min-width: 0; } + +/* ── Topbar ──────────────────────────────────── */ +.topbar { + background: var(--surface); + border-bottom: 1px solid var(--border); + padding: 0 28px; + display: flex; + align-items: center; + gap: 12px; + height: 60px; + position: sticky; + top: 0; + z-index: 5; +} + +.topbar .back { + color: var(--text-muted); + font-size: 18px; + cursor: pointer; + background: none; + border: none; + padding: 4px; + line-height: 1; +} +.topbar .back:hover { color: var(--text); } + +.guild-icon { width: 30px; height: 30px; border-radius: 8px; object-fit: cover; } +.guild-icon-placeholder { + width: 30px; height: 30px; border-radius: 8px; + background: linear-gradient(135deg, var(--accent), #8b1a1a); + display: inline-flex; align-items: center; justify-content: center; + font-size: 13px; font-weight: 800; +} + +.topbar h1 { font-size: 16px; font-weight: 700; } + +/* ── Pill tabs (topbar style) ────────────────── */ +.pill-tabs { + display: flex; + gap: 6px; + padding: 20px 28px 0; + flex-wrap: wrap; +} + +.pill-tab { + padding: 8px 18px; + border-radius: var(--radius-pill); + border: none; + cursor: pointer; + font-size: 13.5px; + font-weight: 600; + background: var(--surface2); + color: var(--text-dim); + transition: all 0.15s; + display: flex; + align-items: center; + gap: 7px; +} + +.pill-tab:hover { background: var(--surface3); color: var(--text); } +.pill-tab.active { + background: var(--accent-soft); + color: var(--accent); + box-shadow: 0 0 0 1px var(--accent-glow); +} + +/* ── Content ─────────────────────────────────── */ +.content { padding: 24px 28px 100px; max-width: 980px; } + +/* ── Page header ─────────────────────────────── */ +.page-header { margin-bottom: 24px; } +.page-header h1 { font-size: 28px; font-weight: 900; margin-bottom: 5px; letter-spacing: -0.5px; } +.page-header p { color: var(--text-dim); font-size: 14px; } + +/* ── Guild selector ──────────────────────────── */ +.guild-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 14px; +} + +.guild-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 22px 16px; + cursor: pointer; + transition: all 0.15s; + text-align: center; + text-decoration: none; + color: var(--text); + display: block; +} + +.guild-card:hover { + border-color: var(--accent); + background: var(--surface2); + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(0,0,0,0.3); + text-decoration: none; + color: var(--text); +} + +.guild-card img { width: 52px; height: 52px; border-radius: 13px; margin-bottom: 12px; } +.guild-card .guild-icon-lg { + width: 52px; height: 52px; border-radius: 13px; + background: linear-gradient(135deg, var(--accent), #8b1a1a); + margin: 0 auto 12px; + display: flex; align-items: center; justify-content: center; + font-size: 20px; font-weight: 800; + box-shadow: 0 4px 12px var(--accent-glow); +} +.guild-card h3 { font-size: 13.5px; font-weight: 700; line-height: 1.3; word-break: break-word; } + +/* ── Stat cards ──────────────────────────────── */ +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 12px; + margin-bottom: 20px; +} + +.stat-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 18px 20px; +} + +.stat-card .stat-label { + font-size: 12px; + color: var(--text-muted); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 6px; +} + +.stat-card .stat-value { + font-size: 28px; + font-weight: 900; + letter-spacing: -1px; + line-height: 1; +} + +.stat-card .stat-sub { + font-size: 12px; + color: var(--text-muted); + margin-top: 4px; +} + +/* ── Cards ───────────────────────────────────── */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 22px 24px; + margin-bottom: 16px; +} + +.card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border); +} + +.card-header h2 { + font-size: 15px; + font-weight: 700; + display: flex; + align-items: center; + gap: 8px; +} + +.card-header p { font-size: 13px; color: var(--text-muted); margin-top: 3px; } + +/* ── Forms ───────────────────────────────────── */ +.form-group { margin-bottom: 16px; } +.form-group:last-child { margin-bottom: 0; } + +label { + display: block; + font-size: 12.5px; + font-weight: 700; + margin-bottom: 7px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.label-hint { + font-weight: 400; + color: var(--text-muted); + font-size: 11px; + margin-left: 5px; + text-transform: none; + letter-spacing: 0; +} + +input[type="text"], +input[type="number"], +select, +textarea { + width: 100%; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + padding: 10px 13px; + font-size: 14px; + font-family: inherit; + transition: border-color 0.15s, box-shadow 0.15s; + outline: none; + appearance: none; + -webkit-appearance: none; +} + +input[type="text"]:focus, +input[type="number"]:focus, +select:focus, +textarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +select { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236b6b78' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + padding-right: 36px; + cursor: pointer; +} + +select option { background: var(--surface2); } +textarea { resize: vertical; min-height: 80px; } + +.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } + +/* ── Toggle ──────────────────────────────────── */ +.toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 0; + border-bottom: 1px solid var(--border); +} +.toggle-row:last-child { border-bottom: none; padding-bottom: 0; } +.toggle-row:first-child { padding-top: 0; } + +.toggle-info h3 { font-size: 14px; font-weight: 600; margin-bottom: 2px; } +.toggle-info p { font-size: 12px; color: var(--text-muted); } + +.toggle { position: relative; width: 42px; height: 23px; flex-shrink: 0; } +.toggle input { opacity: 0; width: 0; height: 0; } +.toggle-slider { + position: absolute; + inset: 0; + background: var(--surface3); + border: 1px solid var(--border); + border-radius: var(--radius-pill); + cursor: pointer; + transition: all 0.2s; +} +.toggle-slider:before { + content: ''; + position: absolute; + width: 17px; height: 17px; + left: 2px; top: 2px; + background: var(--text-muted); + border-radius: 50%; + transition: all 0.2s; +} +.toggle input:checked + .toggle-slider { background: var(--accent); border-color: var(--accent); } +.toggle input:checked + .toggle-slider:before { transform: translateX(19px); background: #fff; } + +/* ── Progress bars ───────────────────────────── */ +.progress-group { margin-bottom: 14px; } +.progress-group:last-child { margin-bottom: 0; } +.progress-label { + display: flex; + justify-content: space-between; + font-size: 12.5px; + color: var(--text-dim); + margin-bottom: 6px; + font-weight: 500; +} +.progress-bar { height: 4px; background: var(--surface3); border-radius: 2px; overflow: hidden; } +.progress-fill { height: 100%; border-radius: 2px; transition: width 0.5s ease; } +.progress-fill.green { background: var(--success); } +.progress-fill.red { background: var(--danger); } +.progress-fill.yellow { background: var(--warning); } +.progress-fill.blue { background: var(--info); } +.progress-fill.accent { background: var(--accent); } + +/* ── Health score ────────────────────────────── */ +.health-score { + font-size: 42px; + font-weight: 900; + letter-spacing: -2px; + line-height: 1; + margin-bottom: 4px; +} +.health-label { font-size: 12px; color: var(--text-muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; } + +/* ── Overview grid ───────────────────────────── */ +.overview-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 16px; +} + +/* ── Save bar ────────────────────────────────── */ +.save-bar { + position: fixed; + bottom: 0; + left: var(--sidebar-w); + right: 0; + background: var(--surface); + border-top: 1px solid var(--border); + padding: 14px 28px; + display: flex; + align-items: center; + justify-content: space-between; + z-index: 20; + backdrop-filter: blur(10px); +} + +.save-status { font-size: 13px; color: var(--text-muted); } +.save-status.success { color: var(--success); } +.save-status.error { color: var(--danger); } + +/* ── Loading ─────────────────────────────────── */ +.spinner { + width: 20px; height: 20px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.6s linear infinite; + display: inline-block; +} +@keyframes spin { to { transform: rotate(360deg); } } + +.loading-overlay { + display: flex; + align-items: center; + justify-content: center; + padding: 80px; + flex-direction: column; + gap: 14px; + color: var(--text-muted); + font-size: 14px; +} + +/* ── Empty state ─────────────────────────────── */ +.empty-state { + text-align: center; + padding: 70px 20px; + color: var(--text-muted); +} +.empty-state .icon { font-size: 44px; margin-bottom: 16px; } +.empty-state h2 { font-size: 20px; color: var(--text); margin-bottom: 8px; font-weight: 800; } +.empty-state p { max-width: 360px; margin: 0 auto 20px; line-height: 1.7; font-size: 14px; } + +/* ── Toast ───────────────────────────────────── */ +.toast-container { + position: fixed; + bottom: 80px; + right: 24px; + display: flex; + flex-direction: column; + gap: 10px; + z-index: 1000; +} + +.toast { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 13px 18px; + font-size: 13.5px; + display: flex; + align-items: center; + gap: 10px; + box-shadow: 0 8px 32px rgba(0,0,0,0.5); + animation: slide-in 0.2s ease; + min-width: 240px; + font-weight: 500; +} +.toast.success { border-left: 3px solid var(--success); } +.toast.error { border-left: 3px solid var(--danger); } +@keyframes slide-in { from { opacity: 0; transform: translateX(16px); } } + +/* ── Tag / badge ─────────────────────────────── */ +.badge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: var(--radius-pill); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; +} +.badge-success { background: rgba(46,204,113,0.15); color: var(--success); } +.badge-danger { background: rgba(231,76,60,0.15); color: var(--danger); } +.badge-warning { background: rgba(243,156,18,0.15); color: var(--warning); } +.badge-muted { background: var(--surface3); color: var(--text-muted); } diff --git a/src/web/public/assets/js/app.js b/src/web/public/assets/js/app.js new file mode 100644 index 000000000..8d88ae8e6 --- /dev/null +++ b/src/web/public/assets/js/app.js @@ -0,0 +1,50 @@ +// Shared utilities for all dashboard pages + +async function api(url, options = {}) { + try { + const res = await fetch(url, { + headers: { 'Content-Type': 'application/json', ...options.headers }, + ...options, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + if (res.status === 401) { location.href = '/dashboard/login'; return null; } + if (!res.ok) throw new Error(await res.text()); + return await res.json(); + } catch (err) { + console.error(url, err); + toast(err.message || 'Request failed', 'error'); + return null; + } +} + +function escHtml(str) { + return String(str ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function toast(message, type = 'success') { + const container = document.getElementById('toasts'); + if (!container) return; + const el = document.createElement('div'); + el.className = `toast ${type}`; + el.innerHTML = `${type === 'success' ? '✅' : '❌'} ${escHtml(message)}`; + container.appendChild(el); + setTimeout(() => el.remove(), 4000); +} + +function buildSelect(options, value, placeholder = 'None / Not set') { + const opts = [``, + ...options.map(o => ``) + ]; + return opts.join(''); +} + +function channelOptions(channels, types) { + return channels.filter(c => types.includes(c.type)); +} + +// Discord channel types +const CH = { TEXT: 0, CATEGORY: 4, ANNOUNCEMENT: 5 }; diff --git a/src/web/public/dashboard.html b/src/web/public/dashboard.html new file mode 100644 index 000000000..b5eb3e6b0 --- /dev/null +++ b/src/web/public/dashboard.html @@ -0,0 +1,97 @@ + + + + + + KJ'S CUSTOMS Dashboard + + + +
+ + +
+
+ + +
+
+
+ Loading your servers… +
+
+
+
+
+ +
+ + + + + diff --git a/src/web/public/guild.html b/src/web/public/guild.html new file mode 100644 index 000000000..b5bd004be --- /dev/null +++ b/src/web/public/guild.html @@ -0,0 +1,558 @@ + + + + + + TitanBot — Server Config + + + +
+ + +
+
+ + +

Loading…

+
+ + + +
+
+
+ Loading server settings… +
+
+ + +
+
+ +
+ + + + diff --git a/src/web/public/login.html b/src/web/public/login.html new file mode 100644 index 000000000..1b9cef89c --- /dev/null +++ b/src/web/public/login.html @@ -0,0 +1,37 @@ + + + + + + SyneBot Dashboard — Login + + + + + + + diff --git a/src/web/routes/api.js b/src/web/routes/api.js new file mode 100644 index 000000000..d0e7df482 --- /dev/null +++ b/src/web/routes/api.js @@ -0,0 +1,242 @@ +import { Router } from 'express'; +import { requireAuth } from '../middleware/auth.js'; +import { getGuildConfig, updateGuildConfig } from '../../services/guildConfig.js'; +import { getApplications, getApplication, getApplicationRoles, getApplicationSettings, getApplicationRoleSettings, updateApplication } from '../../utils/database.js'; +import { resolveApplicationLogChannel } from '../../services/loggingService.js'; +import { EmbedBuilder } from 'discord.js'; + +const MANAGE_GUILD = 0x20; + +const ALLOWED_CONFIG_KEYS = new Set([ + 'prefix', 'modRole', 'adminRole', + 'welcomeChannel', 'welcomeMessage', 'autoRole', + 'dmOnClose', + 'ticketPanelChannelId', 'ticketStaffRoleId', 'ticketCategoryId', + 'ticketClosedCategoryId', 'ticketPanelMessage', 'ticketButtonLabel', + 'maxTicketsPerUser', 'ticketLogsChannelId', 'ticketTranscriptChannelId', + 'logging', + 'leveling', + 'birthdayChannelId', 'birthdayRoleId', + 'verification', +]); + +export function createApiRouter(client) { + const router = Router(); + router.use(requireAuth); + + router.get('/me', (req, res) => { + const { id, username, avatar } = req.session.user; + res.json({ id, username, avatar }); + }); + + router.get('/guilds', (req, res) => { + const userGuilds = req.session.guilds || []; + const managedGuilds = userGuilds.filter(g => (BigInt(g.permissions) & BigInt(MANAGE_GUILD)) === BigInt(MANAGE_GUILD)); + const botGuildIds = new Set(client.guilds.cache.keys()); + + const guilds = managedGuilds + .filter(g => botGuildIds.has(g.id)) + .map(g => ({ + id: g.id, + name: g.name, + icon: g.icon, + })); + + res.json(guilds); + }); + + router.get('/guilds/:id', async (req, res) => { + const { id } = req.params; + if (!canManageGuild(req, id)) return res.status(403).json({ error: 'Forbidden' }); + + const guild = client.guilds.cache.get(id); + if (!guild) return res.status(404).json({ error: 'Bot is not in this guild' }); + + const channels = guild.channels.cache + .filter(c => c.type !== undefined) + .map(c => ({ id: c.id, name: c.name, type: c.type })) + .sort((a, b) => a.name.localeCompare(b.name)); + + const roles = guild.roles.cache + .filter(r => r.id !== guild.id) + .map(r => ({ id: r.id, name: r.name, color: r.hexColor })) + .sort((a, b) => b.rawPosition - a.rawPosition); + + res.json({ id: guild.id, name: guild.name, icon: guild.iconURL(), channels, roles }); + }); + + router.get('/guilds/:id/config', async (req, res) => { + const { id } = req.params; + if (!canManageGuild(req, id)) return res.status(403).json({ error: 'Forbidden' }); + + try { + const config = await getGuildConfig(client, id); + res.json(config); + } catch (err) { + res.status(500).json({ error: 'Failed to load config' }); + } + }); + + router.patch('/guilds/:id/config', async (req, res) => { + const { id } = req.params; + if (!canManageGuild(req, id)) return res.status(403).json({ error: 'Forbidden' }); + + const updates = {}; + for (const [key, value] of Object.entries(req.body)) { + if (ALLOWED_CONFIG_KEYS.has(key)) { + updates[key] = value; + } + } + + try { + const updated = await updateGuildConfig(client, id, updates); + res.json(updated); + } catch (err) { + res.status(500).json({ error: 'Failed to save config' }); + } + }); + + // Applications endpoints + router.get('/guilds/:id/application-roles', async (req, res) => { + const { id } = req.params; + if (!canManageGuild(req, id)) return res.status(403).json({ error: 'Forbidden' }); + try { + const roles = await getApplicationRoles(client, id); + res.json(roles); + } catch (err) { + res.status(500).json({ error: 'Failed to load application roles' }); + } + }); + + router.get('/guilds/:id/applications', async (req, res) => { + const { id } = req.params; + if (!canManageGuild(req, id)) return res.status(403).json({ error: 'Forbidden' }); + try { + const filters = {}; + if (req.query.status) filters.status = req.query.status; + let apps = await getApplications(client, id, filters); + if (req.query.roleId) apps = apps.filter(a => a.roleId === req.query.roleId); + res.json(apps); + } catch (err) { + res.status(500).json({ error: 'Failed to load applications' }); + } + }); + + router.post('/guilds/:id/applications/:appId/review', async (req, res) => { + const { id, appId } = req.params; + if (!canManageGuild(req, id)) return res.status(403).json({ error: 'Forbidden' }); + + const { action, reason } = req.body; + if (!['approve', 'deny'].includes(action)) { + return res.status(400).json({ error: 'action must be approve or deny' }); + } + + try { + const application = await getApplication(client, id, appId); + if (!application) return res.status(404).json({ error: 'Application not found' }); + if (application.status !== 'pending') { + return res.status(409).json({ error: 'Application already reviewed' }); + } + + const status = action === 'approve' ? 'approved' : 'denied'; + const sanitizedReason = (reason || 'No reason provided.').trim().substring(0, 500); + const reviewerId = req.session.user.id; + + await updateApplication(client, id, appId, { + status, + reviewer: reviewerId, + reviewMessage: sanitizedReason, + reviewedAt: new Date().toISOString(), + }); + + const guild = client.guilds.cache.get(id); + const color = action === 'approve' ? 0x2ecc71 : 0xe74c3c; + const statusLabel = action === 'approve' ? 'Accepted' : 'Denied'; + const emoji = action === 'approve' ? '🟢' : '🔴'; + + // DM the applicant + try { + const user = await client.users.fetch(application.userId); + const dmEmbed = new EmbedBuilder() + .setTitle(`${emoji} Application ${statusLabel}`) + .setDescription( + `Your application for **${application.roleName}** has been **${statusLabel.toLowerCase()}**.\n` + + `**Note:** ${sanitizedReason}\n\n` + + `Use \`/apply status id:${appId}\` to view details.` + ) + .setColor(color) + .setTimestamp(); + await user.send({ embeds: [dmEmbed] }); + } catch (_) { + // DM failed (user has DMs closed) — not a fatal error + } + + // Assign role if approved + if (action === 'approve' && application.roleId && guild) { + try { + const member = await guild.members.fetch(application.userId); + await member.roles.add(application.roleId); + } catch (_) {} + } + + // Update existing log message or send a new one to the log channel + try { + const [appSettings, roleSettings, guildConfig] = await Promise.all([ + getApplicationSettings(client, id), + getApplicationRoleSettings(client, id, application.roleId), + getGuildConfig(client, id), + ]); + + const logChannelId = application.logChannelId + || resolveApplicationLogChannel(guildConfig, roleSettings, appSettings); + + if (logChannelId && guild) { + const logChannel = guild.channels.cache.get(logChannelId); + if (logChannel) { + const logEmbed = new EmbedBuilder() + .setTitle(`${emoji} Application ${statusLabel}`) + .setColor(color) + .addFields( + { name: 'Applicant', value: `<@${application.userId}> (${application.username || application.userId})`, inline: true }, + { name: 'Application', value: application.roleName || 'Unknown', inline: true }, + { name: 'Status', value: `${emoji} ${statusLabel}`, inline: true }, + { name: 'Reason', value: sanitizedReason, inline: false }, + { name: 'Reviewed by', value: `<@${reviewerId}>`, inline: true }, + { name: 'Application ID', value: `\`${appId}\``, inline: true }, + ) + .setTimestamp(); + + // If there's an existing log message, edit it; otherwise post a new one + if (application.logMessageId) { + try { + const existing = await logChannel.messages.fetch(application.logMessageId); + await existing.edit({ embeds: [logEmbed], components: [] }); + } catch (_) { + // Message deleted or inaccessible — send a fresh one + await logChannel.send({ embeds: [logEmbed] }); + } + } else { + await logChannel.send({ embeds: [logEmbed] }); + } + } + } + } catch (_) { + // Log channel errors are non-fatal + } + + res.json({ success: true, status }); + } catch (err) { + console.error('Application review error:', err); + res.status(500).json({ error: 'Failed to review application' }); + } + }); + + return router; +} + +function canManageGuild(req, guildId) { + const userGuilds = req.session.guilds || []; + const guild = userGuilds.find(g => g.id === guildId); + if (!guild) return false; + return (BigInt(guild.permissions) & BigInt(MANAGE_GUILD)) === BigInt(MANAGE_GUILD); +} diff --git a/src/web/routes/auth.js b/src/web/routes/auth.js new file mode 100644 index 000000000..f867c53fe --- /dev/null +++ b/src/web/routes/auth.js @@ -0,0 +1,94 @@ +import { Router } from 'express'; + +const router = Router(); + +const DISCORD_API = 'https://discord.com/api/v10'; + +function getOAuthURL(state) { + const params = new URLSearchParams({ + client_id: process.env.CLIENT_ID, + redirect_uri: process.env.DASHBOARD_URL + '/auth/callback', + response_type: 'code', + scope: 'identify guilds', + state, + }); + return `https://discord.com/api/oauth2/authorize?${params}`; +} + +router.get('/debug', (req, res) => { + res.json({ + CLIENT_ID: process.env.CLIENT_ID, + DASHBOARD_URL: process.env.DASHBOARD_URL, + redirect_uri: process.env.DASHBOARD_URL + '/auth/callback', + full_url: getOAuthURL('test'), + }); +}); + +router.get('/login', (req, res) => { + const state = Math.random().toString(36).slice(2); + req.session.oauthState = state; + req.session.save(() => res.redirect(getOAuthURL(state))); +}); + +router.get('/callback', async (req, res) => { + const { code, state } = req.query; + + if (!code || state !== req.session.oauthState) { + return res.redirect('/dashboard/login?error=invalid_state'); + } + + delete req.session.oauthState; + + try { + const tokenRes = await fetch(`${DISCORD_API}/oauth2/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: process.env.CLIENT_ID, + client_secret: process.env.DISCORD_CLIENT_SECRET, + grant_type: 'authorization_code', + code, + redirect_uri: process.env.DASHBOARD_URL + '/auth/callback', + }), + }); + + if (!tokenRes.ok) { + const errBody = await tokenRes.text(); + console.error('Discord token exchange failed:', tokenRes.status, errBody); + throw new Error('Token exchange failed'); + } + const tokens = await tokenRes.json(); + + const [userRes, guildsRes] = await Promise.all([ + fetch(`${DISCORD_API}/users/@me`, { + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }), + fetch(`${DISCORD_API}/users/@me/guilds`, { + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }), + ]); + + const user = await userRes.json(); + const guilds = await guildsRes.json(); + + req.session.user = { + id: user.id, + username: user.username, + avatar: user.avatar, + accessToken: tokens.access_token, + }; + req.session.guilds = guilds; + + res.redirect('/dashboard'); + } catch (err) { + console.error('OAuth error:', err); + res.redirect('/dashboard/login?error=auth_failed'); + } +}); + +router.post('/logout', (req, res) => { + req.session.destroy(); + res.redirect('/dashboard/login'); +}); + +export default router;