From 6cc6916f3ed102e125a510d50a7c503439de6443 Mon Sep 17 00:00:00 2001 From: Mitchell james Date: Mon, 13 Jul 2026 15:48:02 +1000 Subject: [PATCH 1/3] Reorganize project structure, wire bot config, and fix dependency vulnerabilities Co-authored-by: Cursor --- .dockerignore | 16 + .env.example | 25 +- .github/workflows/tests.yml | 29 - CONTRIBUTING.md | 24 +- Dockerfile | 12 +- README.md | 45 +- docker-compose.yml | 32 +- lavalink/nodes.json | 30 + package-lock.json | 40 +- package.json | 9 +- scripts/migrate-keys.js | 48 ++ scripts/migrate.js | 236 ++------ src/app.js | 47 +- src/commands/Birthday/birthday.js | 52 +- .../Birthday/modules/birthday_info.js | 71 +-- .../Birthday/modules/birthday_list.js | 119 ++-- .../Birthday/modules/birthday_remove.js | 55 +- src/commands/Birthday/modules/birthday_set.js | 46 +- .../Birthday/modules/birthday_setchannel.js | 4 +- .../Birthday/modules/next_birthdays.js | 151 +++-- src/commands/Community/app-admin.js | 116 +--- src/commands/Community/apply.js | 12 +- .../Community/modules/app_dashboard.js | 11 +- src/commands/Core/commands.js | 172 +++--- src/commands/Core/configWizard.js | 10 +- src/commands/Core/help.js | 2 +- .../Core/modules/commands_dashboard.js | 2 +- src/commands/Economy/beg.js | 4 +- src/commands/Economy/buy.js | 7 +- src/commands/Economy/crime.js | 12 +- src/commands/Economy/daily.js | 5 +- src/commands/Economy/eleaderboard.js | 3 +- src/commands/Economy/gamble.js | 3 +- .../Economy/modules/economy_dashboard.js | 27 +- src/commands/Economy/modules/shop_browse.js | 10 +- .../Economy/modules/shop_config_setrole.js | 3 +- src/commands/Economy/rob.js | 7 +- src/commands/Economy/work.js | 7 +- src/commands/Fun/count.js | 3 +- src/commands/Fun/fight.js | 107 ++-- src/commands/Fun/flip.js | 26 +- src/commands/Fun/roll.js | 103 ++-- src/commands/Giveaway/gcreate.js | 258 ++++---- src/commands/Giveaway/gdelete.js | 289 +++++---- src/commands/Giveaway/gend.js | 296 +++++---- src/commands/Giveaway/greroll.js | 367 ++++++------ src/commands/JoinToCreate/jointocreate.js | 2 +- src/commands/JoinToCreate/modules/setup.js | 4 +- src/commands/Leveling/leaderboard.js | 110 ++-- src/commands/Leveling/level.js | 142 +++-- src/commands/Leveling/leveladd.js | 94 ++- src/commands/Leveling/levelremove.js | 112 ++-- src/commands/Leveling/levelset.js | 94 ++- .../Leveling/modules/level_dashboard.js | 6 +- src/commands/Leveling/rank.js | 134 ++--- src/commands/Logging/logging.js | 1 + .../Logging/modules/logging_channel.js | 3 +- .../Logging/modules/logging_dashboard.js | 5 +- src/commands/Moderation/ban.js | 88 +-- src/commands/Moderation/cases.js | 3 +- src/commands/Moderation/dm.js | 5 +- src/commands/Moderation/kick.js | 134 ++--- src/commands/Moderation/lock.js | 17 +- src/commands/Moderation/massban.js | 6 +- src/commands/Moderation/masskick.js | 4 +- src/commands/Moderation/purge.js | 16 +- src/commands/Moderation/say.js | 134 +++++ src/commands/Moderation/timeout.js | 121 ++-- src/commands/Moderation/unban.js | 78 ++- src/commands/Moderation/unlock.js | 21 +- src/commands/Moderation/untimeout.js | 73 ++- src/commands/Moderation/usernotes.js | 22 +- src/commands/Moderation/warn.js | 142 ++--- src/commands/Moderation/warnings.js | 129 ++-- src/commands/Music/join.js | 11 +- src/commands/Music/music.js | 157 +++-- src/commands/Music/nowplaying.js | 11 +- src/commands/Music/play.js | 11 +- src/commands/Music/queue.js | 19 +- src/commands/Reaction_roles/reactroles.js | 143 ++--- src/commands/Search/define.js | 6 +- src/commands/Search/google.js | 49 +- src/commands/Search/urban.js | 6 +- .../ServerStats/modules/serverstats_create.js | 7 +- .../ServerStats/modules/serverstats_delete.js | 95 ++- .../ServerStats/modules/serverstats_list.js | 7 +- .../ServerStats/modules/serverstats_update.js | 5 +- src/commands/ServerStats/serverstats.js | 47 +- src/commands/Ticket/claim.js | 88 +-- src/commands/Ticket/close.js | 104 ++-- .../Ticket/modules/ticket_dashboard.js | 27 +- src/commands/Ticket/priority.js | 93 +-- src/commands/Ticket/ticket.js | 77 +-- src/commands/Tools/baseconvert.js | 190 +++--- src/commands/Tools/calculate.js | 514 ++++++++-------- src/commands/Tools/countdown.js | 80 ++- src/commands/Tools/generatepassword.js | 208 ++++--- src/commands/Tools/hexcolor.js | 1 + src/commands/Tools/poll.js | 84 ++- src/commands/Tools/randomuser.js | 306 +++++----- src/commands/Tools/shorten.js | 143 +++-- src/commands/Utility/avatar.js | 41 +- src/commands/Utility/firstmsg.js | 92 ++- src/commands/Utility/modules/report.js | 75 ++- .../Utility/modules/report_setchannel.js | 1 + src/commands/Utility/report.js | 24 +- src/commands/Utility/serverinfo.js | 95 ++- src/commands/Utility/todo.js | 562 +++++++++--------- src/commands/Utility/userinfo.js | 115 ++-- src/commands/Utility/weather.js | 164 +++-- src/commands/Utility/wipedata.js | 59 +- .../Verification/modules/autoVerify.js | 2 +- .../modules/autoVerifyDashboard.js | 4 +- .../modules/verification_dashboard.js | 2 +- src/commands/Verification/verification.js | 49 +- src/commands/Verification/verify.js | 45 +- src/commands/Welcome/autorole.js | 2 +- src/commands/Welcome/goodbye.js | 4 +- src/commands/Welcome/welcome.js | 4 +- src/config/application.js | 30 +- src/config/bot.js | 130 +++- src/config/{ => commands}/commandAliases.js | 0 .../{ => commands}/commandCategories.js | 0 .../{ => commands}/prefixRestrictions.js | 0 src/config/{ => database}/postgres.js | 2 +- src/config/{ => database}/schemaVersion.js | 0 src/config/guild/guildConfigDefaults.js | 15 + src/config/{ => music}/lavalink.js | 37 ++ src/events/guildCreate.js | 2 +- src/events/guildMemberAdd.js | 18 +- src/events/guildMemberRemove.js | 6 +- src/events/interactionCreate.js | 87 ++- src/events/messageCreate.js | 39 +- src/events/messageDelete.js | 2 +- src/events/messageUpdate.js | 2 +- src/events/ready.js | 2 +- src/events/roleCreate.js | 2 +- src/events/roleDelete.js | 2 +- src/handlers/calculateModals.js | 1 + src/handlers/countdownButtons.js | 5 +- src/handlers/counterButtons.js | 22 +- src/handlers/giveawayButtons.js | 2 +- src/handlers/{ => help}/helpButtons.js | 15 +- src/handlers/{ => help}/helpSelectMenus.js | 25 +- src/handlers/{ => loaders}/commandLoader.js | 92 +-- src/handlers/{ => loaders}/events.js | 4 +- src/handlers/{ => loaders}/interactions.js | 45 +- src/handlers/loggingButtons.js | 24 +- src/handlers/musicButtons.js | 11 +- src/handlers/ticketButtons.js | 278 +++------ src/handlers/todoButtons.js | 3 +- src/handlers/verificationButtons.js | 10 +- src/handlers/warningHandlers.js | 23 +- src/handlers/wipedataButtons.js | 20 +- .../buttons/{ => countdown}/countdown.js | 2 +- .../buttons/counter/counterDelete.js | 3 + src/interactions/buttons/counterDelete.js | 3 - .../buttons/{ => giveaway}/giveaway.js | 2 +- src/interactions/buttons/{ => help}/help.js | 2 +- .../buttons/{ => logging}/logging.js | 2 +- src/interactions/buttons/{ => music}/music.js | 4 +- .../buttons/{ => ticket}/ticket.js | 2 +- .../buttons/{ => ticket}/ticketFeedback.js | 10 +- .../buttons/{ => todo}/todoShared.js | 2 +- .../{ => verification}/verification.js | 2 +- .../buttons/{ => warnings}/warnings.js | 2 +- .../buttons/{ => wipedata}/wipedata.js | 2 +- .../modals/{ => calculate}/calculate.js | 2 +- .../modals/{ => config}/config_modal.js | 8 +- .../modals/{ => ticket}/ticket.js | 2 +- .../{ => ticket}/ticketFeedbackComment.js | 10 +- .../modals/{ => todo}/todoShared.js | 2 +- .../modals/{ => warnings}/warningDelete.js | 2 +- .../selectMenus/help/helpCategory.js | 3 + src/interactions/selectMenus/helpCategory.js | 3 - .../selectMenus/{ => logging}/logging.js | 2 +- .../{ => reactionRoles}/reaction_roles.js | 2 +- .../{ => ticket}/ticketFeedback.js | 8 +- src/services/applicationService.js | 3 +- src/services/birthdayService.js | 41 +- src/services/commandAccessService.js | 2 +- src/services/{ => config}/configService.js | 11 +- src/services/config/guildConfig.js | 105 ++++ src/services/economyService.js | 1 - src/services/giveawayService.js | 21 +- src/services/guildConfig.js | 78 --- src/services/joinToCreateService.js | 2 +- .../{ => leveling}/levelRoleSyncService.js | 6 +- src/services/{ => leveling}/leveling.js | 13 +- src/services/leveling/xpSystem.js | 140 +++++ src/services/loggingService.js | 2 +- .../{ => moderation}/moderationService.js | 11 +- src/services/moderation/warningService.js | 124 ++++ src/services/music/musicActions.js | 41 +- src/services/music/musicVoiceState.js | 5 +- src/services/music/playerHandler.js | 7 + src/services/music/playerStore.js | 2 +- src/services/music/riffySetup.js | 4 +- src/services/panelHealthService.js | 18 +- src/services/reactionRoleService.js | 21 +- src/services/serverstatsService.js | 30 +- src/services/ticket.js | 309 +++------- src/services/verificationService.js | 19 +- src/services/warningService.js | 123 ---- src/services/xpSystem.js | 140 ----- src/utils/commandPipeline.js | 21 + src/utils/database.js | 169 ++---- src/utils/database/guildConfigStorage.js | 84 +++ src/utils/database/keyMigration.js | 245 ++++++++ src/utils/database/keyParser.js | 294 +++++++++ src/utils/database/keys.js | 182 ++++++ src/utils/database/schema.js | 227 +++++++ src/utils/database/tickets.js | 4 +- src/utils/economy.js | 188 +++--- src/utils/embeds.js | 16 +- src/utils/errorHandler.js | 161 ++++- src/utils/errorRegistry.js | 18 + src/utils/{ => logging}/logEmbeds.js | 0 src/utils/{ => logging}/loggingUi.js | 2 +- src/utils/memoryStorage.js | 2 +- src/utils/messageAdapter.js | 26 +- src/utils/moderation.js | 1 + src/utils/permissionGuard.js | 7 +- src/utils/postgresDatabase.js | 499 ++++++---------- src/utils/prefixParser.js | 2 +- src/utils/safeMathParser.js | 2 +- src/utils/serviceErrorBoundary.js | 40 +- src/utils/{ => ticket}/ticketLogging.js | 6 +- src/utils/{ => ticket}/ticketPermissions.js | 4 +- tests/commandAccessService.test.js | 81 --- tests/embedUtils.test.js | 73 --- tests/failure-paths/abuseProtection.test.js | 100 ---- tests/failure-paths/database.failure.test.js | 45 -- .../errorHandler.failure.test.js | 169 ------ tests/failure-paths/safeMathParser.test.js | 33 - .../zodValidationCoverage.test.js | 72 --- tests/panelStatus.reaction.test.js | 55 -- tests/panelStatus.test.js | 144 ----- tests/prefixParser.test.js | 103 ---- tests/prefixPermissions.test.js | 196 ------ tests/prefixResponse.test.js | 133 ----- 241 files changed, 6666 insertions(+), 7537 deletions(-) create mode 100644 .dockerignore delete mode 100644 .github/workflows/tests.yml create mode 100644 lavalink/nodes.json create mode 100644 scripts/migrate-keys.js create mode 100644 src/commands/Moderation/say.js rename src/config/{ => commands}/commandAliases.js (100%) rename src/config/{ => commands}/commandCategories.js (100%) rename src/config/{ => commands}/prefixRestrictions.js (100%) rename src/config/{ => database}/postgres.js (98%) rename src/config/{ => database}/schemaVersion.js (100%) create mode 100644 src/config/guild/guildConfigDefaults.js rename src/config/{ => music}/lavalink.js (59%) rename src/handlers/{ => help}/helpButtons.js (93%) rename src/handlers/{ => help}/helpSelectMenus.js (94%) rename src/handlers/{ => loaders}/commandLoader.js (76%) rename src/handlers/{ => loaders}/events.js (93%) rename src/handlers/{ => loaders}/interactions.js (53%) rename src/interactions/buttons/{ => countdown}/countdown.js (69%) create mode 100644 src/interactions/buttons/counter/counterDelete.js delete mode 100644 src/interactions/buttons/counterDelete.js rename src/interactions/buttons/{ => giveaway}/giveaway.js (89%) rename src/interactions/buttons/{ => help}/help.js (88%) rename src/interactions/buttons/{ => logging}/logging.js (85%) rename src/interactions/buttons/{ => music}/music.js (78%) rename src/interactions/buttons/{ => ticket}/ticket.js (89%) rename src/interactions/buttons/{ => ticket}/ticketFeedback.js (95%) rename src/interactions/buttons/{ => todo}/todoShared.js (80%) rename src/interactions/buttons/{ => verification}/verification.js (57%) rename src/interactions/buttons/{ => warnings}/warnings.js (75%) rename src/interactions/buttons/{ => wipedata}/wipedata.js (72%) rename src/interactions/modals/{ => calculate}/calculate.js (70%) rename src/interactions/modals/{ => config}/config_modal.js (92%) rename src/interactions/modals/{ => ticket}/ticket.js (74%) rename src/interactions/modals/{ => ticket}/ticketFeedbackComment.js (92%) rename src/interactions/modals/{ => todo}/todoShared.js (83%) rename src/interactions/modals/{ => warnings}/warningDelete.js (91%) create mode 100644 src/interactions/selectMenus/help/helpCategory.js delete mode 100644 src/interactions/selectMenus/helpCategory.js rename src/interactions/selectMenus/{ => logging}/logging.js (50%) rename src/interactions/selectMenus/{ => reactionRoles}/reaction_roles.js (61%) rename src/interactions/selectMenus/{ => ticket}/ticketFeedback.js (94%) rename src/services/{ => config}/configService.js (98%) create mode 100644 src/services/config/guildConfig.js delete mode 100644 src/services/guildConfig.js rename src/services/{ => leveling}/levelRoleSyncService.js (95%) rename src/services/{ => leveling}/leveling.js (96%) create mode 100644 src/services/leveling/xpSystem.js rename src/services/{ => moderation}/moderationService.js (97%) create mode 100644 src/services/moderation/warningService.js delete mode 100644 src/services/warningService.js delete mode 100644 src/services/xpSystem.js create mode 100644 src/utils/commandPipeline.js create mode 100644 src/utils/database/guildConfigStorage.js create mode 100644 src/utils/database/keyMigration.js create mode 100644 src/utils/database/keyParser.js create mode 100644 src/utils/database/schema.js rename src/utils/{ => logging}/logEmbeds.js (100%) rename src/utils/{ => logging}/loggingUi.js (98%) rename src/utils/{ => ticket}/ticketLogging.js (98%) rename src/utils/{ => ticket}/ticketPermissions.js (89%) delete mode 100644 tests/commandAccessService.test.js delete mode 100644 tests/embedUtils.test.js delete mode 100644 tests/failure-paths/abuseProtection.test.js delete mode 100644 tests/failure-paths/database.failure.test.js delete mode 100644 tests/failure-paths/errorHandler.failure.test.js delete mode 100644 tests/failure-paths/safeMathParser.test.js delete mode 100644 tests/failure-paths/zodValidationCoverage.test.js delete mode 100644 tests/panelStatus.reaction.test.js delete mode 100644 tests/panelStatus.test.js delete mode 100644 tests/prefixParser.test.js delete mode 100644 tests/prefixPermissions.test.js delete mode 100644 tests/prefixResponse.test.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..4795f0b105 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +node_modules +npm-debug.log* +.env +.env.* +!.env.example +.git +.github +.cursor +tests +backups +logs +*.md +!README.md +.DS_Store +coverage +.agent-tools diff --git a/.env.example b/.env.example index 780d991fc1..c2eae4ad28 100644 --- a/.env.example +++ b/.env.example @@ -3,13 +3,10 @@ # Discord Bot Configuration DISCORD_TOKEN=your_discord_bot_token_here CLIENT_ID=your_discord_client_id_here +# Your server ID (used in the setup tutorial; slash commands register globally via CLIENT_ID). GUILD_ID=your_discord_guild_id_here OWNER_IDS=your_discord_id_here (optional) -# Optional — enable slash commands in every server the bot is invited to. -# Leave unset or false for single-server setup (recommended for most users). -MULTI_GUILD=false - # Bot Runtime Configuration NODE_ENV=production LOG_LEVEL=warn @@ -54,16 +51,18 @@ BACKUP_DIR=./backups BACKUP_RETENTION_DAYS=14 POSTGRES_RESTORE_URL= -# Music (Lavalink + Riffy) — requires a Lavalink v4 server -# Local dev: run Lavalink via docker compose (see README), then use localhost below. -# Docker Compose bot service uses LAVALINK_HOST=lavalink instead. -LAVALINK_HOST=localhost -LAVALINK_PORT=2333 -LAVALINK_PASSWORD=youshallnotpass -LAVALINK_SECURE=false -LAVALINK_NAME=Main -# Optional: override nodes as JSON array (for multiple Lavalink nodes) +# Music (Lavalink + Riffy) — requires Lavalink v4 nodes +# Default: loads public v4 SSL nodes from lavalink/nodes.json +# Source: https://lavalink.darrennathanael.com/SSL/Lavalink-SSL/ +# LAVALINK_NODES_FILE=lavalink/nodes.json +# Optional: override nodes as JSON array (takes priority over nodes file) # LAVALINK_NODES=[{"host":"lavalink","port":2333,"password":"youshallnotpass","secure":false,"name":"Main"}] +# Self-hosted single node fallback (used only if nodes file/env array are absent): +# LAVALINK_HOST=localhost +# LAVALINK_PORT=2333 +# LAVALINK_PASSWORD=youshallnotpass +# LAVALINK_SECURE=false +# LAVALINK_NAME=Main LAVALINK_SEARCH_PLATFORM=ytmsearch LAVALINK_REST_VERSION=v4 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index ed3029ba48..0000000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Tests - -on: - pull_request: - push: - branches: - - main - - master - - mitchwork - -jobs: - test: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Run tests - run: npm test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1fbffbd978..0b7e3d974b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,6 @@ Thank you for your interest in contributing to TitanBot! This guide covers local - Bug fixes and reliability improvements - New commands or enhancements to existing features - Documentation updates -- Test coverage for behavior that is easy to regress Before starting large features, open an issue or discuss in the [support server](https://discord.gg/8kJBYhTGW9) so we can align on scope and avoid duplicate work. @@ -15,7 +14,7 @@ Before starting large features, open an issue or discuss in the [support server] ### Prerequisites -- **Node.js 18+** (CI uses Node 20) +- **Node.js 20+** (Docker and CI use Node 20) - **PostgreSQL** (recommended for development; the bot can fall back to in-memory storage if PostgreSQL is unavailable) - A **Discord bot application** with the intents listed in [README.md](README.md#required-bot-intents) @@ -46,8 +45,7 @@ For Docker-based setup, see [README.md](README.md#docker-deployment-recommended) 1. **Fork the repository** and create a branch from `main`. 2. **Make focused changes** — one logical change per pull request when possible. -3. **Run tests** before opening a PR (see below). -4. **Open a pull request** with a clear description of what changed and why. +3. **Open a pull request** with a clear description of what changed and why. Use descriptive branch names, for example: @@ -55,22 +53,6 @@ Use descriptive branch names, for example: - `feat/economy-shop-filter` - `docs/contributing-guide` -## Running Tests - -Tests use Node's built-in test runner: - -```bash -npm test -``` - -Test files live in `tests/` and follow the `*.test.js` naming pattern. When adding or changing behavior, add or update tests for: - -- Permission checks and command access rules -- Parsing, validation, and utility logic -- UI/panel builders and status helpers - -CI runs `npm test` on every pull request and on pushes to `main` and `master`. A separate workflow also validates database migrations against PostgreSQL when migration-related code changes. - ## Database & Migrations TitanBot uses PostgreSQL as its primary store. If PostgreSQL is unreachable at startup, the bot can operate in a **degraded in-memory mode** — but that mode is not suitable for production and should not be the only way you test persistence-related changes. @@ -91,7 +73,7 @@ Test features that read or write guild data with **both** PostgreSQL and the mem - **Match existing style** — ES modules (`import`/`export`), async/await, and the conventions used in neighboring files. - **Handle errors gracefully** — catch failures, log with context, and send user-friendly embed replies where appropriate. -- **Avoid breaking guild isolation** — guild-specific config and data must stay scoped per server, especially when `MULTI_GUILD=true`. +- **Avoid breaking guild isolation** — guild-specific config and data must stay scoped per server (`guild:{guildId}:...` keys, `interaction.guildId`). - **Keep changes minimal** — prefer extending existing utilities and services over duplicating logic. - **Document user-facing behavior** — update README.md when setup steps or configuration change; mention new env vars in `.env.example`. diff --git a/Dockerfile b/Dockerfile index 576813ed8f..03b7bd00f1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,14 @@ FROM node:20-alpine -# Create app directory WORKDIR /usr/src/app -# Install app dependencies -# A wildcard is used to ensure both package.json AND package-lock.json are copied -COPY package*.json ./ +ENV NODE_ENV=production -# Install only production dependencies +COPY package*.json ./ RUN npm ci --omit=dev -# Bundle app source COPY . . -# Expose the health check port from src/app.js EXPOSE 3000 -# Start the bot -CMD [ "npm", "start" ] +CMD ["npm", "start"] diff --git a/README.md b/README.md index ba20e54d99..700ac20739 100644 --- a/README.md +++ b/README.md @@ -109,28 +109,38 @@ TitanBot is fully containerized for easy deployment. ``` 2. **Configure environment variables:** - Create a `.env` file from `.env.example` and fill in your bot details and PostgreSQL credentials. + ```bash + cp .env.example .env + ``` + Set at minimum `DISCORD_TOKEN`, `CLIENT_ID`, and `GUILD_ID`. Docker Compose also reads `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` from `.env` (defaults: `titanbot` / `password` / `titanbot`). -3. **Start the containers:** +3. **Build and start the containers:** ```bash - docker-compose up -d + docker compose up -d --build ``` -This will start the bot, PostgreSQL, and Lavalink (when music is enabled). +4. **Check status:** + ```bash + docker compose ps + curl http://localhost:3000/health + ``` + +This starts the bot and PostgreSQL. The compose file sets `POSTGRES_SSL=false` and `AUTO_MIGRATE=true` for the bundled database. Music uses public Lavalink v4 nodes from `lavalink/nodes.json` by default. ### Music Music uses [Lavalink v4](https://github.com/lavalink-devs/Lavalink) via [Riffy](https://github.com/riffy-rb/riffy), similar to [Musicify](https://github.com/codebymitch/Musicify). -1. Set in `.env`: +1. By default, the bot loads multiple public v4 SSL nodes from [`lavalink/nodes.json`](lavalink/nodes.json) (sourced from [lavalink.darrennathanael.com](https://lavalink.darrennathanael.com/SSL/Lavalink-SSL/)). Edit that file to add or remove nodes. +2. To self-host Lavalink instead, run `docker compose --profile local-lavalink up -d` and set single-node env vars in `.env`: ```env LAVALINK_HOST=lavalink LAVALINK_PORT=2333 LAVALINK_PASSWORD=youshallnotpass LAVALINK_SECURE=false ``` -2. With Docker Compose, Lavalink is included automatically when you `docker compose up`. -3. On Railway, deploy Lavalink separately or as another service and point `LAVALINK_HOST` at the private hostname. + Remove or rename `lavalink/nodes.json` so the bot falls back to those env vars. +3. Override nodes inline with `LAVALINK_NODES` (JSON array) or point at another file with `LAVALINK_NODES_FILE`. 4. Use `/play ` from a voice channel, or `/join` to connect without playing. Prefix shortcuts: `join`, `np`, `leave`, `pause`, `resume`, `skip`, `stop`, `volume <0-100>`, or `music `. Use `/nowplaying` and `/queue` for status; `/music` for loop, shuffle, seek, and other controls. ### Using GitHub Container Registry @@ -145,7 +155,7 @@ docker pull ghcr.io/codebymitch/titanbot:main ## Manual Installation Steps ### Prerequisites -- Node.js 18.0.0 or higher +- Node.js 20.10.0 or higher - PostgreSQL server (recommended) or memory storage fallback - Discord bot application with proper intents @@ -202,18 +212,13 @@ docker pull ghcr.io/codebymitch/titanbot:main This gives clear startup/online status messages while keeping logs simple for non-technical operators. If port `3000` is busy, the bot tries the next available ports automatically (up to `PORT_RETRY_ATTEMPTS`). -### Running in multiple servers (optional) - -Most users run TitanBot on a **single server** with `GUILD_ID` set (default tutorial setup). If you want commands to work in **every server** the bot is invited to, opt in with: +### Multiple servers -```env -MULTI_GUILD=true -``` +Slash commands are registered **globally** on startup (via `CLIENT_ID`), so the bot works in every server it is invited to. `GUILD_ID` stays in the tutorial `.env` for setup steps but is not used for command registration. -Notes for multi-server mode: -- `GUILD_ID` is not used for command registration when `MULTI_GUILD=true` (you can leave it set or remove it) +Notes: - Global slash commands may take up to about an hour to propagate on first deploy -- Each server still has **isolated** config, economy, tickets, leveling, and other data +- Each server has **isolated** data: config, economy, tickets, leveling, dashboards, warnings, etc. (all keys are scoped as `guild:{guildId}:...`) - In the [Discord Developer Portal](https://discord.com/developers/applications), ensure your bot is not restricted to a single guild if you plan to invite it elsewhere - Generate an OAuth2 invite URL from the [Discord Developer Portal](https://discord.com/developers/applications) (OAuth2 → URL Generator, scopes: `bot` and `applications.commands`) @@ -235,6 +240,12 @@ Notes for multi-server mode: ```bash npm start ``` + +> **Note on database migrations:** Schema tables and legacy key migrations run +> **automatically on startup**, so` managed hosts like **Railway** need no manual +> migration step — just deploy/restart. To disable auto-migration set +> `AUTO_MIGRATE=false`. You can still run a manual key migration locally with +> `node scripts/migrate-keys.js --dry-run` (preview) or `node scripts/migrate-keys.js`. ## Required Bot Intents diff --git a/docker-compose.yml b/docker-compose.yml index 63ab2d0398..9de8fec548 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,27 +3,43 @@ services: build: . container_name: titanbot restart: unless-stopped + env_file: + - .env environment: - NODE_ENV=production - DISCORD_TOKEN=${DISCORD_TOKEN} - CLIENT_ID=${CLIENT_ID} - GUILD_ID=${GUILD_ID} - - MULTI_GUILD=${MULTI_GUILD:-false} - - DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} - - LAVALINK_HOST=lavalink - - LAVALINK_PORT=2333 - - LAVALINK_PASSWORD=${LAVALINK_PASSWORD:-youshallnotpass} - - LAVALINK_SECURE=false + - DATABASE_URL=postgres://${POSTGRES_USER:-titanbot}:${POSTGRES_PASSWORD:-password}@db:5432/${POSTGRES_DB:-titanbot} + - POSTGRES_URL=postgres://${POSTGRES_USER:-titanbot}:${POSTGRES_PASSWORD:-password}@db:5432/${POSTGRES_DB:-titanbot} + - POSTGRES_HOST=db + - POSTGRES_PORT=5432 + - POSTGRES_DB=${POSTGRES_DB:-titanbot} + - POSTGRES_USER=${POSTGRES_USER:-titanbot} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-password} + - POSTGRES_SSL=false + - AUTO_MIGRATE=true + - LOG_LEVEL=${LOG_LEVEL:-warn} + - LAVALINK_NODES_FILE=lavalink/nodes.json - PORT=3000 + - WEB_HOST=0.0.0.0 + ports: + - "${PORT:-3000}:3000" depends_on: db: condition: service_healthy - lavalink: - condition: service_started + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 40s networks: - titan-network lavalink: + profiles: + - local-lavalink image: ghcr.io/lavalink-devs/lavalink:4 container_name: titanbot-lavalink restart: unless-stopped diff --git a/lavalink/nodes.json b/lavalink/nodes.json new file mode 100644 index 0000000000..78e107cec4 --- /dev/null +++ b/lavalink/nodes.json @@ -0,0 +1,30 @@ +[ + { + "host": "lavalinkv4.serenetia.com", + "port": 443, + "password": "https://seretia.link/discord", + "secure": true, + "name": "Serenetia" + }, + { + "host": "lavalink.jirayu.net", + "port": 443, + "password": "youshallnotpass", + "secure": true, + "name": "Jirayu" + }, + { + "host": "lava-v4.millohost.my.id", + "port": 443, + "password": "https://discord.gg/mjS5J2K3ep", + "secure": true, + "name": "MilloHost" + }, + { + "host": "lavalink-v4.triniumhost.com", + "port": 443, + "password": "free", + "secure": true, + "name": "TriniumHost" + } +] diff --git a/package-lock.json b/package-lock.json index 2903a8a07d..819d26b9c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "titanbot", "version": "2.1.0", + "engines": { + "node": ">=20.10.0" + }, "dependencies": { "@discordjs/rest": "^2.6.1", "axios": "^1.15.2", @@ -20,8 +23,9 @@ "winston-daily-rotate-file": "^5.0.0", "zod": "^3.25.76" }, - "engines": { - "node": ">=18.0.0" + "overrides": { + "form-data": "^4.0.6", + "undici": "^6.27.0" } }, "node_modules/@asamuzakjp/css-color": { @@ -217,7 +221,7 @@ "discord-api-types": "^0.38.40", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "6.24.1" + "undici": "6.27.0" }, "engines": { "node": ">=18" @@ -605,7 +609,7 @@ "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "6.24.1" + "undici": "6.27.0" }, "engines": { "node": ">=18" @@ -843,9 +847,9 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -1868,9 +1872,9 @@ } }, "node_modules/undici": { - "version": "6.24.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", - "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", "engines": { "node": ">=18.17" } @@ -2168,7 +2172,7 @@ "discord-api-types": "^0.38.40", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "6.24.1" + "undici": "6.27.0" } }, "@discordjs/util": { @@ -2447,7 +2451,7 @@ "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "6.24.1" + "undici": "6.27.0" }, "dependencies": { "@discordjs/collection": { @@ -2613,9 +2617,9 @@ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==" }, "form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -3334,9 +3338,9 @@ } }, "undici": { - "version": "6.24.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", - "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==" + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==" }, "undici-types": { "version": "7.24.6", diff --git a/package.json b/package.json index fe61ec9332..2430aec17b 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,11 @@ "type": "module", "scripts": { "start": "node src/app.js", - "test": "node --test tests/**/*.test.js", "migrate": "node scripts/migrate.js apply", "migrate:check": "node scripts/migrate.js check", "migrate:status": "node scripts/migrate.js status", + "migrate:keys": "node scripts/migrate-keys.js", + "migrate:keys:dry": "node scripts/migrate-keys.js --dry-run", "backup:db": "node scripts/backup.js", "restore:db": "node scripts/restore.js", "backup:drill": "node scripts/restore-drill.js" @@ -28,6 +29,10 @@ "riffy": "1.0.12" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.10.0" + }, + "overrides": { + "form-data": "^4.0.6", + "undici": "^6.27.0" } } diff --git a/scripts/migrate-keys.js b/scripts/migrate-keys.js new file mode 100644 index 0000000000..e82fb31ec4 --- /dev/null +++ b/scripts/migrate-keys.js @@ -0,0 +1,48 @@ +#!/usr/bin/env node +/** + * Manually migrate legacy database keys to canonical form. + * + * NOTE: This also runs automatically on bot startup (see runStartupKeyMigration + * in src/utils/postgresDatabase.js), which is the recommended path for hosts + * like Railway where one-off scripts are inconvenient. Use this script only for + * a dry-run preview or to force a re-run. + * + * Usage: + * node scripts/migrate-keys.js [--dry-run] [--force] + */ + +import pg from 'pg'; +import dotenv from 'dotenv'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { resolveSslConfig } from '../src/config/database/postgres.js'; +import { runKeyMigration } from '../src/utils/database/keyMigration.js'; +import { logger } from '../src/utils/logger.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: path.join(__dirname, '..', '.env') }); + +const { Pool } = pg; +const dryRun = process.argv.includes('--dry-run'); +const force = process.argv.includes('--force'); + +async function run() { + const pool = new Pool({ + connectionString: process.env.POSTGRES_URL || process.env.DATABASE_URL, + ssl: resolveSslConfig(), + }); + + try { + const summary = await runKeyMigration({ pool, dryRun, force, logger }); + if (summary?.alreadyDone) { + logger.info('Key migration already applied. Use --force to re-run.'); + } + } finally { + await pool.end(); + } +} + +run().catch((error) => { + logger.error('Key migration failed:', error); + process.exit(1); +}); diff --git a/scripts/migrate.js b/scripts/migrate.js index adc0eebe51..27dd907d93 100644 --- a/scripts/migrate.js +++ b/scripts/migrate.js @@ -3,16 +3,28 @@ import dotenv from 'dotenv'; import path from 'path'; import { fileURLToPath } from 'url'; import { logger } from '../src/utils/logger.js'; -import { EXPECTED_SCHEMA_LABEL, EXPECTED_SCHEMA_VERSION } from '../src/config/schemaVersion.js'; +import { EXPECTED_SCHEMA_LABEL, EXPECTED_SCHEMA_VERSION } from '../src/config/database/schemaVersion.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); dotenv.config({ path: path.join(__dirname, '..', '.env') }); +// Imported after dotenv.config so resolveSslConfig sees the loaded env vars. +const { resolveSslConfig } = await import('../src/config/database/postgres.js'); +// The schema is the single source of truth shared with the runtime auto-create +// path (src/utils/postgresDatabase.js), so this script can never diverge from it. +const { + tableStatements, + indexStatements, + UPDATE_TIMESTAMP_FUNCTION, + triggerDefinitions, +} = await import('../src/utils/database/schema.js'); +const { assertAllowlistedIdentifier, quoteIdentifier } = await import('../src/utils/sqlIdentifiers.js'); + const { Pool } = pg; const pool = new Pool({ connectionString: process.env.POSTGRES_URL, - ssl: false, + ssl: resolveSslConfig(), }); const migrationTable = process.env.POSTGRES_MIGRATION_TABLE || 'schema_migrations'; @@ -59,136 +71,9 @@ const getCurrentSchemaVersion = async (client) => { const createTables = async (client) => { logger.info('📊 Creating database tables...'); - const tables = [ - - `CREATE TABLE IF NOT EXISTS guild_configs ( - guild_id VARCHAR(255) PRIMARY KEY, - prefix VARCHAR(10) DEFAULT '!', - welcome_channel VARCHAR(255), - welcome_message TEXT, - welcome_enabled BOOLEAN DEFAULT false, - autorole_ids TEXT[] DEFAULT '{}', - modlog_channel VARCHAR(255), - settings JSONB DEFAULT '{}', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - )`, - - `CREATE TABLE IF NOT EXISTS user_levels ( - id SERIAL PRIMARY KEY, - user_id VARCHAR(255) NOT NULL, - guild_id VARCHAR(255) NOT NULL, - xp BIGINT DEFAULT 0, - level INT DEFAULT 1, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(user_id, guild_id) - )`, - - `CREATE TABLE IF NOT EXISTS user_economy ( - id SERIAL PRIMARY KEY, - user_id VARCHAR(255) NOT NULL, - guild_id VARCHAR(255) NOT NULL, - balance BIGINT DEFAULT 0, - bank BIGINT DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(user_id, guild_id) - )`, - - `CREATE TABLE IF NOT EXISTS birthdays ( - id SERIAL PRIMARY KEY, - user_id VARCHAR(255) NOT NULL, - guild_id VARCHAR(255) NOT NULL, - month INT NOT NULL, - day INT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(user_id, guild_id) - )`, - - `CREATE TABLE IF NOT EXISTS tickets ( - id SERIAL PRIMARY KEY, - ticket_id VARCHAR(255) UNIQUE NOT NULL, - guild_id VARCHAR(255) NOT NULL, - user_id VARCHAR(255) NOT NULL, - channel_id VARCHAR(255) NOT NULL, - status VARCHAR(50) DEFAULT 'open', - subject TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - closed_at TIMESTAMP - )`, - - `CREATE TABLE IF NOT EXISTS giveaways ( - id SERIAL PRIMARY KEY, - giveaway_id VARCHAR(255) UNIQUE NOT NULL, - guild_id VARCHAR(255) NOT NULL, - channel_id VARCHAR(255) NOT NULL, - message_id VARCHAR(255) NOT NULL, - prize TEXT NOT NULL, - winners_count INT DEFAULT 1, - ended BOOLEAN DEFAULT false, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - ends_at TIMESTAMP NOT NULL - )`, - - `CREATE TABLE IF NOT EXISTS giveaway_entries ( - id SERIAL PRIMARY KEY, - giveaway_id INTEGER NOT NULL, - user_id VARCHAR(255) NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(giveaway_id, user_id), - FOREIGN KEY(giveaway_id) REFERENCES giveaways(id) ON DELETE CASCADE - )`, - - `CREATE TABLE IF NOT EXISTS reaction_roles ( - id SERIAL PRIMARY KEY, - guild_id VARCHAR(255) NOT NULL, - channel_id VARCHAR(255) NOT NULL, - message_id VARCHAR(255) NOT NULL, - emoji VARCHAR(255) NOT NULL, - role_id VARCHAR(255) NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(message_id, emoji) - )`, - - `CREATE TABLE IF NOT EXISTS welcome_system ( - id SERIAL PRIMARY KEY, - guild_id VARCHAR(255) UNIQUE NOT NULL, - channel_id VARCHAR(255), - message TEXT, - enabled BOOLEAN DEFAULT false, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - )`, - - `CREATE TABLE IF NOT EXISTS counters ( - id SERIAL PRIMARY KEY, - guild_id VARCHAR(255) UNIQUE NOT NULL, - user_count_channel VARCHAR(255), - bot_count_channel VARCHAR(255), - online_count_channel VARCHAR(255), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - )`, - - `CREATE TABLE IF NOT EXISTS audit_logs ( - id SERIAL PRIMARY KEY, - guild_id VARCHAR(255) NOT NULL, - user_id VARCHAR(255), - action VARCHAR(255) NOT NULL, - target_id VARCHAR(255), - reason TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - )` - ]; - - for (const table of tables) { + for (const statement of tableStatements) { try { - await client.query(table); + await client.query(statement); } catch (error) { logger.error(`❌ Error creating table: ${error.message}`); throw error; @@ -201,17 +86,9 @@ const createTables = async (client) => { const createIndexes = async (client) => { logger.info('📈 Creating indexes...'); - const indexes = [ - 'CREATE INDEX IF NOT EXISTS idx_user_levels_guild ON user_levels(guild_id)', - 'CREATE INDEX IF NOT EXISTS idx_user_economy_guild ON user_economy(guild_id)', - 'CREATE INDEX IF NOT EXISTS idx_tickets_guild ON tickets(guild_id)', - 'CREATE INDEX IF NOT EXISTS idx_giveaways_guild ON giveaways(guild_id)', - 'CREATE INDEX IF NOT EXISTS idx_audit_logs_guild ON audit_logs(guild_id)', - ]; - - for (const index of indexes) { + for (const statement of indexStatements) { try { - await client.query(index); + await client.query(statement); } catch (error) { logger.error(`❌ Error creating index: ${error.message}`); throw error; @@ -224,65 +101,26 @@ const createIndexes = async (client) => { const createTriggers = async (client) => { logger.info('⏰ Setting up automatic timestamps...'); - const triggers = [ - { - table: 'guild_configs', - name: 'update_guild_configs_timestamp' - }, - { - table: 'user_levels', - name: 'update_user_levels_timestamp' - }, - { - table: 'user_economy', - name: 'update_user_economy_timestamp' - }, - { - table: 'birthdays', - name: 'update_birthdays_timestamp' - }, - { - table: 'tickets', - name: 'update_tickets_timestamp' - }, - { - table: 'giveaways', - name: 'update_giveaways_timestamp' - }, - { - table: 'reaction_roles', - name: 'update_reaction_roles_timestamp' - }, - { - table: 'welcome_system', - name: 'update_welcome_system_timestamp' - }, - { - table: 'counters', - name: 'update_counters_timestamp' - } - ]; + await client.query(UPDATE_TIMESTAMP_FUNCTION); - for (const { table, name } of triggers) { + const allowedTriggerIdentifiers = new Set(triggerDefinitions.map((trigger) => trigger.name)); + const allowedTableIdentifiers = new Set(triggerDefinitions.map((trigger) => trigger.table)); + + for (const { name, table } of triggerDefinitions) { try { - - await client.query(` - CREATE OR REPLACE FUNCTION update_timestamp_${table}() - RETURNS TRIGGER AS $$ - BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; - END; - $$ LANGUAGE plpgsql; - `); - - await client.query(` - DROP TRIGGER IF EXISTS ${name} ON ${table}; - CREATE TRIGGER ${name} - BEFORE UPDATE ON ${table} - FOR EACH ROW - EXECUTE FUNCTION update_timestamp_${table}(); - `); + const safeTrigger = quoteIdentifier( + assertAllowlistedIdentifier(name, allowedTriggerIdentifiers, 'Trigger identifier') + ); + const safeTable = quoteIdentifier( + assertAllowlistedIdentifier(table, allowedTableIdentifiers, 'Trigger table identifier') + ); + + await client.query(`DROP TRIGGER IF EXISTS ${safeTrigger} ON ${safeTable};`); + await client.query( + `CREATE TRIGGER ${safeTrigger} + BEFORE UPDATE ON ${safeTable} + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();` + ); } catch (error) { logger.error(`❌ Error creating trigger for ${table}: ${error.message}`); throw error; @@ -380,4 +218,4 @@ if (command === 'apply') { } else { logger.error(`Unknown command: ${command}. Use one of: apply, check, status`); process.exit(1); -} \ No newline at end of file +} diff --git a/src/app.js b/src/app.js index 758230f540..693cd228ea 100644 --- a/src/app.js +++ b/src/app.js @@ -6,16 +6,17 @@ import cron from 'node-cron'; import config from './config/application.js'; import { initializeDatabase } from './utils/database.js'; -import { getGuildConfig } from './services/guildConfig.js'; +import { getGuildConfig } from './services/config/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 { checkGiveaways } from './services/giveawayService.js'; -import { loadCommands, registerCommands as registerSlashCommands } from './handlers/commandLoader.js'; +import { loadCommands, registerCommands as registerSlashCommands } from './handlers/loaders/commandLoader.js'; +import { runSafeTask, handleTaskError, ErrorCodes } from './utils/errorHandler.js'; import { initializeMusic } from './services/music/riffySetup.js'; import { shutdownMusic } from './services/music/playerHandler.js'; import pkg from '../package.json' with { type: 'json' }; -import { EXPECTED_SCHEMA_VERSION, EXPECTED_SCHEMA_LABEL } from './config/schemaVersion.js'; +import { EXPECTED_SCHEMA_VERSION, EXPECTED_SCHEMA_LABEL } from './config/database/schemaVersion.js'; class TitanBot extends Client { constructor() { @@ -89,13 +90,8 @@ class TitanBot extends Client { await this.login(this.config.bot.token); startupLog('Discord login successful'); - startupLog('Registering slash commands...'); + startupLog('Registering slash commands globally...'); await this.registerCommands(); - if (this.config.bot.multiGuild) { - startupLog('Multi-guild mode enabled — slash commands registered globally'); - } else if (this.config.bot.guildId) { - startupLog(`Single-guild mode — slash commands registered for guild ${this.config.bot.guildId}`); - } startupLog('Slash commands registration complete'); const databaseMode = dbStatus.isDegraded @@ -137,7 +133,7 @@ class TitanBot extends Client { }); const requestCounts = new Map(); - const windowMs = 60000; + const windowMs = this.config.api?.rateLimit?.windowMs || 60000; const maxRequests = this.config.api?.rateLimit?.max || 100; app.use((req, res, next) => { @@ -252,9 +248,9 @@ class TitanBot extends Client { } setupCronJobs() { - cron.schedule('0 6 * * *', () => checkBirthdays(this)); - cron.schedule('* * * * *', () => checkGiveaways(this)); - cron.schedule('*/15 * * * *', () => this.updateAllCounters()); + cron.schedule('0 6 * * *', runSafeTask('birthday_check', () => checkBirthdays(this))); + cron.schedule('* * * * *', runSafeTask('giveaway_check', () => checkGiveaways(this))); + cron.schedule('*/15 * * * *', runSafeTask('counter_update', () => this.updateAllCounters())); } async updateAllCounters() { @@ -304,7 +300,7 @@ class TitanBot extends Client { for (const handler of handlers) { try { startupLog(`Loading handler: ${handler.path}`); - const module = await import(`./handlers/${handler.path}.js`); + const module = await import(`./handlers/loaders/${handler.path}.js`); const loaderFn = handler.type.startsWith('named:') ? module[handler.type.split(':')[1]] : module.default; @@ -328,8 +324,7 @@ class TitanBot extends Client { async registerCommands() { try { - const { clientId, guildId, multiGuild } = this.config.bot; - await registerSlashCommands(this, { clientId, guildId, multiGuild }); + await registerSlashCommands(this, { clientId: this.config.bot.clientId }); } catch (error) { logger.error('Error registering commands:', error); } @@ -351,6 +346,12 @@ class TitanBot extends Client { await shutdownMusic(this); logger.info('✅ Music players stopped'); + if (this.webServer) { + logger.info('Closing web server...'); + await new Promise((resolve) => this.webServer.close(resolve)); + logger.info('✅ Web server closed'); + } + // Close database connection // Close database connection if (this.db && this.db.db) { @@ -394,11 +395,12 @@ try { process.on('SIGINT', () => bot.shutdown('SIGINT')); process.on('uncaughtException', (error) => { - logger.error('Uncaught Exception:', error); + // Process state may be corrupt after an uncaught throw; log and shut down cleanly. + handleTaskError('uncaught_exception', error, { fatal: true }); bot.shutdown('UNCAUGHT_EXCEPTION'); }); - - process.on('unhandledRejection', (reason, promise) => { + + process.on('unhandledRejection', (reason) => { const code = reason?.code; if (code === 10062 || code === 40060 || code === 50027) { logger.warn('Recoverable Discord interaction rejection:', reason?.message || reason); @@ -408,8 +410,11 @@ try { return; } - logger.error('Unhandled Rejection at:', promise, 'reason:', reason); - bot.shutdown('UNHANDLED_REJECTION'); + // A stray rejection is a bug to fix, not a reason to take the bot down. + // Log loudly with full context; the central task handler categorizes it. + handleTaskError('unhandled_rejection', reason instanceof Error ? reason : new Error(String(reason)), { + errorCode: ErrorCodes.UNHANDLED_REJECTION, + }); }); }; diff --git a/src/commands/Birthday/birthday.js b/src/commands/Birthday/birthday.js index 6454c64831..c1f8672d00 100644 --- a/src/commands/Birthday/birthday.js +++ b/src/commands/Birthday/birthday.js @@ -1,7 +1,6 @@ import { SlashCommandBuilder, MessageFlags, ChannelType } from 'discord.js'; import { createEmbed, successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; +import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import birthdaySet from './modules/birthday_set.js'; import birthdayInfo from './modules/birthday_info.js'; @@ -76,38 +75,23 @@ export default { ), async execute(interaction, config, client) { - try { - const subcommand = interaction.options.getSubcommand(); - - switch (subcommand) { - case 'set': - return await birthdaySet.execute(interaction, config, client); - case 'info': - return await birthdayInfo.execute(interaction, config, client); - case 'list': - return await birthdayList.execute(interaction, config, client); - case 'remove': - return await birthdayRemove.execute(interaction, config, client); - case 'next': - return await nextBirthdays.execute(interaction, config, client); - case 'setchannel': - return await birthdaySetchannel.execute(interaction, config, client); - default: - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Unknown subcommand' }); - } - } catch (error) { - logger.error('Birthday command execution failed', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'birthday', - subcommand: interaction.options.getSubcommand() - }); - await handleInteractionError(interaction, error, { - commandName: 'birthday', - source: 'birthday_command' - }); + const subcommand = interaction.options.getSubcommand(); + + switch (subcommand) { + case 'set': + return await birthdaySet.execute(interaction, config, client); + case 'info': + return await birthdayInfo.execute(interaction, config, client); + case 'list': + return await birthdayList.execute(interaction, config, client); + case 'remove': + return await birthdayRemove.execute(interaction, config, client); + case 'next': + return await nextBirthdays.execute(interaction, config, client); + case 'setchannel': + return await birthdaySetchannel.execute(interaction, config, client); + default: + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Unknown subcommand' }); } } }; \ No newline at end of file diff --git a/src/commands/Birthday/modules/birthday_info.js b/src/commands/Birthday/modules/birthday_info.js index 2e9c18ab8e..07b8c20020 100644 --- a/src/commands/Birthday/modules/birthday_info.js +++ b/src/commands/Birthday/modules/birthday_info.js @@ -1,59 +1,44 @@ import { EmbedBuilder } from 'discord.js'; import { getUserBirthday } from '../../../services/birthdayService.js'; import { logger } from '../../../utils/logger.js'; -import { handleInteractionError } from '../../../utils/errorHandler.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction); + await InteractionHelper.safeDefer(interaction); - const targetUser = interaction.options.getUser("user") || interaction.user; - const userId = targetUser.id; - const guildId = interaction.guildId; + const targetUser = interaction.options.getUser("user") || interaction.user; + const userId = targetUser.id; + const guildId = interaction.guildId; - const birthdayData = await getUserBirthday(client, guildId, userId); + const birthdayData = await getUserBirthday(client, guildId, userId); - if (!birthdayData) { - const embed = new EmbedBuilder() - .setColor(0xFF0000) - .setTitle('No Birthday Found') - .setDescription(targetUser.id === interaction.user.id - ? "You haven't set your birthday yet. Use `/birthday set` to add it!" - : `${targetUser.username} hasn't set their birthday yet.`); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); - } - + if (!birthdayData) { const embed = new EmbedBuilder() - .setColor(0x00FF00) - .setTitle('Birthday Information') - .setDescription(`**Date:** ${birthdayData.monthName} ${birthdayData.day}\n**User:** ${targetUser.toString()}`); - - await InteractionHelper.safeEditReply(interaction, { + .setColor(0xFF0000) + .setTitle('No Birthday Found') + .setDescription(targetUser.id === interaction.user.id + ? "You haven't set your birthday yet. Use `/birthday set` to add it!" + : `${targetUser.username} hasn't set their birthday yet.`); + return await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - - logger.info('Birthday info retrieved successfully', { - userId: interaction.user.id, - targetUserId: targetUser.id, - guildId, - commandName: 'birthday_info' - }); - } catch (error) { - logger.error("Birthday info command execution failed", { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'birthday_info' - }); - await handleInteractionError(interaction, error, { - commandName: 'birthday_info', - source: 'birthday_info_module' - }); } + + const embed = new EmbedBuilder() + .setColor(0x00FF00) + .setTitle('Birthday Information') + .setDescription(`**Date:** ${birthdayData.monthName} ${birthdayData.day}\n**User:** ${targetUser.toString()}`); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [embed] + }); + + logger.info('Birthday info retrieved successfully', { + userId: interaction.user.id, + targetUserId: targetUser.id, + guildId, + commandName: 'birthday_info' + }); } }; \ No newline at end of file diff --git a/src/commands/Birthday/modules/birthday_list.js b/src/commands/Birthday/modules/birthday_list.js index 8e11888c9e..ce7ee064da 100644 --- a/src/commands/Birthday/modules/birthday_list.js +++ b/src/commands/Birthday/modules/birthday_list.js @@ -2,90 +2,75 @@ import { EmbedBuilder } from 'discord.js'; import { getAllBirthdays } from '../../../services/birthdayService.js'; import { deleteBirthday } from '../../../utils/database.js'; import { logger } from '../../../utils/logger.js'; -import { handleInteractionError } from '../../../utils/errorHandler.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction); + await InteractionHelper.safeDefer(interaction); - const guildId = interaction.guildId; + const guildId = interaction.guildId; - const sortedBirthdays = await getAllBirthdays(client, guildId); + const sortedBirthdays = await getAllBirthdays(client, guildId); - if (sortedBirthdays.length === 0) { - const embed = new EmbedBuilder() - .setColor(0xFF0000) - .setTitle('No Birthdays') - .setDescription('No birthdays have been set in this server yet.'); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); - } - - const userIds = sortedBirthdays.map(b => b.userId); - const fetchedMembers = await interaction.guild.members.fetch({ user: userIds }).catch(() => null); + if (sortedBirthdays.length === 0) { + const embed = new EmbedBuilder() + .setColor(0xFF0000) + .setTitle('No Birthdays') + .setDescription('No birthdays have been set in this server yet.'); + return await InteractionHelper.safeEditReply(interaction, { + embeds: [embed] + }); + } - let birthdayList = ''; - let displayIndex = 0; - const staleUserIds = []; + const userIds = sortedBirthdays.map(b => b.userId); + const fetchedMembers = await interaction.guild.members.fetch({ user: userIds }).catch(() => null); - for (const birthday of sortedBirthdays) { - if (fetchedMembers && !fetchedMembers.has(birthday.userId)) { - staleUserIds.push(birthday.userId); - continue; - } - displayIndex++; - birthdayList += `${displayIndex}. <@${birthday.userId}> - ${birthday.monthName} ${birthday.day}\n`; - } + let birthdayList = ''; + let displayIndex = 0; + const staleUserIds = []; - if (fetchedMembers && staleUserIds.length > 0) { - for (const userId of staleUserIds) { - deleteBirthday(client, guildId, userId).catch(() => null); - } + for (const birthday of sortedBirthdays) { + if (fetchedMembers && !fetchedMembers.has(birthday.userId)) { + staleUserIds.push(birthday.userId); + continue; } + displayIndex++; + birthdayList += `${displayIndex}. <@${birthday.userId}> - ${birthday.monthName} ${birthday.day}\n`; + } - if (displayIndex === 0) { - const embed = new EmbedBuilder() - .setColor(0xFF0000) - .setTitle('No Birthdays') - .setDescription('No birthdays have been set by current server members.'); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); + if (fetchedMembers && staleUserIds.length > 0) { + for (const userId of staleUserIds) { + deleteBirthday(client, guildId, userId).catch(() => null); } + } - birthdayList = `**${displayIndex} birthday${displayIndex !== 1 ? 's' : ''} in ${interaction.guild.name}**\n\n` + birthdayList; - + if (displayIndex === 0) { const embed = new EmbedBuilder() - .setColor(0x00FF00) - .setTitle('Server Birthdays') - .setDescription(`${birthdayList}\n\nTotal: ${displayIndex} birthday${displayIndex !== 1 ? 's' : ''}`); - - await InteractionHelper.safeEditReply(interaction, { + .setColor(0xFF0000) + .setTitle('No Birthdays') + .setDescription('No birthdays have been set by current server members.'); + return await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - - logger.info('Birthday list retrieved successfully', { - userId: interaction.user.id, - guildId, - birthdayCount: displayIndex, - staleRemoved: staleUserIds.length, - commandName: 'birthday_list' - }); - } catch (error) { - logger.error("Birthday list command execution failed", { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'birthday_list' - }); - await handleInteractionError(interaction, error, { - commandName: 'birthday_list', - source: 'birthday_list_module' - }); } + + birthdayList = `**${displayIndex} birthday${displayIndex !== 1 ? 's' : ''} in ${interaction.guild.name}**\n\n` + birthdayList; + + const embed = new EmbedBuilder() + .setColor(0x00FF00) + .setTitle('Server Birthdays') + .setDescription(`${birthdayList}\n\nTotal: ${displayIndex} birthday${displayIndex !== 1 ? 's' : ''}`); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [embed] + }); + + logger.info('Birthday list retrieved successfully', { + userId: interaction.user.id, + guildId, + birthdayCount: displayIndex, + staleRemoved: staleUserIds.length, + commandName: 'birthday_list' + }); } }; \ No newline at end of file diff --git a/src/commands/Birthday/modules/birthday_remove.js b/src/commands/Birthday/modules/birthday_remove.js index 49615eb10b..2494c42768 100644 --- a/src/commands/Birthday/modules/birthday_remove.js +++ b/src/commands/Birthday/modules/birthday_remove.js @@ -1,48 +1,33 @@ import { EmbedBuilder } from 'discord.js'; import { deleteBirthday } from '../../../services/birthdayService.js'; -import { logger } from '../../../utils/logger.js'; -import { handleInteractionError } from '../../../utils/errorHandler.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction); + await InteractionHelper.safeDefer(interaction); - const userId = interaction.user.id; - const guildId = interaction.guildId; + const userId = interaction.user.id; + const guildId = interaction.guildId; - const result = await deleteBirthday(client, guildId, userId); + const result = await deleteBirthday(client, guildId, userId); - if (result.success) { - const embed = new EmbedBuilder() - .setColor(0x00FF00) - .setTitle('Birthday Removed') - .setDescription('Your birthday has been successfully removed from the server.'); - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); - } else if (result.notFound) { - const embed = new EmbedBuilder() - .setColor(0xFF0000) - .setTitle('No Birthday Found') - .setDescription('You don\'t have a birthday set to remove.'); - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); - } - } catch (error) { - logger.error("Birthday remove command execution failed", { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'birthday_remove' - }); - await handleInteractionError(interaction, error, { - commandName: 'birthday_remove', - source: 'birthday_remove_module' + if (result.status === 'not_found') { + const embed = new EmbedBuilder() + .setColor(0xFF0000) + .setTitle('No Birthday Found') + .setDescription('You don\'t have a birthday set to remove.'); + await InteractionHelper.safeEditReply(interaction, { + embeds: [embed] }); + return; } + + const embed = new EmbedBuilder() + .setColor(0x00FF00) + .setTitle('Birthday Removed') + .setDescription('Your birthday has been successfully removed from the server.'); + await InteractionHelper.safeEditReply(interaction, { + embeds: [embed] + }); } }; \ No newline at end of file diff --git a/src/commands/Birthday/modules/birthday_set.js b/src/commands/Birthday/modules/birthday_set.js index fd113c379e..bf968b065d 100644 --- a/src/commands/Birthday/modules/birthday_set.js +++ b/src/commands/Birthday/modules/birthday_set.js @@ -1,41 +1,25 @@ import { EmbedBuilder } from 'discord.js'; import { setBirthday } from '../../../services/birthdayService.js'; -import { logger } from '../../../utils/logger.js'; -import { handleInteractionError } from '../../../utils/errorHandler.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction); + await InteractionHelper.safeDefer(interaction); - const month = interaction.options.getInteger("month"); - const day = interaction.options.getInteger("day"); - const userId = interaction.user.id; - const guildId = interaction.guildId; + const month = interaction.options.getInteger("month"); + const day = interaction.options.getInteger("day"); + const userId = interaction.user.id; + const guildId = interaction.guildId; - const result = await setBirthday(client, guildId, userId, month, day); - - const embed = new EmbedBuilder() - .setColor(0x00FF00) - .setTitle('Birthday Set!') - .setDescription(`Your birthday has been set to **${result.data.monthName} ${result.data.day}**!`); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); - } catch (error) { - logger.error("Birthday set command execution failed", { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'birthday_set' - }); - await handleInteractionError(interaction, error, { - commandName: 'birthday_set', - source: 'birthday_set_module' - }); - } + const result = await setBirthday(client, guildId, userId, month, day); + + const embed = new EmbedBuilder() + .setColor(0x00FF00) + .setTitle('Birthday Set!') + .setDescription(`Your birthday has been set to **${result.data.monthName} ${result.data.day}**!`); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [embed] + }); } }; \ No newline at end of file diff --git a/src/commands/Birthday/modules/birthday_setchannel.js b/src/commands/Birthday/modules/birthday_setchannel.js index 968e3a6a28..09083bfa9f 100644 --- a/src/commands/Birthday/modules/birthday_setchannel.js +++ b/src/commands/Birthday/modules/birthday_setchannel.js @@ -1,5 +1,5 @@ -import { PermissionsBitField, EmbedBuilder } from 'discord.js'; -import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; +import { PermissionsBitField, EmbedBuilder, MessageFlags } from 'discord.js'; +import { getGuildConfig, setGuildConfig } from '../../../services/config/guildConfig.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; import { logger } from '../../../utils/logger.js'; diff --git a/src/commands/Birthday/modules/next_birthdays.js b/src/commands/Birthday/modules/next_birthdays.js index b3ef7829c6..0695347c16 100644 --- a/src/commands/Birthday/modules/next_birthdays.js +++ b/src/commands/Birthday/modules/next_birthdays.js @@ -2,105 +2,90 @@ import { EmbedBuilder } from 'discord.js'; import { getUpcomingBirthdays } from '../../../services/birthdayService.js'; import { deleteBirthday } from '../../../utils/database.js'; import { logger } from '../../../utils/logger.js'; -import { handleInteractionError } from '../../../utils/errorHandler.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction); + await InteractionHelper.safeDefer(interaction); - const next5 = await getUpcomingBirthdays(client, interaction.guildId, 5); + const next5 = await getUpcomingBirthdays(client, interaction.guildId, 5); - if (next5.length === 0) { - const embed = new EmbedBuilder() - .setColor(0xFF0000) - .setTitle('No Birthdays Found') - .setDescription('No birthdays have been set up in this server yet. Use `/birthday set` to add birthdays!'); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); - } - - let displayIndex = 0; - for (const birthday of next5) { - const member = await interaction.guild.members.fetch(birthday.userId).catch(() => null); - if (!member) { - deleteBirthday(client, interaction.guildId, birthday.userId).catch(() => null); - continue; - } - displayIndex++; + if (next5.length === 0) { + const embed = new EmbedBuilder() + .setColor(0xFF0000) + .setTitle('No Birthdays Found') + .setDescription('No birthdays have been set up in this server yet. Use `/birthday set` to add birthdays!'); + return await InteractionHelper.safeEditReply(interaction, { + embeds: [embed] + }); + } - let timeUntil = ''; - if (birthday.daysUntil === 0) { - timeUntil = '🎉 **Today!**'; - } else if (birthday.daysUntil === 1) { - timeUntil = '📅 **Tomorrow!**'; - } else { - timeUntil = `In ${birthday.daysUntil} day${birthday.daysUntil > 1 ? 's' : ''}`; - } + let displayIndex = 0; + for (const birthday of next5) { + const member = await interaction.guild.members.fetch(birthday.userId).catch(() => null); + if (!member) { + deleteBirthday(client, interaction.guildId, birthday.userId).catch(() => null); + continue; } + displayIndex++; - if (displayIndex === 0) { - const embed = new EmbedBuilder() - .setColor(0xFF0000) - .setTitle('No Upcoming Birthdays') - .setDescription('No upcoming birthdays found for current server members.'); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); + let timeUntil = ''; + if (birthday.daysUntil === 0) { + timeUntil = '🎉 **Today!**'; + } else if (birthday.daysUntil === 1) { + timeUntil = '📅 **Tomorrow!**'; + } else { + timeUntil = `In ${birthday.daysUntil} day${birthday.daysUntil > 1 ? 's' : ''}`; } + } - let birthdayList = `🎂 **Next 5 Upcoming Birthdays**\n\nHere are the next 5 birthdays in ${interaction.guild.name}:\n\n`; - displayIndex = 0; - for (const birthday of next5) { - const member = await interaction.guild.members.fetch(birthday.userId).catch(() => null); - if (!member) { - continue; - } - displayIndex++; + if (displayIndex === 0) { + const embed = new EmbedBuilder() + .setColor(0xFF0000) + .setTitle('No Upcoming Birthdays') + .setDescription('No upcoming birthdays found for current server members.'); + return await InteractionHelper.safeEditReply(interaction, { + embeds: [embed] + }); + } - let timeUntil = ''; - if (birthday.daysUntil === 0) { - timeUntil = '🎉 **Today!**'; - } else if (birthday.daysUntil === 1) { - timeUntil = '📅 **Tomorrow!**'; - } else { - timeUntil = `In ${birthday.daysUntil} day${birthday.daysUntil > 1 ? 's' : ''}`; - } + let birthdayList = `🎂 **Next 5 Upcoming Birthdays**\n\nHere are the next 5 birthdays in ${interaction.guild.name}:\n\n`; + displayIndex = 0; + for (const birthday of next5) { + const member = await interaction.guild.members.fetch(birthday.userId).catch(() => null); + if (!member) { + continue; + } + displayIndex++; - birthdayList += `${displayIndex}. **${member.displayName}**\n<@${birthday.userId}>\n📅 **Date:** ${birthday.monthName} ${birthday.day}\n⏰ **Time:** ${timeUntil}\n\n`; + let timeUntil = ''; + if (birthday.daysUntil === 0) { + timeUntil = '🎉 **Today!**'; + } else if (birthday.daysUntil === 1) { + timeUntil = '📅 **Tomorrow!**'; + } else { + timeUntil = `In ${birthday.daysUntil} day${birthday.daysUntil > 1 ? 's' : ''}`; } - birthdayList += `Use /birthday set to add your birthday!`; + birthdayList += `${displayIndex}. **${member.displayName}**\n<@${birthday.userId}>\n📅 **Date:** ${birthday.monthName} ${birthday.day}\n⏰ **Time:** ${timeUntil}\n\n`; + } - const embed = new EmbedBuilder() - .setColor(0x00FF00) - .setTitle('Next 5 Upcoming Birthdays') - .setDescription(birthdayList); + birthdayList += `Use /birthday set to add your birthday!`; - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); - - logger.info('Next birthdays retrieved successfully', { - userId: interaction.user.id, - guildId: interaction.guildId, - upcomingCount: displayIndex, - commandName: 'next_birthdays' - }); - } catch (error) { - logger.error('Next birthdays command execution failed', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'next_birthdays' - }); - await handleInteractionError(interaction, error, { - commandName: 'next_birthdays', - source: 'next_birthdays_module' - }); - } + const embed = new EmbedBuilder() + .setColor(0x00FF00) + .setTitle('Next 5 Upcoming Birthdays') + .setDescription(birthdayList); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [embed] + }); + + logger.info('Next birthdays retrieved successfully', { + userId: interaction.user.id, + guildId: interaction.guildId, + upcomingCount: displayIndex, + commandName: 'next_birthdays' + }); } }; \ No newline at end of file diff --git a/src/commands/Community/app-admin.js b/src/commands/Community/app-admin.js index d4891c03cf..6ffc02b3f8 100644 --- a/src/commands/Community/app-admin.js +++ b/src/commands/Community/app-admin.js @@ -1,8 +1,8 @@ import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ComponentType, LabelBuilder, RoleSelectMenuBuilder } from 'discord.js'; import { createEmbed, successEmbed } from '../../utils/embeds.js'; -import { getColor } from '../../config/bot.js'; +import { getColor, getApplicationStatusColor } from '../../config/bot.js'; import { logger } from '../../utils/logger.js'; -import { handleInteractionError, withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; +import { withErrorHandling, createError, ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; import ApplicationService from '../../services/applicationService.js'; import { getApplicationSettings, @@ -238,7 +238,7 @@ async function handleSetup(interaction) { const existingRoles = await getApplicationRoles(interaction.client, interaction.guild.id); if (existingRoles.some(r => r.roleId === roleId)) { - await replyUserError(submitted, { type: ErrorTypes.CONFIGURATION, message: 'The role ${role} is already configured as an application.' }); + await replyUserError(submitted, { type: ErrorTypes.CONFIGURATION, message: `The role ${role} is already configured as an application.` }); return; } @@ -377,14 +377,14 @@ async function handleReview(interaction) { try { const user = await reasonSubmit.client.users.fetch(application.userId); - const statusColor = status === "approved" ? getColor('success') : getColor('error'); + const statusColor = getApplicationStatusColor(status); const reviewStatus = getApplicationStatusPresentation(status); - const dmEmbed = createEmbed( - `${reviewStatus.statusEmoji} Application ${reviewStatus.statusLabel}`, - `Your application for **${application.roleName}** has been **${status}**\n` + + const dmEmbed = createEmbed({ + title: `${reviewStatus.statusEmoji} Application ${reviewStatus.statusLabel}`, + description: `Your application for **${application.roleName}** has been **${status}**\n` + `**Note:** ${reason}\n\n` + `Use \`/apply status id:${appId}\` to view details.` - ).setColor(statusColor); + }).setColor(statusColor); await user.send({ embeds: [dmEmbed] }); } catch (error) { @@ -397,7 +397,7 @@ async function handleReview(interaction) { if (application.logMessageId && application.logChannelId) { try { - const statusColor = status === "approved" ? getColor('success') : getColor('error'); + const statusColor = getApplicationStatusColor(status); const logChannel = interaction.guild.channels.cache.get( application.logChannelId, ); @@ -542,7 +542,11 @@ async function handleList(interaction) { return InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"] }); } else { - return await replyUserError(interaction, { type: ErrorTypes.CONFIGURATION, message: '"No applications found and no application roles configured.\\n" +\n "Use `/app-admin roles add` to configure application roles first."' }); + return await replyUserError(interaction, { + type: ErrorTypes.CONFIGURATION, + message: 'No applications found and no application roles configured.\n' + + 'Use `/app-admin roles add` to configure application roles first.' + }); } } @@ -577,95 +581,3 @@ async function handleList(interaction) { }); } -export async function handleApplicationReviewModal(interaction) { - if (!interaction.isModalSubmit()) return; - - const customId = interaction.customId; - if (!customId.startsWith('app_review_')) return; - - const [, appId, action] = customId.split('_'); - const reason = interaction.fields.getTextInputValue('reason') || 'No reason provided.'; - const isApprove = action === 'approve'; - - try { - const application = await getApplication(interaction.client, interaction.guild.id, appId); - if (!application) { - return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Application not found.' }); - } - - const status = isApprove ? 'approved' : 'denied'; - await updateApplication(interaction.client, interaction.guild.id, appId, { - status, - reviewer: interaction.user.id, - reviewMessage: reason, - reviewedAt: new Date().toISOString() - }); - - try { - const user = await interaction.client.users.fetch(application.userId); - const reviewStatus = getApplicationStatusPresentation(status); - const dmEmbed = createEmbed( - `${reviewStatus.statusEmoji} Application ${reviewStatus.statusLabel}`, - `Your application for **${application.roleName}** has been **${status}**.\n` + - `**Note:** ${reason}\n\n` + - `Use \`/apply status id:${appId}\` to view details.`, - isApprove ? '#00FF00' : '#FF0000' - ); - - await user.send({ embeds: [dmEmbed] }); - } catch (error) { - logger.error('Error sending DM to user:', error); - } - - if (application.logMessageId && application.logChannelId) { - try { - const logChannel = interaction.guild.channels.cache.get(application.logChannelId); - if (logChannel) { - const logMessage = await logChannel.messages.fetch(application.logMessageId); - if (logMessage) { - const embed = logMessage.embeds[0]; - if (embed) { - const reviewStatus = getApplicationStatusPresentation(status); - const newEmbed = EmbedBuilder.from(embed) - .setColor(isApprove ? '#00FF00' : '#FF0000') - .spliceFields(0, 1, { - name: 'Status', - value: `${reviewStatus.statusEmoji} ${reviewStatus.statusLabel}` - }); - - await logMessage.edit({ - embeds: [newEmbed], - components: [] - }); - } - } - } - } catch (error) { - logger.error('Error updating log message:', error); - } - } - - if (isApprove) { - try { - const member = await interaction.guild.members.fetch(application.userId); - await member.roles.add(application.role); - } catch (error) { - logger.error('Error assigning role:', error); - } - } - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - `${getApplicationStatusPresentation(status).statusEmoji} Application ${getApplicationStatusPresentation(status).statusLabel}`, - `The application has been marked as ${getApplicationStatusPresentation(status).statusLabel}.` - ) - ], - flags: ["Ephemeral"] - }); - - } catch (error) { - logger.error('Error processing application review:', error); - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while processing the application.' }); - } -} \ No newline at end of file diff --git a/src/commands/Community/apply.js b/src/commands/Community/apply.js index f4416a9e5f..0e2c2c9249 100644 --- a/src/commands/Community/apply.js +++ b/src/commands/Community/apply.js @@ -1,13 +1,13 @@ -import { getColor } from '../../config/bot.js'; +import { getColor, getDefaultApplicationQuestions } from '../../config/bot.js'; import { SlashCommandBuilder, ActionRowBuilder, ModalBuilder, TextInputBuilder, TextInputStyle } from 'discord.js'; import { createEmbed, successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; -import { handleInteractionError, withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; +import { handleInteractionError, withErrorHandling, createError, ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; import ApplicationService from '../../services/applicationService.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { logEvent, EVENT_TYPES, resolveApplicationLogChannel } from '../../services/loggingService.js'; -import { formatLogLine, resolveUserAuthor } from '../../utils/logEmbeds.js'; -import { getGuildConfig } from '../../services/guildConfig.js'; +import { formatLogLine, resolveUserAuthor } from '../../utils/logging/logEmbeds.js'; +import { getGuildConfig } from '../../services/config/guildConfig.js'; import { getApplicationSettings, getUserApplications, @@ -137,7 +137,7 @@ export async function handleApplicationModal(interaction) { const answers = []; const settings = await getApplicationSettings(interaction.client, interaction.guild.id); - let questions = settings.questions || ["Why do you want this role?", "What is your experience?"]; + let questions = settings.questions?.length ? settings.questions : getDefaultApplicationQuestions(); const roleSettings = await getApplicationRoleSettings(interaction.client, interaction.guild.id, roleId); if (roleSettings.questions && roleSettings.questions.length > 0) { questions = roleSettings.questions; @@ -300,7 +300,7 @@ async function handleSubmit(interaction, settings) { .setCustomId(`app_modal_${applicationRole.roleId}`) .setTitle(`Application for ${applicationRole.name}`); - let questions = settings.questions || ["Why do you want this role?", "What is your experience?"]; + let questions = settings.questions?.length ? settings.questions : getDefaultApplicationQuestions(); const roleSettings = await getApplicationRoleSettings(interaction.client, interaction.guild.id, applicationRole.roleId); if (roleSettings.questions && roleSettings.questions.length > 0) { questions = roleSettings.questions; diff --git a/src/commands/Community/modules/app_dashboard.js b/src/commands/Community/modules/app_dashboard.js index 9e7871df32..856a28b918 100644 --- a/src/commands/Community/modules/app_dashboard.js +++ b/src/commands/Community/modules/app_dashboard.js @@ -1,4 +1,4 @@ -import { getColor } from '../../../config/bot.js'; +import { getColor, getDefaultApplicationQuestions, botConfig } from '../../../config/bot.js'; import { ActionRowBuilder, StringSelectMenuBuilder, @@ -34,7 +34,7 @@ import { getApplications, deleteApplication, } from '../../../utils/database.js'; -import { getGuildConfig } from '../../../services/guildConfig.js'; +import { getGuildConfig } from '../../../services/config/guildConfig.js'; import { setLogChannel, resolveApplicationLogChannel, resolveLogChannel } from '../../../services/loggingService.js'; async function buildDashboardEmbed(settings, roles, guild, client) { @@ -942,12 +942,15 @@ async function handleRoleAdd(selectInteraction, rootInteraction, settings, roles const customName = modalSubmission.fields.getTextInputValue('role_name').trim() || role?.name || roleId; if (roles.some(r => r.roleId === roleId)) { - await replyUserError(modalSubmission, { type: ErrorTypes.UNKNOWN, message: '${role ?? roleId} is already an application role.' }); + await replyUserError(modalSubmission, { type: ErrorTypes.UNKNOWN, message: `${role ?? roleId} is already an application role.` }); return; } roles.push({ roleId, name: customName }); await saveApplicationRoles(client, guildId, roles); + await saveApplicationRoleSettings(client, guildId, roleId, { + questions: getDefaultApplicationQuestions(), + }); await modalSubmission.reply({ embeds: [successEmbed('Role Added', `${role ?? roleId} added as **${customName}**.`)], @@ -1004,7 +1007,7 @@ async function handleRoleRemove(selectInteraction, rootInteraction, settings, ro const index = roles.findIndex(r => r.roleId === roleId); if (index === -1) { - await replyUserError(modalSubmission, { type: ErrorTypes.USER_INPUT, message: '<@&${roleId}> is not in the application roles list.' }); + await replyUserError(modalSubmission, { type: ErrorTypes.USER_INPUT, message: `<@&${roleId}> is not in the application roles list.` }); return; } diff --git a/src/commands/Core/commands.js b/src/commands/Core/commands.js index de5abf73b7..579cbf2bd3 100644 --- a/src/commands/Core/commands.js +++ b/src/commands/Core/commands.js @@ -6,7 +6,7 @@ import { import { InteractionHelper } from '../../utils/interactionHelper.js'; import { successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; -import { handleInteractionError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; +import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { disableCategory, enableCategory, @@ -154,121 +154,111 @@ export default { }, async execute(interaction, config, client) { - try { - if (!(await ensureManageGuild(interaction))) { - return; - } + if (!(await ensureManageGuild(interaction))) { + return; + } - const subcommand = interaction.options.getSubcommand(); + const subcommand = interaction.options.getSubcommand(); - if (subcommand === 'dashboard') { - const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - if (!deferred) { - return; - } + if (subcommand === 'dashboard') { + const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + if (!deferred) { + return; + } - const view = await buildDashboardView(client, interaction.guildId, interaction.guild, 'overview'); - await InteractionHelper.safeEditReply(interaction, { - embeds: [view.embed], - components: view.components, - }); + const view = await buildDashboardView(client, interaction.guildId, interaction.guild, 'overview'); + await InteractionHelper.safeEditReply(interaction, { + embeds: [view.embed], + components: view.components, + }); - const replyMessage = await interaction.fetchReply().catch(() => null); - if (!replyMessage) { - return; - } + const replyMessage = await interaction.fetchReply().catch(() => null); + if (!replyMessage) { + return; + } - const collector = replyMessage.createMessageComponentCollector({ - filter: createDashboardCollectorFilter(interaction.user.id, interaction.guildId), - time: DASHBOARD_TIMEOUT_MS, - }); + const collector = replyMessage.createMessageComponentCollector({ + filter: createDashboardCollectorFilter(interaction.user.id, interaction.guildId), + time: DASHBOARD_TIMEOUT_MS, + }); - collector.on('collect', async (componentInteraction) => { - try { - if (!isCommandAccessCustomId(componentInteraction.customId)) { - return; - } - await handleDashboardComponent(componentInteraction, client); - } catch (error) { - logger.error('Command access dashboard interaction failed', { - error: error.message, - customId: componentInteraction.customId, - guildId: interaction.guildId, - }); - await replyUserError(componentInteraction, { - type: ErrorTypes.UNKNOWN, - message: error.message || 'Failed to update command access.', - }).catch(() => {}); + collector.on('collect', async (componentInteraction) => { + try { + if (!isCommandAccessCustomId(componentInteraction.customId)) { + return; } - }); - - collector.on('end', async () => { - const finalView = await buildDashboardView(client, interaction.guildId, interaction.guild, 'overview'); - const disabledComponents = finalView.components.map((row) => { - const newRow = row.toJSON(); - newRow.components = newRow.components.map((component) => ({ ...component, disabled: true })); - return newRow; + await handleDashboardComponent(componentInteraction, client); + } catch (error) { + logger.error('Command access dashboard interaction failed', { + error: error.message, + customId: componentInteraction.customId, + guildId: interaction.guildId, }); + await replyUserError(componentInteraction, { + type: ErrorTypes.UNKNOWN, + message: error.message || 'Failed to update command access.', + }).catch(() => {}); + } + }); - await replyMessage.edit({ components: disabledComponents }).catch(() => {}); + collector.on('end', async () => { + const finalView = await buildDashboardView(client, interaction.guildId, interaction.guild, 'overview'); + const disabledComponents = finalView.components.map((row) => { + const newRow = row.toJSON(); + newRow.components = newRow.components.map((component) => ({ ...component, disabled: true })); + return newRow; }); - return; - } - - const scope = interaction.options.getString('scope'); - const target = interaction.options.getString('target'); - const isDisable = subcommand === 'disable'; + await replyMessage.edit({ components: disabledComponents }).catch(() => {}); + }); - const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - if (!deferred) { - return; - } + return; + } - if (scope === 'category') { - const category = resolveCategoryChoice(client, target); - if (!category) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'No category matched \\`${target}\\`. Use \\`/commands dashboard\\` to browse categories.' }); - } + const scope = interaction.options.getString('scope'); + const target = interaction.options.getString('target'); + const isDisable = subcommand === 'disable'; - if (isDisable) { - await disableCategory(client, interaction.guildId, category.key); - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - 'Category Disabled', - `All **${category.displayName}** commands are now disabled.\nProtected commands remain available.`, - ), - ], - }); - } + const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + if (!deferred) { + return; + } - await enableCategory(client, interaction.guildId, category.key); - return InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed('Category Enabled', `**${category.displayName}** commands are now enabled (except individually disabled commands).`)], - }); + if (scope === 'category') { + const category = resolveCategoryChoice(client, target); + if (!category) { + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `No category matched \`${target}\`. Use \`/commands dashboard\` to browse categories.` }); } - const commandName = target.toLowerCase(); if (isDisable) { - await disableCommand(client, interaction.guildId, commandName); + await disableCategory(client, interaction.guildId, category.key); return InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed('Command Disabled', `\`/${commandName}\` is now disabled in this server.`)], + embeds: [ + successEmbed( + 'Category Disabled', + `All **${category.displayName}** commands are now disabled.\nProtected commands remain available.`, + ), + ], }); } - await enableCommand(client, interaction.guildId, commandName); + await enableCategory(client, interaction.guildId, category.key); return InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed('Command Enabled', `\`/${commandName}\` is now enabled in this server.`)], + embeds: [successEmbed('Category Enabled', `**${category.displayName}** commands are now enabled (except individually disabled commands).`)], }); - } catch (error) { - logger.error('commands command failed', { - error: error.message, - stack: error.stack, - guildId: interaction.guildId, - userId: interaction.user.id, + } + + const commandName = target.toLowerCase(); + if (isDisable) { + await disableCommand(client, interaction.guildId, commandName); + return InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('Command Disabled', `\`/${commandName}\` is now disabled in this server.`)], }); - await handleInteractionError(interaction, error, { commandName: 'commands' }); } + + await enableCommand(client, interaction.guildId, commandName); + return InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('Command Enabled', `\`/${commandName}\` is now enabled in this server.`)], + }); }, }; diff --git a/src/commands/Core/configWizard.js b/src/commands/Core/configWizard.js index bb593c3f86..22ac013ec0 100644 --- a/src/commands/Core/configWizard.js +++ b/src/commands/Core/configWizard.js @@ -18,10 +18,10 @@ import { import { InteractionHelper } from '../../utils/interactionHelper.js'; import { createEmbed, successEmbed, infoEmbed, warningEmbed, buildUserErrorEmbed } from '../../utils/embeds.js'; import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { getGuildConfig, setConfigValue } from '../../services/guildConfig.js'; -import ConfigService from '../../services/configService.js'; +import { getGuildConfig, setConfigValue } from '../../services/config/guildConfig.js'; +import ConfigService from '../../services/config/configService.js'; import { logger } from '../../utils/logger.js'; -import { botConfig } from '../../config/bot.js'; +import { botConfig, getCommandPrefix } from '../../config/bot.js'; const DASHBOARD_CUSTOM_ID = 'config_select'; const WIZARD_BUTTON_ID = 'config_wizard'; @@ -100,7 +100,7 @@ function buildDashboardEmbed(config, guild) { fields: [ { name: '⌨️ Server Prefix', - value: `\`${config.prefix || guild.client.config.bot.prefix || '!'}\``, + value: `\`${config.prefix || getCommandPrefix()}\``, inline: true, }, { @@ -300,7 +300,7 @@ async function runSetupWizard(buttonInteraction, config, guild, client, rootInte { key: 'prefix', skipMessage: 'Keeping the current server prefix.', - question: 'What command prefix should this server use?\nCurrent: `' + (config.prefix || guild.client.config.bot.prefix || '!') + '`\nReply `skip` to keep it, or `cancel` to stop.', + question: 'What command prefix should this server use?\nCurrent: `' + (config.prefix || getCommandPrefix()) + '`\nReply `skip` to keep it, or `cancel` to stop.', parse: async (answer) => { const normalized = answer.trim(); if (normalized.toLowerCase() === 'skip') return undefined; diff --git a/src/commands/Core/help.js b/src/commands/Core/help.js index 0163b8307e..3d9d7eb783 100644 --- a/src/commands/Core/help.js +++ b/src/commands/Core/help.js @@ -174,7 +174,7 @@ export default { components: [], }); } catch (error) { - + logger.debug('Help menu close edit failed (interaction may have expired):', error?.message); } }, HELP_MENU_TIMEOUT_MS); }, diff --git a/src/commands/Core/modules/commands_dashboard.js b/src/commands/Core/modules/commands_dashboard.js index c896551889..88a8865a50 100644 --- a/src/commands/Core/modules/commands_dashboard.js +++ b/src/commands/Core/modules/commands_dashboard.js @@ -14,7 +14,7 @@ import { enableCommand, resetCategoryCommands, } from '../../../services/commandAccessService.js'; -import { getGuildConfig } from '../../../services/guildConfig.js'; +import { getGuildConfig } from '../../../services/config/guildConfig.js'; export const DASHBOARD_CATEGORY_SELECT = 'cmdaccess_category'; export const DASHBOARD_COMMAND_SELECT = 'cmdaccess_command'; diff --git a/src/commands/Economy/beg.js b/src/commands/Economy/beg.js index e2b37b0dbb..9b2fc717f9 100644 --- a/src/commands/Economy/beg.js +++ b/src/commands/Economy/beg.js @@ -6,8 +6,8 @@ import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHan import { InteractionHelper } from '../../utils/interactionHelper.js'; const COOLDOWN = 30 * 60 * 1000; -const MIN_WIN = 50; -const MAX_WIN = 200; +const MIN_WIN = Number(botConfig?.economy?.begMin) || 50; +const MAX_WIN = Number(botConfig?.economy?.begMax) || 200; const SUCCESS_CHANCE = 0.7; export default { diff --git a/src/commands/Economy/buy.js b/src/commands/Economy/buy.js index 55a3d121ff..1a90387315 100644 --- a/src/commands/Economy/buy.js +++ b/src/commands/Economy/buy.js @@ -2,7 +2,7 @@ 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 { getGuildConfig } from '../../services/config/guildConfig.js'; import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; @@ -136,9 +136,12 @@ export default { } else if (item.type === "upgrade") { userData.upgrades[itemId] = true; successDescription += `\n\n**✨ Your upgrade is now active!**`; - } else if (item.type === "consumable") { + } else if (item.type === "consumable" || item.type === "tool") { userData.inventory[itemId] = (userData.inventory[itemId] || 0) + quantity; + if (item.type === "tool") { + successDescription += `\n\n**🛠️ ${item.name} added to your inventory!**`; + } } await setEconomyData(client, guildId, userId, userData); diff --git a/src/commands/Economy/crime.js b/src/commands/Economy/crime.js index 239c1d9c67..101dc5a575 100644 --- a/src/commands/Economy/crime.js +++ b/src/commands/Economy/crime.js @@ -5,10 +5,8 @@ import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHan 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 FINE_RATE = 0.2; const CRIME_TYPES = [ { name: "Pickpocketing", min: 100, max: 500, risk: 0.3 }, @@ -101,7 +99,9 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); } else { - const fine = Math.floor(amountEarned * 0.2); + // Fine is based on the potential haul of the attempted crime + const potentialHaul = Math.floor((crime.min + crime.max) / 2); + const fine = Math.min(Math.floor(potentialHaul * FINE_RATE), userData.wallet || 0); userData.wallet = Math.max(0, (userData.wallet || 0) - fine); userData.jailedUntil = now + JAIL_TIME; @@ -109,8 +109,8 @@ export default { 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.` + `You were caught while attempting ${crime.name} and have been sent to jail! ` + + `You were fined ${fine.toLocaleString()} coins and will be in jail for 2 hours.` ); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); diff --git a/src/commands/Economy/daily.js b/src/commands/Economy/daily.js index 06cd82ad69..4045d23ed0 100644 --- a/src/commands/Economy/daily.js +++ b/src/commands/Economy/daily.js @@ -1,14 +1,15 @@ 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 { getGuildConfig } from '../../services/config/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'; +import { botConfig } from '../../config/bot.js'; const DAILY_COOLDOWN = 24 * 60 * 60 * 1000; -const DAILY_AMOUNT = 1000; +const DAILY_AMOUNT = botConfig.economy?.dailyAmount ?? 100; const PREMIUM_BONUS_PERCENTAGE = 0.1; export default { diff --git a/src/commands/Economy/eleaderboard.js b/src/commands/Economy/eleaderboard.js index 76a430c1b3..e4603797a7 100644 --- a/src/commands/Economy/eleaderboard.js +++ b/src/commands/Economy/eleaderboard.js @@ -3,6 +3,7 @@ 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'; +import { getEconomyPrefix } from '../../utils/database.js'; export default { data: new SlashCommandBuilder() @@ -18,7 +19,7 @@ export default { logger.debug(`[ECONOMY] Leaderboard requested`, { guildId }); - const prefix = `economy:${guildId}:`; + const prefix = getEconomyPrefix(guildId); let allKeys = await client.db.list(prefix); diff --git a/src/commands/Economy/gamble.js b/src/commands/Economy/gamble.js index ee9837dba3..b4acf1745d 100644 --- a/src/commands/Economy/gamble.js +++ b/src/commands/Economy/gamble.js @@ -83,7 +83,8 @@ export default { if (win) { const amountWon = Math.floor(betAmount * PAYOUT_MULTIPLIER); -cashChange = amountWon; + // Net change: the bet is replaced by the payout (bet was at stake, not pre-deducted) + cashChange = amountWon - betAmount; resultEmbed = successEmbed( "🎉 You Won!", diff --git a/src/commands/Economy/modules/economy_dashboard.js b/src/commands/Economy/modules/economy_dashboard.js index 2bb7d74b73..04f507ff97 100644 --- a/src/commands/Economy/modules/economy_dashboard.js +++ b/src/commands/Economy/modules/economy_dashboard.js @@ -18,6 +18,7 @@ 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 { getEconomyPrefix } from '../../../utils/database.js'; import { getEconomyData, addMoney, removeMoney, getMaxBankCapacity } from '../../../utils/economy.js'; import fs from 'fs/promises'; import path from 'path'; @@ -34,7 +35,7 @@ async function buildDashboardEmbed(guild, client) { let userCount = 0; try { - const economyKeys = await client.db.list(`economy:${guild.id}:`); + const economyKeys = await client.db.list(getEconomyPrefix(guild.id)); if (economyKeys && economyKeys.length > 0) { for (const key of economyKeys) { @@ -111,7 +112,7 @@ async function refreshDashboard(rootInteraction, guild, client) { async function updateConfigFile(currencySymbol, currencyName) { try { - const configPath = path.join(__dirname, '../../config/bot.js'); + const configPath = path.join(__dirname, '../../../config/bot.js'); let configContent = await fs.readFile(configPath, 'utf-8'); configContent = configContent.replace( @@ -300,17 +301,12 @@ async function handleAddCurrency(selectInteraction, rootInteraction, guild, clie 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 { newBalance } = await addMoney(client, guild.id, userId, amount, type); 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()}`)], + embeds: [successEmbed('Currency Added', `Successfully added ${currencySymbol}${amount.toLocaleString()} to ${member.user.tag}'s ${type}.\n**New Balance:** ${currencySymbol}${newBalance.toLocaleString()}`)], flags: MessageFlags.Ephemeral, }); @@ -319,7 +315,7 @@ async function handleAddCurrency(selectInteraction, rootInteraction, guild, clie targetUserId: userId, amount, type, - newBalance: result.newBalance + newBalance, }); await refreshDashboard(rootInteraction, guild, client); @@ -402,17 +398,12 @@ async function handleRemoveCurrency(selectInteraction, rootInteraction, guild, c 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 { newBalance } = await removeMoney(client, guild.id, userId, amount, type); 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()}`)], + embeds: [successEmbed('Currency Removed', `Successfully removed ${currencySymbol}${amount.toLocaleString()} from ${member.user.tag}'s ${type}.\n**New Balance:** ${currencySymbol}${newBalance.toLocaleString()}`)], flags: MessageFlags.Ephemeral, }); @@ -421,7 +412,7 @@ async function handleRemoveCurrency(selectInteraction, rootInteraction, guild, c targetUserId: userId, amount, type, - newBalance: result.newBalance + newBalance, }); await refreshDashboard(rootInteraction, guild, client); diff --git a/src/commands/Economy/modules/shop_browse.js b/src/commands/Economy/modules/shop_browse.js index 0313ddb24f..b7adcf40dd 100644 --- a/src/commands/Economy/modules/shop_browse.js +++ b/src/commands/Economy/modules/shop_browse.js @@ -2,6 +2,7 @@ import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, EmbedBuild import { shopItems } from '../../../config/shop/items.js'; import { getColor } from '../../../config/bot.js'; import { logger } from '../../../utils/logger.js'; +import { handleInteractionError } from '../../../utils/errorHandler.js'; export default { async execute(interaction, config, client) { @@ -80,11 +81,14 @@ export default { const disabledComponents = createShopComponents(currentPage); disabledComponents.forEach(row => row.components.forEach(btn => btn.setDisabled(true))); await message.edit({ components: disabledComponents }); - } catch (_) {} + } catch (error) { + logger.debug('shop_browse: could not disable components on collector end', { + error: error.message, + }); + } }); } catch (error) { - logger.error('shop_browse error:', error); - await interaction.reply({ content: '❌ An error occurred while loading the shop.', flags: MessageFlags.Ephemeral }); + await handleInteractionError(interaction, error, { command: 'shop_browse' }); } }, }; \ 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 index 53a8bbca9e..e917e9e69a 100644 --- a/src/commands/Economy/modules/shop_config_setrole.js +++ b/src/commands/Economy/modules/shop_config_setrole.js @@ -1,9 +1,10 @@ import { PermissionsBitField } from 'discord.js'; import { successEmbed } from '../../../utils/embeds.js'; -import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; +import { getGuildConfig, setGuildConfig } from '../../../services/config/guildConfig.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; import { logger } from '../../../utils/logger.js'; +import { replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; export default { async execute(interaction, config, client) { if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { diff --git a/src/commands/Economy/rob.js b/src/commands/Economy/rob.js index dec0ceff3d..68f74fbfee 100644 --- a/src/commands/Economy/rob.js +++ b/src/commands/Economy/rob.js @@ -3,9 +3,10 @@ import { successEmbed, warningEmbed, buildUserErrorEmbed } from '../../utils/emb import { getEconomyData, setEconomyData } from '../../utils/economy.js'; import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { BotConfig } from '../../config/bot.js'; -const ROB_COOLDOWN = 4 * 60 * 60 * 1000; -const BASE_ROB_SUCCESS_CHANCE = 0.25; +const ROB_COOLDOWN = BotConfig.economy?.cooldowns?.rob ?? 4 * 60 * 60 * 1000; +const BASE_ROB_SUCCESS_CHANCE = BotConfig.economy?.robSuccessRate ?? 0.4; const ROB_PERCENTAGE = 0.15; const FINE_PERCENTAGE = 0.1; @@ -146,7 +147,7 @@ export default { inline: true, }, ) - .setFooter({ text: `Next robbery available in 4 hours.` }); + .setFooter({ text: `Next robbery available in ${Math.ceil(ROB_COOLDOWN / (60 * 60 * 1000))} hours.` }); await InteractionHelper.safeEditReply(interaction, { embeds: [resultEmbed] }); }, { command: 'rob' }) diff --git a/src/commands/Economy/work.js b/src/commands/Economy/work.js index 1f7f1979be..775be897ad 100644 --- a/src/commands/Economy/work.js +++ b/src/commands/Economy/work.js @@ -4,10 +4,11 @@ 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'; +import { botConfig } from '../../config/bot.js'; -const WORK_COOLDOWN = 30 * 60 * 1000; -const MIN_WORK_AMOUNT = 50; -const MAX_WORK_AMOUNT = 300; +const WORK_COOLDOWN = botConfig.economy?.cooldowns?.work ?? 30 * 60 * 1000; +const MIN_WORK_AMOUNT = botConfig.economy?.workMin ?? 10; +const MAX_WORK_AMOUNT = botConfig.economy?.workMax ?? 100; const LAPTOP_MULTIPLIER = 1.5; const WORK_JOBS = [ "Software Developer", diff --git a/src/commands/Fun/count.js b/src/commands/Fun/count.js index 5d465093df..1dbd6014b1 100644 --- a/src/commands/Fun/count.js +++ b/src/commands/Fun/count.js @@ -13,6 +13,7 @@ import { } from '../../services/countingGameService.js'; import { logger } from '../../utils/logger.js'; +import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; export default { data: new SlashCommandBuilder() .setName('count') @@ -84,7 +85,7 @@ export default { } if (config.enabled && config.channelId && config.channelId !== channel.id) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This server already has an active counting channel configured: <#${config.channelId}>. Disable the current counting game first, or use that existing channel.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `This server already has an active counting channel configured: <#${config.channelId}>. Disable the current counting game first, or use that existing channel.` }); } await activateCountingGame(interaction.client, guildId, channel.id, system); diff --git a/src/commands/Fun/fight.js b/src/commands/Fun/fight.js index 0e64e8771a..3093af2419 100644 --- a/src/commands/Fun/fight.js +++ b/src/commands/Fun/fight.js @@ -1,7 +1,6 @@ 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; @@ -20,73 +19,65 @@ export default { category: 'Fun', async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction); + await InteractionHelper.safeDefer(interaction); - const challenger = interaction.user; - const opponent = interaction.options.getUser("opponent"); + 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 (challenger.id === opponent.id) { + const embed = warningEmbed( + "⚔️ Invalid Challenge", + `**${challenger.username}**, you can't fight yourself! That's a draw before it even starts.` + ); + return await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + } + + if (opponent.bot) { + const embed = warningEmbed( + "⚔️ Invalid Opponent", + "You can't fight bots! Challenge a real person instead." + ); + 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 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)`, + ); - const log = []; + 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( - `💥 **${challenger.username}** challenges **${opponent.username}** to a duel! (Best of ${rounds} rounds)`, + `\n**Round ${i}:** ${attacker.username} ${action} on ${target.username} for ${rand(1, damage)} damage!`, ); + } - 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 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 description = fullDescription.length <= EMBED_DESCRIPTION_LIMIT + ? fullDescription + : `${fullDescription.slice(0, EMBED_DESCRIPTION_LIMIT - 15)}\n\n...`; - const embed = successEmbed( - description, - "🏆 Duel Complete!" - ); + const embed = successEmbed( + "🏆 Duel Complete!", + description + ); - 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' - }); - } + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + logger.debug(`Fight command executed between ${challenger.id} and ${opponent.id} in guild ${interaction.guildId}`); }, }; \ No newline at end of file diff --git a/src/commands/Fun/flip.js b/src/commands/Fun/flip.js index 115a011ea5..2eeef5cfa9 100644 --- a/src/commands/Fun/flip.js +++ b/src/commands/Fun/flip.js @@ -1,7 +1,7 @@ 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 { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { @@ -11,23 +11,15 @@ export default { category: 'Fun', async execute(interaction, config, client) { - try { - const result = Math.random() < 0.5 ? "Heads" : "Tails"; - const emoji = result === "Heads" ? "🪙" : "🔮"; + const result = Math.random() < 0.5 ? "Heads" : "Tails"; + const emoji = result === "Heads" ? "🪙" : "🔮"; - const embed = successEmbed( - "Heads or Tails?", - `The coin landed on... **${result}** ${emoji}!`, - ); + 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' - }); - } + await InteractionHelper.safeReply(interaction, { embeds: [embed] }); + logger.debug(`Flip command executed by user ${interaction.user.id} in guild ${interaction.guildId}`); }, }; \ No newline at end of file diff --git a/src/commands/Fun/roll.js b/src/commands/Fun/roll.js index b502d958f0..988fe1b663 100644 --- a/src/commands/Fun/roll.js +++ b/src/commands/Fun/roll.js @@ -1,7 +1,7 @@ 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 { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { @@ -18,71 +18,64 @@ export default { category: 'Fun', async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction); + await InteractionHelper.safeDefer(interaction); - const notation = interaction.options - .getString("notation") - .toLowerCase() - .replace(/\s/g, ""); + const notation = interaction.options + .getString("notation") + .toLowerCase() + .replace(/\s/g, ""); - const match = notation.match(/^(\d*)d(\d+)([\+\-]\d+)?$/); + 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`.' - ); - } + 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); + 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 (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.' - ); - } + 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; + 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; - } + 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 finalTotal = totalRoll + modifier; - const resultsDetail = - numDice > 1 ? `**Rolls:** ${rolls.join(" + ")}\n` : ""; - const modifierText = modifier !== 0 ? `+ (${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}**`, - ); + 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' - }); - } + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + logger.debug(`Roll command executed by user ${interaction.user.id} with notation ${notation} in guild ${interaction.guildId}`); }, }; \ No newline at end of file diff --git a/src/commands/Giveaway/gcreate.js b/src/commands/Giveaway/gcreate.js index f0bc518f8b..e5f19c048c 100644 --- a/src/commands/Giveaway/gcreate.js +++ b/src/commands/Giveaway/gcreate.js @@ -1,7 +1,7 @@ import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, MessageFlags } from 'discord.js'; import { errorEmbed, successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; -import { TitanBotError, ErrorTypes, handleInteractionError } from '../../utils/errorHandler.js'; +import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { saveGiveaway } from '../../utils/giveaways.js'; import { parseDuration, @@ -13,6 +13,11 @@ import { import { logEvent, EVENT_TYPES } from '../../services/loggingService.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { botConfig } from '../../config/bot.js'; + +const GIVEAWAY_MIN_WINNERS = botConfig.giveaways?.minimumWinners ?? 1; +const GIVEAWAY_MAX_WINNERS = botConfig.giveaways?.maximumWinners ?? 10; + export default { data: new SlashCommandBuilder() .setName("gcreate") @@ -29,8 +34,8 @@ export default { option .setName("winners") .setDescription("The number of winners to pick.") - .setMinValue(1) - .setMaxValue(10) + .setMinValue(GIVEAWAY_MIN_WINNERS) + .setMaxValue(GIVEAWAY_MAX_WINNERS) .setRequired(true), ) .addStringOption((option) => @@ -49,138 +54,131 @@ export default { .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild), async execute(interaction) { - try { - - if (!interaction.inGuild()) { - throw new TitanBotError( - 'Giveaway command used outside guild', - ErrorTypes.VALIDATION, - 'This command can only be used in a server.', - { userId: interaction.user.id } - ); - } - - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { - throw new TitanBotError( - 'User lacks ManageGuild permission', - ErrorTypes.PERMISSION, - "You need the 'Manage Server' permission to start a giveaway.", - { userId: interaction.user.id, guildId: interaction.guildId } - ); - } - - logger.info(`Giveaway creation started by ${interaction.user.tag} in guild ${interaction.guildId}`); - - const durationString = interaction.options.getString("duration"); - const winnerCount = interaction.options.getInteger("winners"); - const prize = interaction.options.getString("prize"); - const targetChannel = interaction.options.getChannel("channel") || interaction.channel; - - const durationMs = parseDuration(durationString); - validateWinnerCount(winnerCount); - const prizeName = validatePrize(prize); - - if (!targetChannel.isTextBased()) { - throw new TitanBotError( - 'Target channel is not text-based', - ErrorTypes.VALIDATION, - 'The channel must be a text channel.', - { channelId: targetChannel.id, channelType: targetChannel.type } - ); - } - - const endTime = Date.now() + durationMs; - - const initialGiveawayData = { - messageId: "placeholder", - channelId: targetChannel.id, - guildId: interaction.guildId, - prize: prizeName, - hostId: interaction.user.id, - endTime: endTime, - endsAt: endTime, - winnerCount: winnerCount, - participants: [], - isEnded: false, - ended: false, - createdAt: new Date().toISOString() - }; - - const embed = createGiveawayEmbed(initialGiveawayData, "active"); - const row = createGiveawayButtons(false); - - const giveawayMessage = await targetChannel.send({ - content: "🎉 **NEW GIVEAWAY** 🎉", - embeds: [embed], - components: [row], - }); + // Defer up front: sending the giveaway message + DB write can exceed the 3s window + await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + + if (!interaction.inGuild()) { + throw new TitanBotError( + 'Giveaway command used outside guild', + ErrorTypes.VALIDATION, + 'This command can only be used in a server.', + { userId: interaction.user.id } + ); + } - initialGiveawayData.messageId = giveawayMessage.id; - const saved = await saveGiveaway( - interaction.client, - interaction.guildId, - initialGiveawayData, + if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { + throw new TitanBotError( + 'User lacks ManageGuild permission', + ErrorTypes.PERMISSION, + "You need the 'Manage Server' permission to start a giveaway.", + { userId: interaction.user.id, guildId: interaction.guildId } ); + } - if (!saved) { - logger.warn(`Failed to save giveaway to database: ${giveawayMessage.id}`); - } - - try { - await logEvent({ - client: interaction.client, - guildId: interaction.guildId, - eventType: EVENT_TYPES.GIVEAWAY_CREATE, - data: { - description: `Giveaway created: ${prizeName}`, - channelId: targetChannel.id, - userId: interaction.user.id, - fields: [ - { - name: 'Prize', - value: prizeName, - inline: true - }, - { - name: 'Winners', - value: winnerCount.toString(), - inline: true - }, - { - name: 'Duration', - value: durationString, - inline: true - }, - { - name: 'Channel', - value: targetChannel.toString(), - inline: true - } - ] - } - }); - } catch (logError) { - logger.debug('Error logging giveaway creation event:', logError); - } - - logger.info(`Giveaway created successfully: ${giveawayMessage.id} in ${targetChannel.name}`); - - await InteractionHelper.safeReply(interaction, { - embeds: [ - successEmbed( - `Giveaway Started! 🎉`, - `A new giveaway for **${prizeName}** has been started in ${targetChannel} and will end in **${durationString}**.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); + logger.info(`Giveaway creation started by ${interaction.user.tag} in guild ${interaction.guildId}`); + + const durationString = interaction.options.getString("duration"); + const winnerCount = interaction.options.getInteger("winners"); + const prize = interaction.options.getString("prize"); + const targetChannel = interaction.options.getChannel("channel") || interaction.channel; - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'gcreate', - context: 'giveaway_creation' + const durationMs = parseDuration(durationString); + validateWinnerCount(winnerCount); + const prizeName = validatePrize(prize); + + if (!targetChannel.isTextBased()) { + throw new TitanBotError( + 'Target channel is not text-based', + ErrorTypes.VALIDATION, + 'The channel must be a text channel.', + { channelId: targetChannel.id, channelType: targetChannel.type } + ); + } + + const endTime = Date.now() + durationMs; + + const initialGiveawayData = { + messageId: "placeholder", + channelId: targetChannel.id, + guildId: interaction.guildId, + prize: prizeName, + hostId: interaction.user.id, + endTime: endTime, + endsAt: endTime, + winnerCount: winnerCount, + participants: [], + isEnded: false, + ended: false, + createdAt: new Date().toISOString() + }; + + const embed = createGiveawayEmbed(initialGiveawayData, "active"); + const row = createGiveawayButtons(false); + + const giveawayMessage = await targetChannel.send({ + content: "🎉 **NEW GIVEAWAY** 🎉", + embeds: [embed], + components: [row], + }); + + initialGiveawayData.messageId = giveawayMessage.id; + const saved = await saveGiveaway( + interaction.client, + interaction.guildId, + initialGiveawayData, + ); + + if (!saved) { + logger.warn(`Failed to save giveaway to database: ${giveawayMessage.id}`); + } + + try { + await logEvent({ + client: interaction.client, + guildId: interaction.guildId, + eventType: EVENT_TYPES.GIVEAWAY_CREATE, + data: { + description: `Giveaway created: ${prizeName}`, + channelId: targetChannel.id, + userId: interaction.user.id, + fields: [ + { + name: 'Prize', + value: prizeName, + inline: true + }, + { + name: 'Winners', + value: winnerCount.toString(), + inline: true + }, + { + name: 'Duration', + value: durationString, + inline: true + }, + { + name: 'Channel', + value: targetChannel.toString(), + inline: true + } + ] + } }); + } catch (logError) { + logger.debug('Error logging giveaway creation event:', logError); } + + logger.info(`Giveaway created successfully: ${giveawayMessage.id} in ${targetChannel.name}`); + + await InteractionHelper.safeReply(interaction, { + embeds: [ + successEmbed( + `Giveaway Started! 🎉`, + `A new giveaway for **${prizeName}** has been started in ${targetChannel} and will end in **${durationString}**.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); }, }; \ No newline at end of file diff --git a/src/commands/Giveaway/gdelete.js b/src/commands/Giveaway/gdelete.js index 7c8b792d7f..838209c0c0 100644 --- a/src/commands/Giveaway/gdelete.js +++ b/src/commands/Giveaway/gdelete.js @@ -1,7 +1,7 @@ import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; import { errorEmbed, successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; -import { TitanBotError, ErrorTypes, handleInteractionError } from '../../utils/errorHandler.js'; +import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { getGuildGiveaways, deleteGiveaway } from '../../utils/giveaways.js'; import { logEvent, EVENT_TYPES } from '../../services/loggingService.js'; @@ -21,180 +21,169 @@ export default { .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild), async execute(interaction) { - try { - - if (!interaction.inGuild()) { - throw new TitanBotError( - 'Giveaway command used outside guild', - ErrorTypes.VALIDATION, - 'This command can only be used in a server.', - { userId: interaction.user.id } - ); - } + if (!interaction.inGuild()) { + throw new TitanBotError( + 'Giveaway command used outside guild', + ErrorTypes.VALIDATION, + 'This command can only be used in a server.', + { userId: interaction.user.id } + ); + } - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { - throw new TitanBotError( - 'User lacks ManageGuild permission', - ErrorTypes.PERMISSION, - "You need the 'Manage Server' permission to delete a giveaway.", - { userId: interaction.user.id, guildId: interaction.guildId } - ); - } + if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { + throw new TitanBotError( + 'User lacks ManageGuild permission', + ErrorTypes.PERMISSION, + "You need the 'Manage Server' permission to delete a giveaway.", + { userId: interaction.user.id, guildId: interaction.guildId } + ); + } - logger.info(`Giveaway deletion started by ${interaction.user.tag} in guild ${interaction.guildId}`); + logger.info(`Giveaway deletion started by ${interaction.user.tag} in guild ${interaction.guildId}`); - const messageId = interaction.options.getString("messageid"); + const messageId = interaction.options.getString("messageid"); - if (!messageId || !/^\d+$/.test(messageId)) { - throw new TitanBotError( - 'Invalid message ID format', - ErrorTypes.VALIDATION, - 'Please provide a valid message ID.', - { providedId: messageId } - ); - } + if (!messageId || !/^\d+$/.test(messageId)) { + throw new TitanBotError( + 'Invalid message ID format', + ErrorTypes.VALIDATION, + 'Please provide a valid message ID.', + { providedId: messageId } + ); + } - const giveaways = await getGuildGiveaways(interaction.client, interaction.guildId); - const giveaway = giveaways.find(g => g.messageId === messageId); + const giveaways = await getGuildGiveaways(interaction.client, interaction.guildId); + const giveaway = giveaways.find(g => g.messageId === messageId); - if (!giveaway) { - throw new TitanBotError( - `Giveaway not found: ${messageId}`, - ErrorTypes.VALIDATION, - "No giveaway was found with that message ID.", - { messageId, guildId: interaction.guildId } - ); - } + if (!giveaway) { + throw new TitanBotError( + `Giveaway not found: ${messageId}`, + ErrorTypes.VALIDATION, + "No giveaway was found with that message ID.", + { messageId, guildId: interaction.guildId } + ); + } - let deletedMessage = false; - let channelName = "Unknown Channel"; + let deletedMessage = false; + let channelName = "Unknown Channel"; - const tryDeleteFromChannel = async (channel) => { - if (!channel || !channel.isTextBased() || !channel.messages?.fetch) { - return false; - } + const tryDeleteFromChannel = async (channel) => { + if (!channel || !channel.isTextBased() || !channel.messages?.fetch) { + return false; + } - const message = await channel.messages.fetch(messageId).catch(() => null); - if (!message) { - return false; - } + const message = await channel.messages.fetch(messageId).catch(() => null); + if (!message) { + return false; + } - await message.delete(); - channelName = channel.name || 'unknown-channel'; - deletedMessage = true; - return true; - }; + await message.delete(); + channelName = channel.name || 'unknown-channel'; + deletedMessage = true; + return true; + }; - try { - const channel = await interaction.client.channels.fetch(giveaway.channelId).catch(() => null); - if (await tryDeleteFromChannel(channel)) { - logger.debug(`Deleted giveaway message ${messageId} from channel ${channelName}`); - } + try { + const channel = await interaction.client.channels.fetch(giveaway.channelId).catch(() => null); + if (await tryDeleteFromChannel(channel)) { + logger.debug(`Deleted giveaway message ${messageId} from channel ${channelName}`); + } - if (!deletedMessage && interaction.guild) { - const textChannels = interaction.guild.channels.cache.filter( - ch => ch.id !== giveaway.channelId && ch.isTextBased() && ch.messages?.fetch - ); + if (!deletedMessage && interaction.guild) { + const textChannels = interaction.guild.channels.cache.filter( + ch => ch.id !== giveaway.channelId && ch.isTextBased() && ch.messages?.fetch + ); - for (const [, guildChannel] of textChannels) { - const foundAndDeleted = await tryDeleteFromChannel(guildChannel).catch(() => false); - if (foundAndDeleted) { - logger.debug(`Deleted giveaway message ${messageId} via fallback lookup in #${channelName}`); - break; - } + for (const [, guildChannel] of textChannels) { + const foundAndDeleted = await tryDeleteFromChannel(guildChannel).catch(() => false); + if (foundAndDeleted) { + logger.debug(`Deleted giveaway message ${messageId} via fallback lookup in #${channelName}`); + break; } } - } catch (error) { - logger.warn(`Could not delete giveaway message: ${error.message}`); } + } catch (error) { + logger.warn(`Could not delete giveaway message: ${error.message}`); + } - const removedFromDatabase = await deleteGiveaway( - interaction.client, - interaction.guildId, - messageId, + const removedFromDatabase = await deleteGiveaway( + interaction.client, + interaction.guildId, + messageId, + ); + + if (!removedFromDatabase) { + throw new TitanBotError( + `Failed to delete giveaway from database: ${messageId}`, + ErrorTypes.UNKNOWN, + 'The giveaway could not be removed from the database. Please try again.', + { messageId, guildId: interaction.guildId } ); + } - if (!removedFromDatabase) { - throw new TitanBotError( - `Failed to delete giveaway from database: ${messageId}`, - ErrorTypes.UNKNOWN, - 'The giveaway could not be removed from the database. Please try again.', - { messageId, guildId: interaction.guildId } - ); - } + const giveawaysAfterDelete = await getGuildGiveaways(interaction.client, interaction.guildId); + const stillExistsInDatabase = giveawaysAfterDelete.some(g => g.messageId === messageId); + + if (stillExistsInDatabase) { + throw new TitanBotError( + `Giveaway still exists after deletion: ${messageId}`, + ErrorTypes.UNKNOWN, + 'Deletion did not persist in the database. Please try again.', + { messageId, guildId: interaction.guildId } + ); + } - const giveawaysAfterDelete = await getGuildGiveaways(interaction.client, interaction.guildId); - const stillExistsInDatabase = giveawaysAfterDelete.some(g => g.messageId === messageId); + const statusMsg = deletedMessage + ? `and the message was deleted from #${channelName}` + : `but the message was already deleted or the channel was inaccessible.`; - if (stillExistsInDatabase) { - throw new TitanBotError( - `Giveaway still exists after deletion: ${messageId}`, - ErrorTypes.UNKNOWN, - 'Deletion did not persist in the database. Please try again.', - { messageId, guildId: interaction.guildId } - ); - } + const winnerIds = Array.isArray(giveaway.winnerIds) ? giveaway.winnerIds : []; + const hasWinners = winnerIds.length > 0; + const wasEnded = giveaway.ended === true || giveaway.isEnded === true || hasWinners; - const statusMsg = deletedMessage - ? `and the message was deleted from #${channelName}` - : `but the message was already deleted or the channel was inaccessible.`; - - const winnerIds = Array.isArray(giveaway.winnerIds) ? giveaway.winnerIds : []; - const hasWinners = winnerIds.length > 0; - const wasEnded = giveaway.ended === true || giveaway.isEnded === true || hasWinners; - - const winnerStatusMsg = hasWinners - ? `This giveaway already had ${winnerIds.length} winner(s) selected.` - : wasEnded - ? 'This giveaway was ended with no valid winners.' - : 'No winner was picked before deletion.'; - - logger.info(`Giveaway deleted: ${messageId} in ${channelName}`); - - try { - await logEvent({ - client: interaction.client, - guildId: interaction.guildId, - eventType: EVENT_TYPES.GIVEAWAY_DELETE, - data: { - description: `Giveaway deleted: ${giveaway.prize}`, - channelId: giveaway.channelId, - userId: interaction.user.id, - fields: [ - { - name: 'Prize', - value: giveaway.prize || 'Unknown', - inline: true - }, - { - name: 'Entries', - value: (giveaway.participants?.length || 0).toString(), - inline: true - } - ] - } - }); - } catch (logError) { - logger.debug('Error logging giveaway deletion:', logError); - } + const winnerStatusMsg = hasWinners + ? `This giveaway already had ${winnerIds.length} winner(s) selected.` + : wasEnded + ? 'This giveaway was ended with no valid winners.' + : 'No winner was picked before deletion.'; - return InteractionHelper.safeReply(interaction, { - embeds: [ - successEmbed( - "Giveaway Deleted", - `Successfully deleted the giveaway for **${giveaway.prize}** ${statusMsg}. ${winnerStatusMsg}`, - ), - ], - flags: MessageFlags.Ephemeral, - }); + logger.info(`Giveaway deleted: ${messageId} in ${channelName}`); - } catch (error) { - logger.error('Error in gdelete command:', error); - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'gdelete', - context: 'giveaway_deletion' + try { + await logEvent({ + client: interaction.client, + guildId: interaction.guildId, + eventType: EVENT_TYPES.GIVEAWAY_DELETE, + data: { + description: `Giveaway deleted: ${giveaway.prize}`, + channelId: giveaway.channelId, + userId: interaction.user.id, + fields: [ + { + name: 'Prize', + value: giveaway.prize || 'Unknown', + inline: true + }, + { + name: 'Entries', + value: (giveaway.participants?.length || 0).toString(), + inline: true + } + ] + } }); + } catch (logError) { + logger.debug('Error logging giveaway deletion:', logError); } + + return InteractionHelper.safeReply(interaction, { + embeds: [ + successEmbed( + "Giveaway Deleted", + `Successfully deleted the giveaway for **${giveaway.prize}** ${statusMsg}. ${winnerStatusMsg}`, + ), + ], + flags: MessageFlags.Ephemeral, + }); }, }; \ No newline at end of file diff --git a/src/commands/Giveaway/gend.js b/src/commands/Giveaway/gend.js index a2b42bc18f..700ca5eaeb 100644 --- a/src/commands/Giveaway/gend.js +++ b/src/commands/Giveaway/gend.js @@ -1,7 +1,7 @@ import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; import { errorEmbed, successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; -import { TitanBotError, ErrorTypes, handleInteractionError } from '../../utils/errorHandler.js'; +import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { getGuildGiveaways, saveGiveaway } from '../../utils/giveaways.js'; import { endGiveaway as endGiveawayService, @@ -26,176 +26,166 @@ export default { .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild), async execute(interaction) { - try { - - if (!interaction.inGuild()) { - throw new TitanBotError( - 'Giveaway command used outside guild', - ErrorTypes.VALIDATION, - 'This command can only be used in a server.', - { userId: interaction.user.id } - ); - } - - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { - throw new TitanBotError( - 'User lacks ManageGuild permission', - ErrorTypes.PERMISSION, - "You need the 'Manage Server' permission to end a giveaway.", - { userId: interaction.user.id, guildId: interaction.guildId } - ); - } + if (!interaction.inGuild()) { + throw new TitanBotError( + 'Giveaway command used outside guild', + ErrorTypes.VALIDATION, + 'This command can only be used in a server.', + { userId: interaction.user.id } + ); + } - logger.info(`Giveaway end initiated by ${interaction.user.tag} in guild ${interaction.guildId}`); + if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { + throw new TitanBotError( + 'User lacks ManageGuild permission', + ErrorTypes.PERMISSION, + "You need the 'Manage Server' permission to end a giveaway.", + { userId: interaction.user.id, guildId: interaction.guildId } + ); + } - const messageId = interaction.options.getString("messageid"); + logger.info(`Giveaway end initiated by ${interaction.user.tag} in guild ${interaction.guildId}`); - if (!messageId || !/^\d+$/.test(messageId)) { - throw new TitanBotError( - 'Invalid message ID format', - ErrorTypes.VALIDATION, - 'Please provide a valid message ID.', - { providedId: messageId } - ); - } + const messageId = interaction.options.getString("messageid"); - const giveaways = await getGuildGiveaways(interaction.client, interaction.guildId); - const giveaway = giveaways.find(g => g.messageId === messageId); + if (!messageId || !/^\d+$/.test(messageId)) { + throw new TitanBotError( + 'Invalid message ID format', + ErrorTypes.VALIDATION, + 'Please provide a valid message ID.', + { providedId: messageId } + ); + } - if (!giveaway) { - throw new TitanBotError( - `Giveaway not found: ${messageId}`, - ErrorTypes.VALIDATION, - "No giveaway was found with that message ID in the database.", - { messageId, guildId: interaction.guildId } - ); - } + const giveaways = await getGuildGiveaways(interaction.client, interaction.guildId); + const giveaway = giveaways.find(g => g.messageId === messageId); - const endResult = await endGiveawayService( - interaction.client, - giveaway, - interaction.guildId, - interaction.user.id + if (!giveaway) { + throw new TitanBotError( + `Giveaway not found: ${messageId}`, + ErrorTypes.VALIDATION, + "No giveaway was found with that message ID in the database.", + { messageId, guildId: interaction.guildId } ); + } - const updatedGiveaway = endResult.giveaway; - const winners = endResult.winners; + const endResult = await endGiveawayService( + interaction.client, + giveaway, + interaction.guildId, + interaction.user.id + ); + + const updatedGiveaway = endResult.giveaway; + const winners = endResult.winners; + + const channel = await interaction.client.channels.fetch( + updatedGiveaway.channelId, + ).catch(err => { + logger.warn(`Could not fetch channel ${updatedGiveaway.channelId}:`, err.message); + return null; + }); + + if (!channel || !channel.isTextBased()) { + throw new TitanBotError( + `Channel not found: ${updatedGiveaway.channelId}`, + ErrorTypes.VALIDATION, + "Could not find the channel where the giveaway was hosted. The giveaway state has been updated.", + { channelId: updatedGiveaway.channelId, messageId } + ); + } - const channel = await interaction.client.channels.fetch( - updatedGiveaway.channelId, - ).catch(err => { - logger.warn(`Could not fetch channel ${updatedGiveaway.channelId}:`, err.message); + const message = await channel.messages + .fetch(messageId) + .catch(err => { + logger.warn(`Could not fetch message ${messageId}:`, err.message); return null; }); - if (!channel || !channel.isTextBased()) { - throw new TitanBotError( - `Channel not found: ${updatedGiveaway.channelId}`, - ErrorTypes.VALIDATION, - "Could not find the channel where the giveaway was hosted. The giveaway state has been updated.", - { channelId: updatedGiveaway.channelId, messageId } - ); - } - - const message = await channel.messages - .fetch(messageId) - .catch(err => { - logger.warn(`Could not fetch message ${messageId}:`, err.message); - return null; - }); - - if (!message) { - throw new TitanBotError( - `Message not found: ${messageId}`, - ErrorTypes.VALIDATION, - "Could not find the giveaway message. The giveaway state has been updated.", - { messageId, channelId: updatedGiveaway.channelId } - ); - } - - await saveGiveaway( - interaction.client, - interaction.guildId, - updatedGiveaway, + if (!message) { + throw new TitanBotError( + `Message not found: ${messageId}`, + ErrorTypes.VALIDATION, + "Could not find the giveaway message. The giveaway state has been updated.", + { messageId, channelId: updatedGiveaway.channelId } ); + } - const newEmbed = createGiveawayEmbed(updatedGiveaway, "ended", winners); - const newRow = createGiveawayButtons(true); - - await message.edit({ - content: "🎉 **GIVEAWAY ENDED** 🎉", - embeds: [newEmbed], - components: [newRow], + await saveGiveaway( + interaction.client, + interaction.guildId, + updatedGiveaway, + ); + + const newEmbed = createGiveawayEmbed(updatedGiveaway, "ended", winners); + const newRow = createGiveawayButtons(true); + + await message.edit({ + content: "🎉 **GIVEAWAY ENDED** 🎉", + embeds: [newEmbed], + components: [newRow], + }); + + if (winners.length > 0) { + const winnerMentions = winners + .map((id) => `<@${id}>`) + .join(","); + const winnerPingMsg = await channel.send({ + content: `🎉 CONGRATULATIONS ${winnerMentions}! You won the **${updatedGiveaway.prize}** giveaway! Please contact the host <@${updatedGiveaway.hostId}> to claim your prize.`, }); - - if (winners.length > 0) { - const winnerMentions = winners - .map((id) => `<@${id}>`) - .join(","); - const winnerPingMsg = await channel.send({ - content: `🎉 CONGRATULATIONS ${winnerMentions}! You won the **${updatedGiveaway.prize}** giveaway! Please contact the host <@${updatedGiveaway.hostId}> to claim your prize.`, - }); - updatedGiveaway.winnerPingMessageId = winnerPingMsg.id; - await saveGiveaway(interaction.client, interaction.guildId, updatedGiveaway); - - logger.info(`Giveaway ended with ${winners.length} winner(s): ${messageId}`); - - try { - await logEvent({ - client: interaction.client, - guildId: interaction.guildId, - eventType: EVENT_TYPES.GIVEAWAY_WINNER, - data: { - description: `Giveaway ended with ${winners.length} winner(s)`, - channelId: channel.id, - userId: interaction.user.id, - fields: [ - { - name: 'Prize', - value: updatedGiveaway.prize || 'Mystery Prize!', - inline: true - }, - { - name: 'Winners', - value: winnerMentions, - inline: false - }, - { - name: 'Entries', - value: endResult.participantCount.toString(), - inline: true - } - ] - } - }); - } catch (logError) { - logger.debug('Error logging giveaway winner event:', logError); - } - } else { - await channel.send({ - content: `The giveaway for **${updatedGiveaway.prize}** has ended with no valid entries.`, + updatedGiveaway.winnerPingMessageId = winnerPingMsg.id; + await saveGiveaway(interaction.client, interaction.guildId, updatedGiveaway); + + logger.info(`Giveaway ended with ${winners.length} winner(s): ${messageId}`); + + try { + await logEvent({ + client: interaction.client, + guildId: interaction.guildId, + eventType: EVENT_TYPES.GIVEAWAY_WINNER, + data: { + description: `Giveaway ended with ${winners.length} winner(s)`, + channelId: channel.id, + userId: interaction.user.id, + fields: [ + { + name: 'Prize', + value: updatedGiveaway.prize || 'Mystery Prize!', + inline: true + }, + { + name: 'Winners', + value: winnerMentions, + inline: false + }, + { + name: 'Entries', + value: endResult.participantCount.toString(), + inline: true + } + ] + } }); - logger.info(`Giveaway ended with no winners: ${messageId}`); + } catch (logError) { + logger.debug('Error logging giveaway winner event:', logError); } - - logger.info(`Giveaway successfully ended by ${interaction.user.tag}: ${messageId}`); - - return InteractionHelper.safeReply(interaction, { - embeds: [ - successEmbed( - "Giveaway Ended ✅", - `Successfully ended the giveaway for **${updatedGiveaway.prize}** in ${channel}. Selected ${winners.length} winner(s) from ${endResult.participantCount} entries.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'gend', - context: 'giveaway_end' + } else { + await channel.send({ + content: `The giveaway for **${updatedGiveaway.prize}** has ended with no valid entries.`, }); + logger.info(`Giveaway ended with no winners: ${messageId}`); } + + logger.info(`Giveaway successfully ended by ${interaction.user.tag}: ${messageId}`); + + return InteractionHelper.safeReply(interaction, { + embeds: [ + successEmbed( + "Giveaway Ended ✅", + `Successfully ended the giveaway for **${updatedGiveaway.prize}** in ${channel}. Selected ${winners.length} winner(s) from ${endResult.participantCount} entries.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); }, }; \ No newline at end of file diff --git a/src/commands/Giveaway/greroll.js b/src/commands/Giveaway/greroll.js index 92ff4267aa..64be4b3e03 100644 --- a/src/commands/Giveaway/greroll.js +++ b/src/commands/Giveaway/greroll.js @@ -1,7 +1,7 @@ import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; import { errorEmbed, successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; -import { TitanBotError, ErrorTypes, handleInteractionError } from '../../utils/errorHandler.js'; +import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { getGuildGiveaways, saveGiveaway } from '../../utils/giveaways.js'; import { selectWinners, @@ -24,192 +24,93 @@ export default { .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild), async execute(interaction) { - try { - - if (!interaction.inGuild()) { - throw new TitanBotError( - 'Giveaway command used outside guild', - ErrorTypes.VALIDATION, - 'This command can only be used in a server.', - { userId: interaction.user.id } - ); - } - - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { - throw new TitanBotError( - 'User lacks ManageGuild permission', - ErrorTypes.PERMISSION, - "You need the 'Manage Server' permission to reroll a giveaway.", - { userId: interaction.user.id, guildId: interaction.guildId } - ); - } + if (!interaction.inGuild()) { + throw new TitanBotError( + 'Giveaway command used outside guild', + ErrorTypes.VALIDATION, + 'This command can only be used in a server.', + { userId: interaction.user.id } + ); + } - logger.info(`Giveaway reroll initiated by ${interaction.user.tag} in guild ${interaction.guildId}`); + if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { + throw new TitanBotError( + 'User lacks ManageGuild permission', + ErrorTypes.PERMISSION, + "You need the 'Manage Server' permission to reroll a giveaway.", + { userId: interaction.user.id, guildId: interaction.guildId } + ); + } - const messageId = interaction.options.getString("messageid"); + logger.info(`Giveaway reroll initiated by ${interaction.user.tag} in guild ${interaction.guildId}`); - if (!messageId || !/^\d+$/.test(messageId)) { - throw new TitanBotError( - 'Invalid message ID format', - ErrorTypes.VALIDATION, - 'Please provide a valid message ID.', - { providedId: messageId } - ); - } + const messageId = interaction.options.getString("messageid"); - const giveaways = await getGuildGiveaways( - interaction.client, - interaction.guildId, + if (!messageId || !/^\d+$/.test(messageId)) { + throw new TitanBotError( + 'Invalid message ID format', + ErrorTypes.VALIDATION, + 'Please provide a valid message ID.', + { providedId: messageId } ); + } - const giveaway = giveaways.find(g => g.messageId === messageId); - - if (!giveaway) { - throw new TitanBotError( - `Giveaway not found: ${messageId}`, - ErrorTypes.VALIDATION, - "No giveaway was found with that message ID in the database.", - { messageId, guildId: interaction.guildId } - ); - } + const giveaways = await getGuildGiveaways( + interaction.client, + interaction.guildId, + ); - if (!giveaway.isEnded && !giveaway.ended) { - throw new TitanBotError( - `Giveaway still active: ${messageId}`, - ErrorTypes.VALIDATION, - "This giveaway is still active. Please use `/gend` to end it first.", - { messageId, status: 'active' } - ); - } + const giveaway = giveaways.find(g => g.messageId === messageId); - const participants = giveaway.participants || []; - - if (participants.length < giveaway.winnerCount) { - throw new TitanBotError( - `Insufficient participants for reroll: ${participants.length} < ${giveaway.winnerCount}`, - ErrorTypes.VALIDATION, - "Not enough entries to pick the required number of winners.", - { participantsCount: participants.length, winnersNeeded: giveaway.winnerCount } - ); - } + if (!giveaway) { + throw new TitanBotError( + `Giveaway not found: ${messageId}`, + ErrorTypes.VALIDATION, + "No giveaway was found with that message ID in the database.", + { messageId, guildId: interaction.guildId } + ); + } - const newWinners = selectWinners( - participants, - giveaway.winnerCount, + if (!giveaway.isEnded && !giveaway.ended) { + throw new TitanBotError( + `Giveaway still active: ${messageId}`, + ErrorTypes.VALIDATION, + "This giveaway is still active. Please use `/gend` to end it first.", + { messageId, status: 'active' } ); + } - const updatedGiveaway = { - ...giveaway, - winnerIds: newWinners, - rerolledAt: new Date().toISOString(), - rerolledBy: interaction.user.id - }; - - const channel = await interaction.client.channels.fetch( - giveaway.channelId, - ).catch(err => { - logger.warn(`Could not fetch channel ${giveaway.channelId}:`, err.message); - return null; - }); + const participants = giveaway.participants || []; - if (!channel || !channel.isTextBased()) { - - await saveGiveaway( - interaction.client, - interaction.guildId, - updatedGiveaway, - ); - - logger.warn(`Could not find channel for giveaway ${messageId}, but saved new winners to database`); - - return InteractionHelper.safeReply(interaction, { - embeds: [ - successEmbed( - "Reroll Complete", - "The new winners have been selected and saved to the database. Could not find channel to announce.", - ), - ], - flags: MessageFlags.Ephemeral, - }); - } + if (participants.length < giveaway.winnerCount) { + throw new TitanBotError( + `Insufficient participants for reroll: ${participants.length} < ${giveaway.winnerCount}`, + ErrorTypes.VALIDATION, + "Not enough entries to pick the required number of winners.", + { participantsCount: participants.length, winnersNeeded: giveaway.winnerCount } + ); + } - const message = await channel.messages - .fetch(messageId) - .catch(err => { - logger.warn(`Could not fetch message ${messageId}:`, err.message); - return null; - }); + const newWinners = selectWinners( + participants, + giveaway.winnerCount, + ); - if (!message) { - - await saveGiveaway( - interaction.client, - interaction.guildId, - updatedGiveaway, - ); - - const winnerMentions = newWinners - .map((id) => `<@${id}>`) - .join(","); - - const existingPingMsg = giveaway.winnerPingMessageId - ? await channel.messages.fetch(giveaway.winnerPingMessageId).catch(() => null) - : null; - if (existingPingMsg) { - await existingPingMsg.edit({ - content: `🔄 **GIVEAWAY REROLL** 🔄 New winners for **${giveaway.prize}**: ${winnerMentions}!`, - }); - } else { - const newPingMsg = await channel.send({ - content: `🔄 **GIVEAWAY REROLL** 🔄 New winners for **${giveaway.prize}**: ${winnerMentions}!`, - }); - updatedGiveaway.winnerPingMessageId = newPingMsg.id; - } + const updatedGiveaway = { + ...giveaway, + winnerIds: newWinners, + rerolledAt: new Date().toISOString(), + rerolledBy: interaction.user.id + }; - logger.info(`Giveaway rerolled (message not found, but announced): ${messageId}`); - - try { - await logEvent({ - client: interaction.client, - guildId: interaction.guildId, - eventType: EVENT_TYPES.GIVEAWAY_REROLL, - data: { - description: `Giveaway rerolled: ${giveaway.prize}`, - channelId: giveaway.channelId, - userId: interaction.user.id, - fields: [ - { - name: 'Prize', - value: giveaway.prize || 'Mystery Prize!', - inline: true - }, - { - name: 'New Winners', - value: winnerMentions, - inline: false - }, - { - name: 'Total Entries', - value: participants.length.toString(), - inline: true - } - ] - } - }); - } catch (logError) { - logger.debug('Error logging giveaway reroll:', logError); - } + const channel = await interaction.client.channels.fetch( + giveaway.channelId, + ).catch(err => { + logger.warn(`Could not fetch channel ${giveaway.channelId}:`, err.message); + return null; + }); - return InteractionHelper.safeReply(interaction, { - embeds: [ - successEmbed( - "Reroll Complete", - `The new winners have been announced in ${channel}. (Original message not found).`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - } + if (!channel || !channel.isTextBased()) { await saveGiveaway( interaction.client, @@ -217,15 +118,34 @@ export default { updatedGiveaway, ); - const newEmbed = createGiveawayEmbed(updatedGiveaway, "reroll", newWinners); - const newRow = createGiveawayButtons(true); + logger.warn(`Could not find channel for giveaway ${messageId}, but saved new winners to database`); - await message.edit({ - content: "🔄 **GIVEAWAY REROLLED** 🔄", - embeds: [newEmbed], - components: [newRow], + return InteractionHelper.safeReply(interaction, { + embeds: [ + successEmbed( + "Reroll Complete", + "The new winners have been selected and saved to the database. Could not find channel to announce.", + ), + ], + flags: MessageFlags.Ephemeral, + }); + } + + const message = await channel.messages + .fetch(messageId) + .catch(err => { + logger.warn(`Could not fetch message ${messageId}:`, err.message); + return null; }); + if (!message) { + + await saveGiveaway( + interaction.client, + interaction.guildId, + updatedGiveaway, + ); + const winnerMentions = newWinners .map((id) => `<@${id}>`) .join(","); @@ -235,16 +155,16 @@ export default { : null; if (existingPingMsg) { await existingPingMsg.edit({ - content: `🔄 **REROLL WINNERS** 🔄 CONGRATULATIONS ${winnerMentions}! You are the new winner(s) for the **${giveaway.prize}** giveaway! Please contact the host <@${giveaway.hostId}> to claim your prize.`, + content: `🔄 **GIVEAWAY REROLL** 🔄 New winners for **${giveaway.prize}**: ${winnerMentions}!`, }); } else { const newPingMsg = await channel.send({ - content: `🔄 **REROLL WINNERS** 🔄 CONGRATULATIONS ${winnerMentions}! You are the new winner(s) for the **${giveaway.prize}** giveaway! Please contact the host <@${giveaway.hostId}> to claim your prize.`, + content: `🔄 **GIVEAWAY REROLL** 🔄 New winners for **${giveaway.prize}**: ${winnerMentions}!`, }); updatedGiveaway.winnerPingMessageId = newPingMsg.id; } - logger.info(`Giveaway successfully rerolled: ${messageId} with ${newWinners.length} new winners`); + logger.info(`Giveaway rerolled (message not found, but announced): ${messageId}`); try { await logEvent({ @@ -275,26 +195,95 @@ export default { } }); } catch (logError) { - logger.debug('Error logging giveaway reroll event:', logError); + logger.debug('Error logging giveaway reroll:', logError); } return InteractionHelper.safeReply(interaction, { embeds: [ successEmbed( - "Reroll Successful ✅", - `Successfully rerolled the giveaway for **${giveaway.prize}** in ${channel}. Selected ${newWinners.length} new winner(s).`, + "Reroll Complete", + `The new winners have been announced in ${channel}. (Original message not found).`, ), ], flags: MessageFlags.Ephemeral, }); + } + + await saveGiveaway( + interaction.client, + interaction.guildId, + updatedGiveaway, + ); + + const newEmbed = createGiveawayEmbed(updatedGiveaway, "reroll", newWinners); + const newRow = createGiveawayButtons(true); - } catch (error) { - logger.error('Error in greroll command:', error); - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'greroll', - context: 'giveaway_reroll' + await message.edit({ + content: "🔄 **GIVEAWAY REROLLED** 🔄", + embeds: [newEmbed], + components: [newRow], + }); + + const winnerMentions = newWinners + .map((id) => `<@${id}>`) + .join(","); + + const existingPingMsg = giveaway.winnerPingMessageId + ? await channel.messages.fetch(giveaway.winnerPingMessageId).catch(() => null) + : null; + if (existingPingMsg) { + await existingPingMsg.edit({ + content: `🔄 **REROLL WINNERS** 🔄 CONGRATULATIONS ${winnerMentions}! You are the new winner(s) for the **${giveaway.prize}** giveaway! Please contact the host <@${giveaway.hostId}> to claim your prize.`, }); + } else { + const newPingMsg = await channel.send({ + content: `🔄 **REROLL WINNERS** 🔄 CONGRATULATIONS ${winnerMentions}! You are the new winner(s) for the **${giveaway.prize}** giveaway! Please contact the host <@${giveaway.hostId}> to claim your prize.`, + }); + updatedGiveaway.winnerPingMessageId = newPingMsg.id; } + + logger.info(`Giveaway successfully rerolled: ${messageId} with ${newWinners.length} new winners`); + + try { + await logEvent({ + client: interaction.client, + guildId: interaction.guildId, + eventType: EVENT_TYPES.GIVEAWAY_REROLL, + data: { + description: `Giveaway rerolled: ${giveaway.prize}`, + channelId: giveaway.channelId, + userId: interaction.user.id, + fields: [ + { + name: 'Prize', + value: giveaway.prize || 'Mystery Prize!', + inline: true + }, + { + name: 'New Winners', + value: winnerMentions, + inline: false + }, + { + name: 'Total Entries', + value: participants.length.toString(), + inline: true + } + ] + } + }); + } catch (logError) { + logger.debug('Error logging giveaway reroll event:', logError); + } + + return InteractionHelper.safeReply(interaction, { + embeds: [ + successEmbed( + "Reroll Successful ✅", + `Successfully rerolled the giveaway for **${giveaway.prize}** in ${channel}. Selected ${newWinners.length} new winner(s).`, + ), + ], + flags: MessageFlags.Ephemeral, + }); }, }; \ No newline at end of file diff --git a/src/commands/JoinToCreate/jointocreate.js b/src/commands/JoinToCreate/jointocreate.js index 0611fcf613..593212f856 100644 --- a/src/commands/JoinToCreate/jointocreate.js +++ b/src/commands/JoinToCreate/jointocreate.js @@ -452,7 +452,7 @@ async function handleNameTemplateModal(interaction, triggerChannel, currentConfi async function handleUserLimitModal(interaction, triggerChannel, currentConfig, client) { try { - const currentLimit = currentConfig.channelConfig.userLimit ?? currentConfig.userLimit ?? 0; + const currentLimit = currentConfig.channelConfig?.userLimit ?? currentConfig.userLimit ?? 0; const modal = new ModalBuilder() .setCustomId(`jtc_limit_modal_${triggerChannel.id}`) diff --git a/src/commands/JoinToCreate/modules/setup.js b/src/commands/JoinToCreate/modules/setup.js index 5c1d210371..00ebd31053 100644 --- a/src/commands/JoinToCreate/modules/setup.js +++ b/src/commands/JoinToCreate/modules/setup.js @@ -36,14 +36,14 @@ export default { }); const embed = successEmbed( + '✅ Join to Create Setup Complete', `Created trigger channel: ${triggerChannel}\n\n` + `**Settings:**\n` + `• Temporary Channel Name Template: \`${nameTemplate}\`\n` + `• User Limit: ${userLimit === 0 ? 'No limit' : userLimit + ' users'}\n` + `• Bitrate: ${bitrate} kbps\n` + `${category ?`• Category: ${category.name}`: '• Category: None (root level)'}\n\n` + - `When users join this channel, a temporary voice channel will be created for them.`, - '✅ Join to Create Setup Complete' + `When users join this channel, a temporary voice channel will be created for them.` ); try { diff --git a/src/commands/Leveling/leaderboard.js b/src/commands/Leveling/leaderboard.js index 029f330b89..52169ae6cb 100644 --- a/src/commands/Leveling/leaderboard.js +++ b/src/commands/Leveling/leaderboard.js @@ -1,7 +1,7 @@ import { SlashCommandBuilder, EmbedBuilder, MessageFlags } from 'discord.js'; import { logger } from '../../utils/logger.js'; -import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { getLeaderboard, getLevelingConfig, getXpForLevel } from '../../services/leveling.js'; +import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { getLeaderboard, getLevelingConfig, getXpForLevel } from '../../services/leveling/leveling.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { @@ -12,72 +12,64 @@ export default { category: 'Leveling', async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction); + await InteractionHelper.safeDefer(interaction); - const levelingConfig = await getLevelingConfig(client, interaction.guildId); + const levelingConfig = await getLevelingConfig(client, interaction.guildId); - if (!levelingConfig?.enabled) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - new EmbedBuilder() - .setColor('#f1c40f') - .setDescription('The leveling system is currently disabled on this server.') - ], - flags: MessageFlags.Ephemeral - }); - return; - } + if (!levelingConfig?.enabled) { + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + new EmbedBuilder() + .setColor('#f1c40f') + .setDescription('The leveling system is currently disabled on this server.') + ], + flags: MessageFlags.Ephemeral + }); + return; + } - const leaderboard = await getLeaderboard(client, interaction.guildId, 10); + const leaderboard = await getLeaderboard(client, interaction.guildId, 10); - if (leaderboard.length === 0) { - throw new TitanBotError( - 'No leaderboard data found', - ErrorTypes.DATABASE, - 'No level data found yet. Start chatting to gain XP!' - ); - } + if (leaderboard.length === 0) { + throw new TitanBotError( + 'No leaderboard data found', + ErrorTypes.DATABASE, + 'No level data found yet. Start chatting to gain XP!' + ); + } - const embed = new EmbedBuilder() - .setTitle('Level Leaderboard') - .setColor('#2ecc71') - .setDescription("Top 10 most active members in this server:") - .setTimestamp(); + const embed = new EmbedBuilder() + .setTitle('Level Leaderboard') + .setColor('#2ecc71') + .setDescription("Top 10 most active members in this server:") + .setTimestamp(); - const leaderboardText = await Promise.all( - leaderboard.map(async (user, index) => { - try { - const member = await interaction.guild.members.fetch(user.userId).catch(() => null); - const userMention = member?.user.toString() || `<@${user.userId}>`; - const xpForNextLevel = getXpForLevel(user.level + 1); + const leaderboardText = await Promise.all( + leaderboard.map(async (user, index) => { + try { + const member = await interaction.guild.members.fetch(user.userId).catch(() => null); + const userMention = member?.user.toString() || `<@${user.userId}>`; + const xpForNextLevel = getXpForLevel(user.level + 1); - let rankPrefix = `${index + 1}.`; - if (index === 0) rankPrefix = '🥇'; - else if (index === 1) rankPrefix = '🥈'; - else if (index === 2) rankPrefix = '🥉'; - else rankPrefix = `**${index + 1}.**`; + let rankPrefix = `${index + 1}.`; + if (index === 0) rankPrefix = '🥇'; + else if (index === 1) rankPrefix = '🥈'; + else if (index === 2) rankPrefix = '🥉'; + else rankPrefix = `**${index + 1}.**`; - return `${rankPrefix} ${userMention} - Level ${user.level} (${user.xp}/${xpForNextLevel} XP)`; - } catch { - return `**${index + 1}.** Error loading user ${user.userId}`; - } - }) - ); + return `${rankPrefix} ${userMention} - Level ${user.level} (${user.xp}/${xpForNextLevel} XP)`; + } catch { + return `**${index + 1}.** Error loading user ${user.userId}`; + } + }) + ); - embed.addFields({ - name: 'Rankings', - value: leaderboardText.join('\n') - }); + embed.addFields({ + name: 'Rankings', + value: leaderboardText.join('\n') + }); - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.debug(`Leaderboard displayed for guild ${interaction.guildId}`); - } catch (error) { - logger.error('Leaderboard command error:', error); - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'leaderboard' - }); - } + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + logger.debug(`Leaderboard displayed for guild ${interaction.guildId}`); } }; \ No newline at end of file diff --git a/src/commands/Leveling/level.js b/src/commands/Leveling/level.js index 9dadc83c55..ecfb5620e3 100644 --- a/src/commands/Leveling/level.js +++ b/src/commands/Leveling/level.js @@ -1,9 +1,9 @@ import { getColor } from '../../config/bot.js'; import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, MessageFlags } from 'discord.js'; import { createEmbed } from '../../utils/embeds.js'; -import { getLevelingConfig, saveLevelingConfig } from '../../services/leveling.js'; +import { getLevelingConfig, saveLevelingConfig } from '../../services/leveling/leveling.js'; import { botHasPermission } from '../../utils/permissionGuard.js'; -import { TitanBotError, ErrorTypes, handleInteractionError } from '../../utils/errorHandler.js'; +import { TitanBotError, ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { logger } from '../../utils/logger.js'; import levelDashboard from './modules/level_dashboard.js'; @@ -67,91 +67,83 @@ export default { category: 'Leveling', async execute(interaction, config, client) { - try { - const deferred = await InteractionHelper.safeDefer(interaction, { - flags: MessageFlags.Ephemeral, - }); - if (!deferred) return; + const deferred = await InteractionHelper.safeDefer(interaction, { + flags: MessageFlags.Ephemeral, + }); + if (!deferred) return; - if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the **Manage Server** permission to use this command.' }); - } + if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the **Manage Server** permission to use this command.' }); + } - const subcommand = interaction.options.getSubcommand(); + const subcommand = interaction.options.getSubcommand(); - if (subcommand === 'dashboard') { - return levelDashboard.execute(interaction, config, client); - } + if (subcommand === 'dashboard') { + return levelDashboard.execute(interaction, config, client); + } - if (subcommand === 'setup') { - const channel = interaction.options.getChannel('channel'); - const xpMin = interaction.options.getInteger('xp_min') ?? 15; - const xpMax = interaction.options.getInteger('xp_max') ?? 25; - const message = - interaction.options.getString('message') ?? - '{user} has leveled up to level {level}!'; - const xpCooldown = interaction.options.getInteger('xp_cooldown') ?? 60; + if (subcommand === 'setup') { + const channel = interaction.options.getChannel('channel'); + const xpMin = interaction.options.getInteger('xp_min') ?? 15; + const xpMax = interaction.options.getInteger('xp_max') ?? 25; + const message = + interaction.options.getString('message') ?? + '{user} has leveled up to level {level}!'; + const xpCooldown = interaction.options.getInteger('xp_cooldown') ?? 60; - if (xpMin > xpMax) { - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Minimum XP (**${xpMin}**) cannot be greater than maximum XP (**${xpMax}**).' }); - } + if (xpMin > xpMax) { + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: `Minimum XP (**${xpMin}**) cannot be greater than maximum XP (**${xpMax}**).` }); + } - if (!botHasPermission(channel, ['SendMessages', 'EmbedLinks'])) { - throw new TitanBotError( - 'Bot missing permissions in the specified channel', - ErrorTypes.PERMISSION, - `I need **SendMessages** and **EmbedLinks** permissions in ${channel} to send level-up notifications.`, - ); - } + if (!botHasPermission(channel, ['SendMessages', 'EmbedLinks'])) { + throw new TitanBotError( + 'Bot missing permissions in the specified channel', + ErrorTypes.PERMISSION, + `I need **SendMessages** and **EmbedLinks** permissions in ${channel} to send level-up notifications.`, + ); + } - const existingConfig = await getLevelingConfig(client, interaction.guildId); + const existingConfig = await getLevelingConfig(client, interaction.guildId); - if (existingConfig.configured) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'The leveling system is already set up on this server (level-up notifications go to <#${existingConfig.levelUpChannel}>).\n\nUse \\`/level dashboard\\` to adjust any settings.' }); - } + if (existingConfig.configured) { + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `The leveling system is already set up on this server (level-up notifications go to <#${existingConfig.levelUpChannel}>).\n\nUse \`/level dashboard\` to adjust any settings.` }); + } - const newConfig = { - ...existingConfig, - configured: true, - enabled: true, - levelUpChannel: channel.id, - xpRange: { min: xpMin, max: xpMax }, - xpCooldown: xpCooldown, - levelUpMessage: message, - announceLevelUp: true, - }; + const newConfig = { + ...existingConfig, + configured: true, + enabled: true, + levelUpChannel: channel.id, + xpRange: { min: xpMin, max: xpMax }, + xpCooldown: xpCooldown, + levelUpMessage: message, + announceLevelUp: true, + }; - await saveLevelingConfig(client, interaction.guildId, newConfig); + await saveLevelingConfig(client, interaction.guildId, newConfig); - logger.info(`Leveling system set up in guild ${interaction.guildId}`, { - channelId: channel.id, - xpMin, - xpMax, - xpCooldown, - userId: interaction.user.id, - }); + logger.info(`Leveling system set up in guild ${interaction.guildId}`, { + channelId: channel.id, + xpMin, + xpMax, + xpCooldown, + userId: interaction.user.id, + }); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - createEmbed({ - title: 'Leveling System Set Up', - description: - `The leveling system is now **enabled** and ready to go.\n\n` + - `**Level-up Channel:** ${channel}\n` + - `**XP per Message:** ${xpMin} – ${xpMax}\n` + - `**XP Cooldown:** ${xpCooldown}s\n` + - `**Level-up Message:** \`${message}\`\n\n` + - `Use \`/level dashboard\` to adjust any of these settings at any time.`, - color: 'success', - }), - ], - }); - } - } catch (error) { - logger.error('Level command error:', error); - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'level', + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + createEmbed({ + title: 'Leveling System Set Up', + description: + `The leveling system is now **enabled** and ready to go.\n\n` + + `**Level-up Channel:** ${channel}\n` + + `**XP per Message:** ${xpMin} – ${xpMax}\n` + + `**XP Cooldown:** ${xpCooldown}s\n` + + `**Level-up Message:** \`${message}\`\n\n` + + `Use \`/level dashboard\` to adjust any of these settings at any time.`, + color: 'success', + }), + ], }); } }, diff --git a/src/commands/Leveling/leveladd.js b/src/commands/Leveling/leveladd.js index 0c6fd25eab..9d439696b7 100644 --- a/src/commands/Leveling/leveladd.js +++ b/src/commands/Leveling/leveladd.js @@ -1,8 +1,8 @@ import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, MessageFlags } from 'discord.js'; import { logger } from '../../utils/logger.js'; -import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { checkUserPermissions } from '../../utils/permissionGuard.js'; -import { addLevels, getLevelingConfig } from '../../services/leveling.js'; +import { addLevels, getLevelingConfig } from '../../services/leveling/leveling.js'; import { createEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; @@ -28,62 +28,54 @@ export default { category: 'Leveling', async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction); + await InteractionHelper.safeDefer(interaction); - const hasPermission = await checkUserPermissions( - interaction, - PermissionFlagsBits.ManageGuild, - 'You need ManageGuild permission to use this command.' - ); - if (!hasPermission) return; - - const levelingConfig = await getLevelingConfig(client, interaction.guildId); - if (!levelingConfig?.enabled) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - new EmbedBuilder() - .setColor('#f1c40f') - .setDescription('The leveling system is currently disabled on this server.') - ], - flags: MessageFlags.Ephemeral - }); - return; - } - - const targetUser = interaction.options.getUser('user'); - const levelsToAdd = interaction.options.getInteger('levels'); - - const member = await interaction.guild.members.fetch(targetUser.id).catch(() => null); - if (!member) { - throw new TitanBotError( - `User ${targetUser.id} not found in this guild`, - ErrorTypes.USER_INPUT, - 'The specified user is not in this server.' - ); - } - - const userData = await addLevels(client, interaction.guildId, targetUser.id, levelsToAdd); + const hasPermission = await checkUserPermissions( + interaction, + PermissionFlagsBits.ManageGuild, + 'You need ManageGuild permission to use this command.' + ); + if (!hasPermission) return; + const levelingConfig = await getLevelingConfig(client, interaction.guildId); + if (!levelingConfig?.enabled) { await InteractionHelper.safeEditReply(interaction, { embeds: [ - createEmbed({ - title: 'Levels Added', - description: `Successfully added ${levelsToAdd} levels to ${targetUser.tag}.\n**New Level:** ${userData.level}`, - color: 'success' - }) - ] + new EmbedBuilder() + .setColor('#f1c40f') + .setDescription('The leveling system is currently disabled on this server.') + ], + flags: MessageFlags.Ephemeral }); + return; + } - logger.info( - `[ADMIN] User ${interaction.user.tag} added ${levelsToAdd} levels to ${targetUser.tag} in guild ${interaction.guildId}` + const targetUser = interaction.options.getUser('user'); + const levelsToAdd = interaction.options.getInteger('levels'); + + const member = await interaction.guild.members.fetch(targetUser.id).catch(() => null); + if (!member) { + throw new TitanBotError( + `User ${targetUser.id} not found in this guild`, + ErrorTypes.USER_INPUT, + 'The specified user is not in this server.' ); - } catch (error) { - logger.error('LevelAdd command error:', error); - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'leveladd' - }); } + + const userData = await addLevels(client, interaction.guildId, targetUser.id, levelsToAdd); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + createEmbed({ + title: 'Levels Added', + description: `Successfully added ${levelsToAdd} levels to ${targetUser.tag}.\n**New Level:** ${userData.level}`, + color: 'success' + }) + ] + }); + + logger.info( + `[ADMIN] User ${interaction.user.tag} added ${levelsToAdd} levels to ${targetUser.tag} in guild ${interaction.guildId}` + ); } }; \ No newline at end of file diff --git a/src/commands/Leveling/levelremove.js b/src/commands/Leveling/levelremove.js index 79cf21b9cb..f781d33df8 100644 --- a/src/commands/Leveling/levelremove.js +++ b/src/commands/Leveling/levelremove.js @@ -1,8 +1,8 @@ import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, MessageFlags } from 'discord.js'; import { logger } from '../../utils/logger.js'; -import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { checkUserPermissions } from '../../utils/permissionGuard.js'; -import { removeLevels, getUserLevelData, getLevelingConfig } from '../../services/leveling.js'; +import { removeLevels, getUserLevelData, getLevelingConfig } from '../../services/leveling/leveling.js'; import { createEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; @@ -28,71 +28,63 @@ export default { category: 'Leveling', async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction); + await InteractionHelper.safeDefer(interaction); - const hasPermission = await checkUserPermissions( - interaction, - PermissionFlagsBits.ManageGuild, - 'You need ManageGuild permission to use this command.' - ); - if (!hasPermission) return; - - const levelingConfig = await getLevelingConfig(client, interaction.guildId); - if (!levelingConfig?.enabled) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - new EmbedBuilder() - .setColor('#f1c40f') - .setDescription('The leveling system is currently disabled on this server.') - ], - flags: MessageFlags.Ephemeral - }); - return; - } - - const targetUser = interaction.options.getUser('user'); - const levelsToRemove = interaction.options.getInteger('levels'); - - const member = await interaction.guild.members.fetch(targetUser.id).catch(() => null); - if (!member) { - throw new TitanBotError( - `User ${targetUser.id} not found in this guild`, - ErrorTypes.USER_INPUT, - 'The specified user is not in this server.' - ); - } - - const userData = await getUserLevelData(client, interaction.guildId, targetUser.id); - if (userData.level === 0) { - throw new TitanBotError( - `User ${targetUser.id} is already at minimum level`, - ErrorTypes.VALIDATION, - `${targetUser.tag} is already at level 0 and cannot have levels removed.` - ); - } - - const updatedData = await removeLevels(client, interaction.guildId, targetUser.id, levelsToRemove); + const hasPermission = await checkUserPermissions( + interaction, + PermissionFlagsBits.ManageGuild, + 'You need ManageGuild permission to use this command.' + ); + if (!hasPermission) return; + const levelingConfig = await getLevelingConfig(client, interaction.guildId); + if (!levelingConfig?.enabled) { await InteractionHelper.safeEditReply(interaction, { embeds: [ - createEmbed({ - title: 'Levels Removed', - description: `Successfully removed ${levelsToRemove} levels from ${targetUser.tag}.\n**New Level:** ${updatedData.level}`, - color: 'success' - }) - ] + new EmbedBuilder() + .setColor('#f1c40f') + .setDescription('The leveling system is currently disabled on this server.') + ], + flags: MessageFlags.Ephemeral }); + return; + } + + const targetUser = interaction.options.getUser('user'); + const levelsToRemove = interaction.options.getInteger('levels'); - logger.info( - `[ADMIN] User ${interaction.user.tag} removed ${levelsToRemove} levels from ${targetUser.tag} in guild ${interaction.guildId}` + const member = await interaction.guild.members.fetch(targetUser.id).catch(() => null); + if (!member) { + throw new TitanBotError( + `User ${targetUser.id} not found in this guild`, + ErrorTypes.USER_INPUT, + 'The specified user is not in this server.' + ); + } + + const userData = await getUserLevelData(client, interaction.guildId, targetUser.id); + if (userData.level === 0) { + throw new TitanBotError( + `User ${targetUser.id} is already at minimum level`, + ErrorTypes.VALIDATION, + `${targetUser.tag} is already at level 0 and cannot have levels removed.` ); - } catch (error) { - logger.error('LevelRemove command error:', error); - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'levelremove' - }); } + + const updatedData = await removeLevels(client, interaction.guildId, targetUser.id, levelsToRemove); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + createEmbed({ + title: 'Levels Removed', + description: `Successfully removed ${levelsToRemove} levels from ${targetUser.tag}.\n**New Level:** ${updatedData.level}`, + color: 'success' + }) + ] + }); + + logger.info( + `[ADMIN] User ${interaction.user.tag} removed ${levelsToRemove} levels from ${targetUser.tag} in guild ${interaction.guildId}` + ); } }; \ No newline at end of file diff --git a/src/commands/Leveling/levelset.js b/src/commands/Leveling/levelset.js index 8d37577555..4ce8589ac0 100644 --- a/src/commands/Leveling/levelset.js +++ b/src/commands/Leveling/levelset.js @@ -1,8 +1,8 @@ import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, MessageFlags } from 'discord.js'; import { logger } from '../../utils/logger.js'; -import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { checkUserPermissions } from '../../utils/permissionGuard.js'; -import { setUserLevel, getLevelingConfig } from '../../services/leveling.js'; +import { setUserLevel, getLevelingConfig } from '../../services/leveling/leveling.js'; import { createEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; @@ -28,62 +28,54 @@ export default { category: 'Leveling', async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction); + await InteractionHelper.safeDefer(interaction); - const hasPermission = await checkUserPermissions( - interaction, - PermissionFlagsBits.ManageGuild, - 'You need ManageGuild permission to use this command.' - ); - if (!hasPermission) return; - - const levelingConfig = await getLevelingConfig(client, interaction.guildId); - if (!levelingConfig?.enabled) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - new EmbedBuilder() - .setColor('#f1c40f') - .setDescription('The leveling system is currently disabled on this server.') - ], - flags: MessageFlags.Ephemeral - }); - return; - } - - const targetUser = interaction.options.getUser('user'); - const newLevel = interaction.options.getInteger('level'); - - const member = await interaction.guild.members.fetch(targetUser.id).catch(() => null); - if (!member) { - throw new TitanBotError( - `User ${targetUser.id} not found in this guild`, - ErrorTypes.USER_INPUT, - 'The specified user is not in this server.' - ); - } - - const userData = await setUserLevel(client, interaction.guildId, targetUser.id, newLevel); + const hasPermission = await checkUserPermissions( + interaction, + PermissionFlagsBits.ManageGuild, + 'You need ManageGuild permission to use this command.' + ); + if (!hasPermission) return; + const levelingConfig = await getLevelingConfig(client, interaction.guildId); + if (!levelingConfig?.enabled) { await InteractionHelper.safeEditReply(interaction, { embeds: [ - createEmbed({ - title: 'Level Set', - description: `Successfully set ${targetUser.tag}'s level to **${newLevel}**.\n**Total XP:** ${userData.totalXp}`, - color: 'success' - }) - ] + new EmbedBuilder() + .setColor('#f1c40f') + .setDescription('The leveling system is currently disabled on this server.') + ], + flags: MessageFlags.Ephemeral }); + return; + } - logger.info( - `[ADMIN] User ${interaction.user.tag} set ${targetUser.tag}'s level to ${newLevel} in guild ${interaction.guildId}` + const targetUser = interaction.options.getUser('user'); + const newLevel = interaction.options.getInteger('level'); + + const member = await interaction.guild.members.fetch(targetUser.id).catch(() => null); + if (!member) { + throw new TitanBotError( + `User ${targetUser.id} not found in this guild`, + ErrorTypes.USER_INPUT, + 'The specified user is not in this server.' ); - } catch (error) { - logger.error('LevelSet command error:', error); - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'levelset' - }); } + + const userData = await setUserLevel(client, interaction.guildId, targetUser.id, newLevel); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + createEmbed({ + title: 'Level Set', + description: `Successfully set ${targetUser.tag}'s level to **${newLevel}**.\n**Total XP:** ${userData.totalXp}`, + color: 'success' + }) + ] + }); + + logger.info( + `[ADMIN] User ${interaction.user.tag} set ${targetUser.tag}'s level to ${newLevel} in guild ${interaction.guildId}` + ); } }; \ No newline at end of file diff --git a/src/commands/Leveling/modules/level_dashboard.js b/src/commands/Leveling/modules/level_dashboard.js index d7598136cc..3d8ee34b61 100644 --- a/src/commands/Leveling/modules/level_dashboard.js +++ b/src/commands/Leveling/modules/level_dashboard.js @@ -20,7 +20,7 @@ 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 { getLevelingConfig, saveLevelingConfig } from '../../../services/leveling.js'; +import { getLevelingConfig, saveLevelingConfig } from '../../../services/leveling/leveling.js'; import { botHasPermission } from '../../../utils/permissionGuard.js'; import { startDashboardSession } from '../../../utils/dashboardSession.js'; @@ -357,7 +357,7 @@ async function handleRoleRewardRemove(selectInteraction, rootInteraction, cfg, g const level = parseInt(rawLevel, 10); if (isNaN(level) || !cfg.roleRewards?.[level]) { - await replyUserError(submitted, { type: ErrorTypes.USER_INPUT, message: 'No role reward is configured for level **${rawLevel}**.' }); + await replyUserError(submitted, { type: ErrorTypes.USER_INPUT, message: `No role reward is configured for level **${rawLevel}**.` }); return; } @@ -407,7 +407,7 @@ async function handleChannel(selectInteraction, rootInteraction, cfg, guildId, c const channel = selectInteraction.guild.channels.cache.get(channelId); if (channel && !botHasPermission(channel, ['SendMessages', 'EmbedLinks'])) { - await replyUserError(submitted, { type: ErrorTypes.PERMISSION, message: 'I need **SendMessages** and **EmbedLinks** permissions in ${channel} to send level-up notifications.' }); + await replyUserError(submitted, { type: ErrorTypes.PERMISSION, message: `I need **SendMessages** and **EmbedLinks** permissions in ${channel} to send level-up notifications.` }); return; } diff --git a/src/commands/Leveling/rank.js b/src/commands/Leveling/rank.js index 51ff02fe45..fea894105c 100644 --- a/src/commands/Leveling/rank.js +++ b/src/commands/Leveling/rank.js @@ -1,7 +1,7 @@ import { SlashCommandBuilder, EmbedBuilder, MessageFlags } from 'discord.js'; import { logger } from '../../utils/logger.js'; -import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { getUserLevelData, getLevelingConfig, getXpForLevel } from '../../services/leveling.js'; +import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { getUserLevelData, getLevelingConfig, getXpForLevel } from '../../services/leveling/leveling.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { @@ -18,83 +18,75 @@ export default { category: 'Leveling', async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction); + await InteractionHelper.safeDefer(interaction); - const levelingConfig = await getLevelingConfig(client, interaction.guildId); - if (!levelingConfig?.enabled) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - new EmbedBuilder() - .setColor('#f1c40f') - .setDescription('The leveling system is currently disabled on this server.') - ], - flags: MessageFlags.Ephemeral - }); - return; - } + const levelingConfig = await getLevelingConfig(client, interaction.guildId); + if (!levelingConfig?.enabled) { + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + new EmbedBuilder() + .setColor('#f1c40f') + .setDescription('The leveling system is currently disabled on this server.') + ], + flags: MessageFlags.Ephemeral + }); + return; + } - const targetUser = interaction.options.getUser('user') || interaction.user; - const member = await interaction.guild.members - .fetch(targetUser.id) - .catch(() => null); + const targetUser = interaction.options.getUser('user') || interaction.user; + const member = await interaction.guild.members + .fetch(targetUser.id) + .catch(() => null); - if (!member) { - throw new TitanBotError( - `User ${targetUser.id} not found in guild`, - ErrorTypes.USER_INPUT, - 'Could not find the specified user in this server.' - ); - } + if (!member) { + throw new TitanBotError( + `User ${targetUser.id} not found in guild`, + ErrorTypes.USER_INPUT, + 'Could not find the specified user in this server.' + ); + } - const userData = await getUserLevelData(client, interaction.guildId, targetUser.id); + const userData = await getUserLevelData(client, interaction.guildId, targetUser.id); - const safeUserData = { - level: userData?.level ?? 0, - xp: userData?.xp ?? 0, - totalXp: userData?.totalXp ?? 0 - }; + const safeUserData = { + level: userData?.level ?? 0, + xp: userData?.xp ?? 0, + totalXp: userData?.totalXp ?? 0 + }; - const xpNeeded = getXpForLevel(safeUserData.level + 1); - const progress = xpNeeded > 0 ? Math.floor((safeUserData.xp / xpNeeded) * 100) : 0; - const progressBar = createProgressBar(progress, 20); + const xpNeeded = getXpForLevel(safeUserData.level + 1); + const progress = xpNeeded > 0 ? Math.floor((safeUserData.xp / xpNeeded) * 100) : 0; + const progressBar = createProgressBar(progress, 20); - const embed = new EmbedBuilder() - .setTitle(`${member.displayName}'s Rank`) - .setThumbnail(member.displayAvatarURL({ dynamic: true })) - .addFields( - { - name: 'Level', - value: safeUserData.level.toString(), - inline: true - }, - { - name: 'XP', - value: `${safeUserData.xp}/${xpNeeded}`, - inline: true - }, - { - name: 'Total XP', - value: safeUserData.totalXp.toString(), - inline: true - }, - { - name: `Progress to Level ${safeUserData.level + 1}`, - value: `${progressBar} ${progress}%` - } - ) - .setColor('#2ecc71') - .setTimestamp(); + const embed = new EmbedBuilder() + .setTitle(`${member.displayName}'s Rank`) + .setThumbnail(member.displayAvatarURL({ dynamic: true })) + .addFields( + { + name: 'Level', + value: safeUserData.level.toString(), + inline: true + }, + { + name: 'XP', + value: `${safeUserData.xp}/${xpNeeded}`, + inline: true + }, + { + name: 'Total XP', + value: safeUserData.totalXp.toString(), + inline: true + }, + { + name: `Progress to Level ${safeUserData.level + 1}`, + value: `${progressBar} ${progress}%` + } + ) + .setColor('#2ecc71') + .setTimestamp(); - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.debug(`Rank checked for user ${targetUser.id} in guild ${interaction.guildId}`); - } catch (error) { - logger.error('Rank command error:', error); - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'rank' - }); - } + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + logger.debug(`Rank checked for user ${targetUser.id} in guild ${interaction.guildId}`); } }; diff --git a/src/commands/Logging/logging.js b/src/commands/Logging/logging.js index 303d0870dc..0d94386314 100644 --- a/src/commands/Logging/logging.js +++ b/src/commands/Logging/logging.js @@ -5,6 +5,7 @@ import { InteractionHelper } from '../../utils/interactionHelper.js'; import dashboard from './modules/logging_dashboard.js'; import channel from './modules/logging_channel.js'; +import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; export default { data: new SlashCommandBuilder() .setName('logging') diff --git a/src/commands/Logging/modules/logging_channel.js b/src/commands/Logging/modules/logging_channel.js index 77b286ff7c..f92e6a6d3b 100644 --- a/src/commands/Logging/modules/logging_channel.js +++ b/src/commands/Logging/modules/logging_channel.js @@ -4,6 +4,7 @@ import { successEmbed } from '../../../utils/embeds.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; import { logger } from '../../../utils/logger.js'; +import { replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; const DESTINATION_LABELS = { audit: 'Audit Log', applications: 'Applications', @@ -40,7 +41,7 @@ export default { const botPerms = channel.permissionsFor(interaction.guild.members.me); if (!botPerms?.has(['ViewChannel', 'SendMessages', 'EmbedLinks'])) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'I need **View Channel**, **Send Messages**, and **Embed Links** in ${channel}.' }); + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: `I need **View Channel**, **Send Messages**, and **Embed Links** in ${channel}.` }); } await setLogChannel(client, interaction.guildId, destination, channel.id); diff --git a/src/commands/Logging/modules/logging_dashboard.js b/src/commands/Logging/modules/logging_dashboard.js index 3743beab7e..f317698ccf 100644 --- a/src/commands/Logging/modules/logging_dashboard.js +++ b/src/commands/Logging/modules/logging_dashboard.js @@ -1,6 +1,6 @@ import { EmbedBuilder, MessageFlags, PermissionsBitField } from 'discord.js'; import { getColor } from '../../../config/bot.js'; -import { getGuildConfig } from '../../../services/guildConfig.js'; +import { getGuildConfig } from '../../../services/config/guildConfig.js'; import { getLoggingStatus } from '../../../services/loggingService.js'; import { createLoggingDashboardComponents, @@ -9,10 +9,11 @@ import { DASHBOARD_CATEGORIES, DASHBOARD_CATEGORY_LABELS, EVENT_TYPES_BY_CATEGORY, -} from '../../../utils/loggingUi.js'; +} from '../../../utils/logging/loggingUi.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; import { logger } from '../../../utils/logger.js'; +import { replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; export function getCategoryStatus(enabledEvents, category, auditEnabled) { if (!auditEnabled) return false; const events = enabledEvents || {}; diff --git a/src/commands/Moderation/ban.js b/src/commands/Moderation/ban.js index 10d6345d14..86dd778858 100644 --- a/src/commands/Moderation/ban.js +++ b/src/commands/Moderation/ban.js @@ -1,10 +1,9 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logModerationAction } from '../../utils/moderation.js'; -import { logger } from '../../utils/logger.js'; +import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import { successEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { ModerationService } from '../../services/moderationService.js'; -import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { ModerationService } from '../../services/moderation/moderationService.js'; +import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; + export default { data: new SlashCommandBuilder() .setName("ban") @@ -18,48 +17,51 @@ export default { .addStringOption((option) => option.setName("reason").setDescription("Reason for the ban"), ) -.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers), + .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers), category: "moderation", async execute(interaction, config, client) { - try { - const user = interaction.options.getUser("target"); - const reason = interaction.options.getString("reason") || "No reason provided"; + const user = interaction.options.getUser("target"); + const reason = interaction.options.getString("reason") || "No reason provided"; - if (!user) { - throw new TitanBotError( - 'Missing target user', - ErrorTypes.USER_INPUT, - 'You must specify a user to ban.', - { subtype: 'invalid_user' }, - ); - } + if (!user) { + throw new TitanBotError( + 'Missing target user', + ErrorTypes.USER_INPUT, + 'You must specify a user to ban.', + { subtype: 'invalid_user' }, + ); + } - if (user.id === interaction.user.id) { - throw new Error("You cannot ban yourself."); - } - if (user.id === client.user.id) { - throw new Error("You cannot ban the bot."); - } + if (user.id === interaction.user.id) { + throw new TitanBotError( + 'Cannot ban self', + ErrorTypes.VALIDATION, + 'You cannot ban yourself.', + ); + } + if (user.id === client.user.id) { + throw new TitanBotError( + 'Cannot ban bot', + ErrorTypes.VALIDATION, + 'You cannot ban the bot.', + ); + } - const result = await ModerationService.banUser({ - guild: interaction.guild, - user, - moderator: interaction.member, - reason - }); + const result = await ModerationService.banUser({ + guild: interaction.guild, + user, + moderator: interaction.member, + reason, + }); - await InteractionHelper.universalReply(interaction, { - embeds: [ - successEmbed( - `🚫 **Banned** ${user.tag}`, - `**Reason:** ${reason}\n**Case ID:** #${result.caseId}`, - ), - ], - }); - } catch (error) { - logger.error('Ban command error:', error); - await handleInteractionError(interaction, error, { subtype: 'ban_failed' }); - } + await InteractionHelper.universalReply(interaction, { + embeds: [ + successEmbed( + `🚫 **Banned** ${user.tag}`, + `**Reason:** ${reason}\n**Case ID:** #${result.caseId}`, + ), + ], + }); }, -}; \ No newline at end of file +}; diff --git a/src/commands/Moderation/cases.js b/src/commands/Moderation/cases.js index cb8f7663e0..5999cd834d 100644 --- a/src/commands/Moderation/cases.js +++ b/src/commands/Moderation/cases.js @@ -3,6 +3,7 @@ import { createEmbed, successEmbed } from '../../utils/embeds.js'; import { getModerationCases } from '../../utils/moderation.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() .setName('cases') @@ -150,7 +151,7 @@ time: 120000 currentPage++; } - await buttonInteraction.editReply({ + await interaction.editReply({ embeds: [createCasesEmbed(currentPage)], components: [createNavigationRow(currentPage)] }); diff --git a/src/commands/Moderation/dm.js b/src/commands/Moderation/dm.js index 4ab7815c2a..da9714f18a 100644 --- a/src/commands/Moderation/dm.js +++ b/src/commands/Moderation/dm.js @@ -5,6 +5,7 @@ import { logger } from '../../utils/logger.js'; import { sanitizeMarkdown } from '../../utils/validation.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; export default { data: new SlashCommandBuilder() .setName("dm") @@ -100,10 +101,10 @@ export default { logger.error('DM command error:', error); if (error.code === 50007) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Could not send a DM to ${targetUser.tag}. They may have DMs disabled.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `Could not send a DM to ${targetUser.tag}. They may have DMs disabled.` }); } - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Failed to send DM: ${error.message}' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `Failed to send DM: ${error.message}` }); } } }; \ No newline at end of file diff --git a/src/commands/Moderation/kick.js b/src/commands/Moderation/kick.js index 374f11f8cf..03fa9f677c 100644 --- a/src/commands/Moderation/kick.js +++ b/src/commands/Moderation/kick.js @@ -1,84 +1,78 @@ import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; import { successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { ModerationService } from '../../services/moderationService.js'; -import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { ModerationService } from '../../services/moderation/moderationService.js'; +import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; export default { data: new SlashCommandBuilder() - .setName("kick") - .setDescription("Kick a user from the server") - .addUserOption((option) => - option - .setName("target") - .setDescription("The user to kick") - .setRequired(true), - ) - .addStringOption((option) => - option.setName("reason").setDescription("Reason for the kick"), - ) -.setDefaultMemberPermissions(PermissionFlagsBits.KickMembers), - category: "moderation", + .setName("kick") + .setDescription("Kick a user from the server") + .addUserOption((option) => + option + .setName("target") + .setDescription("The user to kick") + .setRequired(true), + ) + .addStringOption((option) => + option.setName("reason").setDescription("Reason for the kick"), + ) + .setDefaultMemberPermissions(PermissionFlagsBits.KickMembers), + category: "moderation", - async execute(interaction, config, client) { - try { - const targetUser = interaction.options.getUser("target"); - const member = interaction.options.getMember("target"); - const reason = interaction.options.getString("reason") || "No reason provided"; + async execute(interaction, config, client) { + const targetUser = interaction.options.getUser("target"); + const member = interaction.options.getMember("target"); + const reason = interaction.options.getString("reason") || "No reason provided"; - if (!targetUser) { - throw new TitanBotError( - 'Missing target user', - ErrorTypes.USER_INPUT, - 'You must specify a user to kick.', - { subtype: 'invalid_user' }, - ); - } + if (!targetUser) { + throw new TitanBotError( + 'Missing target user', + ErrorTypes.USER_INPUT, + 'You must specify a user to kick.', + { subtype: 'invalid_user' }, + ); + } - if (targetUser.id === interaction.user.id) { - throw new TitanBotError( - "Cannot kick self", - ErrorTypes.VALIDATION, - "You cannot kick yourself." - ); - } + if (targetUser.id === interaction.user.id) { + throw new TitanBotError( + "Cannot kick self", + ErrorTypes.VALIDATION, + "You cannot kick yourself.", + ); + } - if (targetUser.id === client.user.id) { - throw new TitanBotError( - "Cannot kick bot", - ErrorTypes.VALIDATION, - "You cannot kick the bot." - ); - } + if (targetUser.id === client.user.id) { + throw new TitanBotError( + "Cannot kick bot", + ErrorTypes.VALIDATION, + "You cannot kick the bot.", + ); + } - if (!member) { - throw new TitanBotError( - "Target not found", - ErrorTypes.USER_INPUT, - "The target user is not currently in this server.", - { subtype: 'user_not_found' } - ); - } + if (!member) { + throw new TitanBotError( + "Target not found", + ErrorTypes.USER_INPUT, + "The target user is not currently in this server.", + { subtype: 'user_not_found' }, + ); + } - const result = await ModerationService.kickUser({ - guild: interaction.guild, - member, - moderator: interaction.member, - reason, - }); + const result = await ModerationService.kickUser({ + guild: interaction.guild, + member, + moderator: interaction.member, + reason, + }); - await InteractionHelper.universalReply(interaction, { - embeds: [ - successEmbed( - `👢 **Kicked** ${targetUser.tag}`, - `**Reason:** ${reason}\n**Case ID:** #${result.caseId}`, - ), - ], - }); - } catch (error) { - logger.error('Kick command error:', error); - await handleInteractionError(interaction, error, { subtype: 'kick_failed' }); - } - } + await InteractionHelper.universalReply(interaction, { + embeds: [ + successEmbed( + `👢 **Kicked** ${targetUser.tag}`, + `**Reason:** ${reason}\n**Case ID:** #${result.caseId}`, + ), + ], + }); + }, }; diff --git a/src/commands/Moderation/lock.js b/src/commands/Moderation/lock.js index 22bcfb525b..75b251973f 100644 --- a/src/commands/Moderation/lock.js +++ b/src/commands/Moderation/lock.js @@ -5,6 +5,7 @@ import { logger } from '../../utils/logger.js'; import { getColor } from '../../config/bot.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; export default { data: new SlashCommandBuilder() .setName("lock") @@ -31,7 +32,7 @@ export default { try { const currentPermissions = channel.permissionsFor(everyoneRole); if (currentPermissions.has(PermissionFlagsBits.SendMessages) === false) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: '${channel} is already locked.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `${channel} is already locked.` }); } await channel.permissionOverwrites.edit( @@ -40,20 +41,6 @@ export default { { type: 0, reason: `Channel locked by ${interaction.user.tag}` }, ); - const lockEmbed = createEmbed( - "🔒 Channel Locked (Action Log)", - `${channel} has been locked down by ${interaction.user}.`, - ) -.setColor(getColor('moderation')) - .addFields( - { name: "Channel", value: channel.toString(), inline: true }, - { - name: "Moderator", - value: `${interaction.user.tag} (${interaction.user.id})`, - inline: true, - }, - ); - await logEvent({ client, guild: interaction.guild, diff --git a/src/commands/Moderation/massban.js b/src/commands/Moderation/massban.js index dbf9366ffe..9f6f8637a4 100644 --- a/src/commands/Moderation/massban.js +++ b/src/commands/Moderation/massban.js @@ -2,8 +2,8 @@ import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord. 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 { ModerationService } from '../../services/moderation/moderationService.js'; +import { TitanBotError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { @@ -108,7 +108,7 @@ export default { await interaction.guild.members.ban(userId, { reason: reason, - deleteMessageDays: deleteDays + deleteMessageSeconds: deleteDays * 24 * 60 * 60 }); results.successful.push({ diff --git a/src/commands/Moderation/masskick.js b/src/commands/Moderation/masskick.js index 93ea176eb5..26e7e21a48 100644 --- a/src/commands/Moderation/masskick.js +++ b/src/commands/Moderation/masskick.js @@ -2,8 +2,8 @@ import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord. 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 { ModerationService } from '../../services/moderation/moderationService.js'; +import { TitanBotError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { diff --git a/src/commands/Moderation/purge.js b/src/commands/Moderation/purge.js index ccda46935a..7b279f2d4f 100644 --- a/src/commands/Moderation/purge.js +++ b/src/commands/Moderation/purge.js @@ -5,6 +5,7 @@ import { logger } from '../../utils/logger.js'; import { getColor } from '../../config/bot.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; export default { data: new SlashCommandBuilder() .setName("purge") @@ -43,21 +44,6 @@ export default { const deleted = await channel.bulkDelete(fetched, true); const deletedCount = deleted.size; - const purgeEmbed = createEmbed( - "🗑️ Messages Purged (Action Log)", - `${deletedCount} messages were deleted by ${interaction.user}.`, - ) -.setColor(getColor('moderation')) - .addFields( - { name: "Channel", value: channel.toString(), inline: true }, - { - name: "Moderator", - value: `${interaction.user.tag} (${interaction.user.id})`, - inline: true, - }, - { name: "Count", value: `${deletedCount} messages`, inline: false }, - ); - await logEvent({ client, guild: interaction.guild, diff --git a/src/commands/Moderation/say.js b/src/commands/Moderation/say.js new file mode 100644 index 0000000000..51d8dffbfa --- /dev/null +++ b/src/commands/Moderation/say.js @@ -0,0 +1,134 @@ +import { + SlashCommandBuilder, + PermissionFlagsBits, + ChannelType, + MessageFlags, +} from 'discord.js'; +import { successEmbed } from '../../utils/embeds.js'; +import { logEvent } from '../../utils/moderation.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; +import { sanitizeInput } from '../../utils/validation.js'; + +const TEXT_CHANNEL_TYPES = [ + ChannelType.GuildText, + ChannelType.GuildAnnouncement, +]; + +function resolveTargetChannel(interaction) { + const selected = interaction.options.getChannel('channel'); + if (selected) { + return selected; + } + + if (!interaction.channel || !TEXT_CHANNEL_TYPES.includes(interaction.channel.type)) { + return null; + } + + return interaction.channel; +} + +export default { + data: new SlashCommandBuilder() + .setName('say') + .setDescription('Send a plain message as the bot') + .addStringOption((option) => + option + .setName('message') + .setDescription('The message the bot should send') + .setRequired(true) + .setMaxLength(2000), + ) + .addChannelOption((option) => + option + .setName('channel') + .setDescription('Channel to send in (defaults to the current channel)') + .addChannelTypes(...TEXT_CHANNEL_TYPES) + .setRequired(false), + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) + .setDMPermission(false), + category: 'moderation', + abuseProtection: { maxAttempts: 8, windowMs: 60_000 }, + + async execute(interaction, _config, client) { + const deferSuccess = await InteractionHelper.safeDefer(interaction, { + flags: MessageFlags.Ephemeral, + }); + if (!deferSuccess) { + logger.warn('Say interaction defer failed', { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'say', + }); + return; + } + + const rawMessage = interaction.options.getString('message'); + const message = sanitizeInput(rawMessage, 2000); + + if (!message) { + return replyUserError(interaction, { + type: ErrorTypes.VALIDATION, + message: 'Message cannot be empty.', + }); + } + + const channel = resolveTargetChannel(interaction); + if (!channel) { + return replyUserError(interaction, { + type: ErrorTypes.VALIDATION, + message: 'Choose a text channel or run this command in one.', + }); + } + + const memberPermissions = channel.permissionsFor(interaction.member); + const botPermissions = channel.permissionsFor(interaction.guild.members.me); + + if (!memberPermissions?.has(PermissionFlagsBits.SendMessages)) { + return replyUserError(interaction, { + type: ErrorTypes.PERMISSION, + message: `You do not have permission to send messages in ${channel}.`, + }); + } + + if (!botPermissions?.has(PermissionFlagsBits.SendMessages)) { + return replyUserError(interaction, { + type: ErrorTypes.PERMISSION, + message: `I do not have permission to send messages in ${channel}.`, + }); + } + + const sentMessage = await channel.send({ content: message }); + + await logEvent({ + client, + guild: interaction.guild, + event: { + action: 'Bot Message Sent', + target: `${channel} (${channel.id})`, + executor: `${interaction.user.tag} (${interaction.user.id})`, + reason: message.length > 200 + ? `${message.slice(0, 197)}...` + : message, + metadata: { + channelId: channel.id, + messageId: sentMessage.id, + moderatorId: interaction.user.id, + messageLength: message.length, + }, + }, + }); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + successEmbed( + 'Message Sent', + `Posted in ${channel}. [Jump to message](${sentMessage.url})`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + }, +}; diff --git a/src/commands/Moderation/timeout.js b/src/commands/Moderation/timeout.js index 611ad7c49b..936464ced6 100644 --- a/src/commands/Moderation/timeout.js +++ b/src/commands/Moderation/timeout.js @@ -1,9 +1,9 @@ import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; import { successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; -import { TitanBotError, ErrorTypes, handleInteractionError } from '../../utils/errorHandler.js'; +import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { ModerationService } from '../../services/moderationService.js'; +import { ModerationService } from '../../services/moderation/moderationService.js'; const durationChoices = [ { name: "5 minutes", value: 5 }, @@ -31,12 +31,12 @@ export default { .setName("duration") .setDescription("Duration of the timeout") .setRequired(true) -.addChoices(...durationChoices), + .addChoices(...durationChoices), ) .addStringOption((option) => option.setName("reason").setDescription("Reason for the timeout"), ) -.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), category: "moderation", async execute(interaction, config, client) { @@ -45,72 +45,67 @@ export default { logger.warn(`Timeout interaction defer failed`, { userId: interaction.user.id, guildId: interaction.guildId, - commandName: 'timeout' + commandName: 'timeout', }); return; } - try { - const targetUser = interaction.options.getUser("target"); - const member = interaction.options.getMember("target"); - const durationMinutes = interaction.options.getInteger("duration"); - const reason = interaction.options.getString("reason") || "No reason provided"; + const targetUser = interaction.options.getUser("target"); + const member = interaction.options.getMember("target"); + const durationMinutes = interaction.options.getInteger("duration"); + const reason = interaction.options.getString("reason") || "No reason provided"; - if (!targetUser) { - throw new TitanBotError( - 'Missing target user', - ErrorTypes.USER_INPUT, - 'You must specify a user to timeout.', - { subtype: 'invalid_user' }, - ); - } + if (!targetUser) { + throw new TitanBotError( + 'Missing target user', + ErrorTypes.USER_INPUT, + 'You must specify a user to timeout.', + { subtype: 'invalid_user' }, + ); + } - if (targetUser.id === interaction.user.id) { - throw new TitanBotError( - "Cannot timeout self", - ErrorTypes.VALIDATION, - "You cannot timeout yourself." - ); - } - if (targetUser.id === client.user.id) { - throw new TitanBotError( - "Cannot timeout bot", - ErrorTypes.VALIDATION, - "You cannot timeout the bot." - ); - } - if (!member) { - throw new TitanBotError( - "Target not found", - ErrorTypes.USER_INPUT, - "The target user is not currently in this server." - ); - } + if (targetUser.id === interaction.user.id) { + throw new TitanBotError( + "Cannot timeout self", + ErrorTypes.VALIDATION, + "You cannot timeout yourself.", + ); + } + if (targetUser.id === client.user.id) { + throw new TitanBotError( + "Cannot timeout bot", + ErrorTypes.VALIDATION, + "You cannot timeout the bot.", + ); + } + if (!member) { + throw new TitanBotError( + "Target not found", + ErrorTypes.USER_INPUT, + "The target user is not currently in this server.", + ); + } - const durationMs = durationMinutes * 60 * 1000; - const result = await ModerationService.timeoutUser({ - guild: interaction.guild, - member, - moderator: interaction.member, - durationMs, - reason, - }); + const durationMs = durationMinutes * 60 * 1000; + const result = await ModerationService.timeoutUser({ + guild: interaction.guild, + member, + moderator: interaction.member, + durationMs, + reason, + }); - const durationDisplay = - durationChoices.find((c) => c.value === durationMinutes) - ?.name || `${durationMinutes} minutes`; + const durationDisplay = + durationChoices.find((c) => c.value === durationMinutes) + ?.name || `${durationMinutes} minutes`; - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - `⏳ **Timed out** ${targetUser.tag} for ${durationDisplay}.`, - `**Reason:** ${reason}\n**Case ID:** #${result.caseId}`, - ), - ], - }); - } catch (error) { - logger.error('Timeout command error:', error); - await handleInteractionError(interaction, error, { subtype: 'timeout_failed' }); - } - } + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + successEmbed( + `⏳ **Timed out** ${targetUser.tag} for ${durationDisplay}.`, + `**Reason:** ${reason}\n**Case ID:** #${result.caseId}`, + ), + ], + }); + }, }; diff --git a/src/commands/Moderation/unban.js b/src/commands/Moderation/unban.js index 6f61b341e9..484cdec02a 100644 --- a/src/commands/Moderation/unban.js +++ b/src/commands/Moderation/unban.js @@ -1,24 +1,24 @@ import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logModerationAction } from '../../utils/moderation.js'; +import { successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; -import { ModerationService } from '../../services/moderationService.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; +import { ModerationService } from '../../services/moderation/moderationService.js'; +import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; + export default { data: new SlashCommandBuilder() .setName("unban") .setDescription("Unban a user from the server") - .addUserOption(option => + .addStringOption(option => option .setName("target") - .setDescription("The user to unban (can be ID or mention)") - .setRequired(true) + .setDescription("The ID (or mention) of the user to unban") + .setRequired(true), ) .addStringOption(option => option.setName("reason") .setDescription("Reason for the unban") - .setRequired(false) + .setRequired(false), ) .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers), category: "moderation", @@ -29,33 +29,45 @@ export default { logger.warn(`Unban interaction defer failed`, { userId: interaction.user.id, guildId: interaction.guildId, - commandName: 'unban' + commandName: 'unban', }); return; } - try { - const targetUser = interaction.options.getUser("target"); - const reason = interaction.options.getString("reason") || "No reason provided"; - - const result = await ModerationService.unbanUser({ - guild: interaction.guild, - user: targetUser, - moderator: interaction.member, - reason - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "✅ User Unbanned", - `Successfully unbanned **${targetUser.tag}** from the server.\n\n**Reason:** ${reason}\n**Case ID:** #${result.caseId}` - ) - ] - }); - } catch (error) { - logger.error('Unban command error:', error); - await handleInteractionError(interaction, error, { subtype: 'unban_failed' }); + const rawTarget = interaction.options.getString("target"); + const targetId = rawTarget.replace(/[<@!>]/g, '').trim(); + + if (!/^\d{17,20}$/.test(targetId)) { + return replyUserError(interaction, { + type: ErrorTypes.USER_INPUT, + message: 'Please provide a valid user ID or mention.', + }); + } + + const targetUser = await client.users.fetch(targetId).catch(() => null); + if (!targetUser) { + return replyUserError(interaction, { + type: ErrorTypes.USER_INPUT, + message: `Could not find a user with the ID \`${targetId}\`.`, + }); } - } -}; \ No newline at end of file + + const reason = interaction.options.getString("reason") || "No reason provided"; + + const result = await ModerationService.unbanUser({ + guild: interaction.guild, + user: targetUser, + moderator: interaction.member, + reason, + }); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + successEmbed( + "✅ User Unbanned", + `Successfully unbanned **${targetUser.tag}** from the server.\n\n**Reason:** ${reason}\n**Case ID:** #${result.caseId}`, + ), + ], + }); + }, +}; diff --git a/src/commands/Moderation/unlock.js b/src/commands/Moderation/unlock.js index c9c6490c40..408c2b3b07 100644 --- a/src/commands/Moderation/unlock.js +++ b/src/commands/Moderation/unlock.js @@ -5,6 +5,7 @@ import { logger } from '../../utils/logger.js'; import { getColor } from '../../config/bot.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; export default { data: new SlashCommandBuilder() .setName("unlock") @@ -36,7 +37,7 @@ export default { currentPermissions.has(PermissionFlagsBits.SendMessages) === null ) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: '${channel} is not explicitly locked (everyone can already send messages).' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `${channel} is not explicitly locked (everyone can already send messages).` }); } await channel.permissionOverwrites.edit( @@ -48,24 +49,6 @@ export default { }, ); - const unlockEmbed = createEmbed( - "🔓 Channel Unlocked (Action Log)", - `${channel} has been unlocked by ${interaction.user}.`, - ) -.setColor(getColor('success')) - .addFields( - { - name: "Channel", - value: channel.toString(), - inline: true, - }, - { - name: "Moderator", - value: `${interaction.user.tag} (${interaction.user.id})`, - inline: true, - }, - ); - await logEvent({ client, guild: interaction.guild, diff --git a/src/commands/Moderation/untimeout.js b/src/commands/Moderation/untimeout.js index cd3a201602..104a108f2e 100644 --- a/src/commands/Moderation/untimeout.js +++ b/src/commands/Moderation/untimeout.js @@ -1,8 +1,8 @@ import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; import { successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; -import { ModerationService } from '../../services/moderationService.js'; -import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { ModerationService } from '../../services/moderation/moderationService.js'; +import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { @@ -15,7 +15,7 @@ export default { .setDescription("User to untimeout") .setRequired(true), ) -.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), category: "moderation", async execute(interaction, config, client) { @@ -24,48 +24,43 @@ export default { logger.warn(`Untimeout interaction defer failed`, { userId: interaction.user.id, guildId: interaction.guildId, - commandName: 'untimeout' + commandName: 'untimeout', }); return; } - try { - const targetUser = interaction.options.getUser("target"); - const member = interaction.options.getMember("target"); + const targetUser = interaction.options.getUser("target"); + const member = interaction.options.getMember("target"); - if (!targetUser) { - throw new TitanBotError( - 'Missing target user', - ErrorTypes.USER_INPUT, - 'You must specify a user to untimeout.', - { subtype: 'invalid_user' }, - ); - } + if (!targetUser) { + throw new TitanBotError( + 'Missing target user', + ErrorTypes.USER_INPUT, + 'You must specify a user to untimeout.', + { subtype: 'invalid_user' }, + ); + } - if (!member) { - throw new TitanBotError( - "Target not found", - ErrorTypes.USER_INPUT, - "The target user is not currently in this server." - ); - } + if (!member) { + throw new TitanBotError( + "Target not found", + ErrorTypes.USER_INPUT, + "The target user is not currently in this server.", + ); + } - await ModerationService.removeTimeoutUser({ - guild: interaction.guild, - member, - moderator: interaction.member - }); + await ModerationService.removeTimeoutUser({ + guild: interaction.guild, + member, + moderator: interaction.member, + }); - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - `🔓 **Removed timeout** from ${targetUser.tag}`, - ), - ], - }); - } catch (error) { - logger.error('Untimeout command error:', error); - await handleInteractionError(interaction, error, { subtype: 'untimeout_failed' }); - } - } + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + successEmbed( + `🔓 **Removed timeout** from ${targetUser.tag}`, + ), + ], + }); + }, }; diff --git a/src/commands/Moderation/usernotes.js b/src/commands/Moderation/usernotes.js index 5bbf85f865..4fc581fec7 100644 --- a/src/commands/Moderation/usernotes.js +++ b/src/commands/Moderation/usernotes.js @@ -1,17 +1,11 @@ import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; -import { getFromDb, setInDb, deleteFromDb } from '../../utils/database.js'; +import { getFromDb, setInDb, deleteFromDb, getUserNotesKey, getUserNotesListKey } from '../../utils/database.js'; import { sanitizeInput } from '../../utils/validation.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; -function getUserNotesKey(guildId, userId) { - return `moderation_user_notes_${guildId}_${userId}`; -} - -function getGuildNotesListKey(guildId) { - return `moderation_user_notes_list_${guildId}`; -} +import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; export default { data: new SlashCommandBuilder() @@ -206,14 +200,18 @@ async function handleViewNotes(interaction, targetUser, notes) { } async function handleRemoveNote(interaction, targetUser, notes, guildId) { -const index = interaction.options.getInteger("index") - 1; + const index = interaction.options.getInteger("index") - 1; if (index < 0 || index >= notes.length) { - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please provide a valid note index (1-${notes.length}).' }); + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: `Please provide a valid note index (1-${notes.length}).` }); } - const removedNote = notes[index]; - notes.splice(index, 1); + // The view command displays notes sorted newest-first, so resolve the index + // against the same ordering to delete the note the user actually sees. + const sortedNotes = [...notes].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); + const removedNote = sortedNotes[index]; + const originalIndex = notes.indexOf(removedNote); + notes.splice(originalIndex, 1); const notesKey = getUserNotesKey(guildId, targetUser.id); await setInDb(notesKey, notes); diff --git a/src/commands/Moderation/warn.js b/src/commands/Moderation/warn.js index 2f66f4243d..1d1b2ef1eb 100644 --- a/src/commands/Moderation/warn.js +++ b/src/commands/Moderation/warn.js @@ -2,9 +2,9 @@ import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelT import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { logModerationAction } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; -import { WarningService } from '../../services/warningService.js'; -import { ModerationService } from '../../services/moderationService.js'; -import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { WarningService } from '../../services/moderation/warningService.js'; +import { ModerationService } from '../../services/moderation/moderationService.js'; +import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() @@ -36,91 +36,73 @@ export default { return; } - try { - const target = interaction.options.getUser("target"); - const member = interaction.options.getMember("target"); - const reason = interaction.options.getString("reason"); - const moderator = interaction.user; - const guildId = interaction.guildId; + const target = interaction.options.getUser("target"); + const member = interaction.options.getMember("target"); + const reason = interaction.options.getString("reason"); + const moderator = interaction.user; + const guildId = interaction.guildId; - if (!target) { - throw new TitanBotError( - 'Missing target user', - ErrorTypes.USER_INPUT, - 'You must specify a user to warn.', - { subtype: 'invalid_user' }, - ); - } + if (!target) { + throw new TitanBotError( + 'Missing target user', + ErrorTypes.USER_INPUT, + 'You must specify a user to warn.', + { subtype: 'invalid_user' }, + ); + } - if (!reason) { - throw new TitanBotError( - 'Missing warning reason', - ErrorTypes.VALIDATION, - 'You must provide a reason for the warning.', - { subtype: 'missing_required' }, - ); - } + if (!reason) { + throw new TitanBotError( + 'Missing warning reason', + ErrorTypes.VALIDATION, + 'You must provide a reason for the warning.', + { subtype: 'missing_required' }, + ); + } - if (!member) { - throw new TitanBotError( - "Target not found", - ErrorTypes.USER_INPUT, - "The target user is not currently in this server." - ); - } + if (!member) { + throw new TitanBotError( + "Target not found", + ErrorTypes.USER_INPUT, + "The target user is not currently in this server." + ); + } - const hierarchyCheck = ModerationService.validateHierarchy(interaction.member, member, 'warn'); - if (!hierarchyCheck.valid) { - throw new TitanBotError( - hierarchyCheck.error, - ErrorTypes.PERMISSION, - hierarchyCheck.error - ); - } + ModerationService.assertModerationHierarchy(interaction.member, member, 'warn'); + + const { id, totalCount } = await WarningService.addWarning({ + guildId, + userId: target.id, + moderatorId: moderator.id, + reason, + timestamp: Date.now() + }); - const result = await WarningService.addWarning({ - guildId, + await logModerationAction({ + client, + guild: interaction.guild, + event: { + action: "User Warned", + target: `${target.tag} (${target.id})`, + executor: `${moderator.tag} (${moderator.id})`, + reason, + metadata: { userId: target.id, moderatorId: moderator.id, - reason, - timestamp: Date.now() - }); - - if (!result.success) { - throw new Error("Failed to store warning in database"); + totalWarns: totalCount, + warningNumber: totalCount, + warningId: id } + } + }); - const totalWarns = result.totalCount; - - await logModerationAction({ - client, - guild: interaction.guild, - event: { - action: "User Warned", - target: `${target.tag} (${target.id})`, - executor: `${moderator.tag} (${moderator.id})`, - reason, - metadata: { - userId: target.id, - moderatorId: moderator.id, - totalWarns, - warningNumber: totalWarns, - warningId: result.id - } - } - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - `⚠️ **Warned** ${target.tag}`, - `**Reason:** ${reason}\n**Total Warns:** ${totalWarns}`, - ), - ], - }); - } catch (error) { - logger.error('Warn command error:', error); - await handleInteractionError(interaction, error, { subtype: 'warn_failed' }); - } + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + successEmbed( + `⚠️ **Warned** ${target.tag}`, + `**Reason:** ${reason}\n**Total Warns:** ${totalCount}`, + ), + ], + }); } }; \ No newline at end of file diff --git a/src/commands/Moderation/warnings.js b/src/commands/Moderation/warnings.js index 202ad0565f..59fbce081c 100644 --- a/src/commands/Moderation/warnings.js +++ b/src/commands/Moderation/warnings.js @@ -1,11 +1,11 @@ import { getColor } from '../../config/bot.js'; import { SlashCommandBuilder, PermissionFlagsBits, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; +import { createEmbed } from '../../utils/embeds.js'; import { logEvent } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; -import { WarningService } from '../../services/warningService.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; +import { WarningService } from '../../services/moderation/warningService.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; + export default { data: new SlashCommandBuilder() .setName("warnings") @@ -25,79 +25,74 @@ export default { logger.warn(`Warnings interaction defer failed`, { userId: interaction.user.id, guildId: interaction.guildId, - commandName: 'warnings' + commandName: 'warnings', }); return; } - try { - const target = interaction.options.getUser("target"); - const guildId = interaction.guildId; + const target = interaction.options.getUser("target"); + const guildId = interaction.guildId; - const validWarnings = await WarningService.getWarnings(guildId, target.id); - const totalWarns = validWarnings.length; + const validWarnings = await WarningService.getWarnings(guildId, target.id); + const totalWarns = validWarnings.length; - if (totalWarns === 0) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - createEmbed({ - title: `Warnings: ${target.tag}`, - description: "This user has no recorded warnings." - }).setColor(getColor('success')), - ], - }); - return; - } + if (totalWarns === 0) { + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + createEmbed({ + title: `Warnings: ${target.tag}`, + description: "This user has no recorded warnings.", + }).setColor(getColor('success')), + ], + }); + return; + } - const embed = createEmbed({ - title: `Warnings: ${target.tag}`, - description: `Total Warnings: **${totalWarns}**` - }).setColor(getColor('warning')); + const embed = createEmbed({ + title: `Warnings: ${target.tag}`, + description: `Total Warnings: **${totalWarns}**`, + }).setColor(getColor('warning')); - const warningFields = validWarnings - .map((w, i) => { - const discordTimestamp = Math.floor(w.timestamp / 1000); - return { - name: `[#${i + 1}] Reason: ${w.reason.substring(0, 100)}`, - value: `**Moderator:** <@${w.moderatorId}>\n**Date:** ()`, - inline: false, - }; - }) - .slice(0, 25); + const warningFields = validWarnings + .map((w, i) => { + const discordTimestamp = Math.floor(w.timestamp / 1000); + return { + name: `[#${i + 1}] Reason: ${w.reason.substring(0, 100)}`, + value: `**Moderator:** <@${w.moderatorId}>\n**Date:** ()`, + inline: false, + }; + }) + .slice(0, 25); - embed.addFields(warningFields); + embed.addFields(warningFields); - const actionRow = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`warning_delete_specific:${target.id}:${interaction.user.id}`) - .setLabel('Delete Specific Warning') - .setStyle(ButtonStyle.Danger), - new ButtonBuilder() - .setCustomId(`warning_clear_all:${target.id}:${interaction.user.id}`) - .setLabel('Clear All Warnings') - .setStyle(ButtonStyle.Danger) - ); + const actionRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`warning_delete_specific:${target.id}:${interaction.user.id}`) + .setLabel('Delete Specific Warning') + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId(`warning_clear_all:${target.id}:${interaction.user.id}`) + .setLabel('Clear All Warnings') + .setStyle(ButtonStyle.Danger), + ); - await logEvent({ - client, - guild: interaction.guild, - event: { - action: "Warnings Viewed", - target: `${target.tag} (${target.id})`, - executor: `${interaction.user.tag} (${interaction.user.id})`, - reason: `Viewed ${totalWarns} warnings`, - metadata: { - userId: target.id, - moderatorId: interaction.user.id, - totalWarnings: totalWarns - } - } - }); + await logEvent({ + client, + guild: interaction.guild, + event: { + action: "Warnings Viewed", + target: `${target.tag} (${target.id})`, + executor: `${interaction.user.tag} (${interaction.user.id})`, + reason: `Viewed ${totalWarns} warnings`, + metadata: { + userId: target.id, + moderatorId: interaction.user.id, + totalWarnings: totalWarns, + }, + }, + }); - await InteractionHelper.safeEditReply(interaction, { embeds: [embed], components: [actionRow] }); - } catch (error) { - logger.error('Warnings command error:', error); - await handleInteractionError(interaction, error, { subtype: 'warnings_view_failed' }); - } - } -}; \ No newline at end of file + await InteractionHelper.safeEditReply(interaction, { embeds: [embed], components: [actionRow] }); + }, +}; diff --git a/src/commands/Music/join.js b/src/commands/Music/join.js index 7e8a92f593..7606a2a492 100644 --- a/src/commands/Music/join.js +++ b/src/commands/Music/join.js @@ -1,6 +1,5 @@ import { SlashCommandBuilder } from 'discord.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; import { joinVoiceChannel, replyMusicSuccess } from '../../services/music/musicActions.js'; import { deferMusicCommand } from '../../services/music/prefixSupport.js'; @@ -11,12 +10,8 @@ export default { .setDescription('Join your voice channel without starting playback'), async execute(interaction, config, client) { - try { - await deferMusicCommand(interaction); - const embed = await joinVoiceChannel(client, interaction); - await replyMusicSuccess(interaction, embed); - } catch (error) { - await handleInteractionError(interaction, error, { command: 'join' }); - } + await deferMusicCommand(interaction); + const embed = await joinVoiceChannel(client, interaction); + await replyMusicSuccess(interaction, embed); }, }; diff --git a/src/commands/Music/music.js b/src/commands/Music/music.js index 7006c8f236..fa8d7cfef6 100644 --- a/src/commands/Music/music.js +++ b/src/commands/Music/music.js @@ -1,6 +1,5 @@ import { SlashCommandBuilder } from 'discord.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; import { skipTrack, stopPlayback, @@ -106,88 +105,84 @@ export default { ), async execute(interaction, config, client) { - try { - await deferMusicCommand(interaction); - const subcommand = interaction.options.getSubcommand(); + await deferMusicCommand(interaction); + const subcommand = interaction.options.getSubcommand(); - switch (subcommand) { - case 'pause': { - const embed = await pausePlayback(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case 'resume': { - const embed = await resumePlayback(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case 'skip': { - const embed = await skipTrack(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case 'stop': { - const embed = await stopPlayback(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case 'shuffle': { - const embed = await shuffleQueue(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case 'loop': { - const embed = await setLoopMode(client, interaction, interaction.options.getString('mode')); - await replyMusicSuccess(interaction, embed); - break; - } - case 'volume': { - const embed = await setVolume(client, interaction, interaction.options.getInteger('level')); - await replyMusicSuccess(interaction, embed); - break; - } - case 'seek': { - const embed = await seekTrack(client, interaction, interaction.options.getInteger('seconds')); - await replyMusicSuccess(interaction, embed); - break; - } - case 'remove': { - const embed = await removeFromQueue(client, interaction, interaction.options.getInteger('position')); - await replyMusicSuccess(interaction, embed); - break; - } - case 'move': { - const embed = await moveInQueue( - client, - interaction, - interaction.options.getInteger('from'), - interaction.options.getInteger('to'), - ); - await replyMusicSuccess(interaction, embed); - break; - } - case 'clear': { - const embed = await clearQueue(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case 'leave': { - const embed = await leaveVoiceChannel(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case '247': { - const embed = await setTwentyFourSeven(client, interaction, interaction.options.getBoolean('enabled')); - await replyMusicSuccess(interaction, embed); - break; - } - default: - await InteractionHelper.safeEditReply(interaction, { - content: 'Unknown music subcommand.', - }); + switch (subcommand) { + case 'pause': { + const embed = await pausePlayback(client, interaction); + await replyMusicSuccess(interaction, embed); + break; } - } catch (error) { - await handleInteractionError(interaction, error, { command: 'music' }); + case 'resume': { + const embed = await resumePlayback(client, interaction); + await replyMusicSuccess(interaction, embed); + break; + } + case 'skip': { + const embed = await skipTrack(client, interaction); + await replyMusicSuccess(interaction, embed); + break; + } + case 'stop': { + const embed = await stopPlayback(client, interaction); + await replyMusicSuccess(interaction, embed); + break; + } + case 'shuffle': { + const embed = await shuffleQueue(client, interaction); + await replyMusicSuccess(interaction, embed); + break; + } + case 'loop': { + const embed = await setLoopMode(client, interaction, interaction.options.getString('mode')); + await replyMusicSuccess(interaction, embed); + break; + } + case 'volume': { + const embed = await setVolume(client, interaction, interaction.options.getInteger('level')); + await replyMusicSuccess(interaction, embed); + break; + } + case 'seek': { + const embed = await seekTrack(client, interaction, interaction.options.getInteger('seconds')); + await replyMusicSuccess(interaction, embed); + break; + } + case 'remove': { + const embed = await removeFromQueue(client, interaction, interaction.options.getInteger('position')); + await replyMusicSuccess(interaction, embed); + break; + } + case 'move': { + const embed = await moveInQueue( + client, + interaction, + interaction.options.getInteger('from'), + interaction.options.getInteger('to'), + ); + await replyMusicSuccess(interaction, embed); + break; + } + case 'clear': { + const embed = await clearQueue(client, interaction); + await replyMusicSuccess(interaction, embed); + break; + } + case 'leave': { + const embed = await leaveVoiceChannel(client, interaction); + await replyMusicSuccess(interaction, embed); + break; + } + case '247': { + const embed = await setTwentyFourSeven(client, interaction, interaction.options.getBoolean('enabled')); + await replyMusicSuccess(interaction, embed); + break; + } + default: + await InteractionHelper.safeEditReply(interaction, { + content: 'Unknown music subcommand.', + }); } }, }; diff --git a/src/commands/Music/nowplaying.js b/src/commands/Music/nowplaying.js index efcdaedb94..e50c685122 100644 --- a/src/commands/Music/nowplaying.js +++ b/src/commands/Music/nowplaying.js @@ -1,6 +1,5 @@ import { SlashCommandBuilder } from 'discord.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; import { buildNowPlayingReply } from '../../services/music/musicActions.js'; import { deferMusicCommand } from '../../services/music/prefixSupport.js'; @@ -11,12 +10,8 @@ export default { .setDescription('Show the currently playing track'), async execute(interaction, config, client) { - try { - await deferMusicCommand(interaction); - const payload = buildNowPlayingReply(client, interaction.guild.id); - await InteractionHelper.safeEditReply(interaction, payload); - } catch (error) { - await handleInteractionError(interaction, error, { command: 'nowplaying' }); - } + await deferMusicCommand(interaction); + const payload = buildNowPlayingReply(client, interaction.guild.id); + await InteractionHelper.safeEditReply(interaction, payload); }, }; diff --git a/src/commands/Music/play.js b/src/commands/Music/play.js index 7223a23598..193b2d4d69 100644 --- a/src/commands/Music/play.js +++ b/src/commands/Music/play.js @@ -1,6 +1,5 @@ import { SlashCommandBuilder, MessageFlags } from 'discord.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; import { playQuery, replyMusicSuccess } from '../../services/music/musicActions.js'; export default { @@ -14,12 +13,8 @@ export default { ), async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - const result = await playQuery(client, interaction, interaction.options.getString('query')); - await replyMusicSuccess(interaction, result.embed); - } catch (error) { - await handleInteractionError(interaction, error, { command: 'play' }); - } + await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + const result = await playQuery(client, interaction, interaction.options.getString('query')); + await replyMusicSuccess(interaction, result.embed); }, }; diff --git a/src/commands/Music/queue.js b/src/commands/Music/queue.js index d75556f908..66faa4eb9d 100644 --- a/src/commands/Music/queue.js +++ b/src/commands/Music/queue.js @@ -1,6 +1,5 @@ import { SlashCommandBuilder, MessageFlags } from 'discord.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; import { buildQueueReply } from '../../services/music/musicActions.js'; export default { @@ -14,16 +13,12 @@ export default { ), async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - const page = (interaction.options.getInteger('page') || 1) - 1; - const payload = buildQueueReply(client, interaction.guild.id, page); - await InteractionHelper.safeEditReply(interaction, { - embeds: payload.embeds, - components: payload.components, - }); - } catch (error) { - await handleInteractionError(interaction, error, { command: 'queue' }); - } + await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + const page = (interaction.options.getInteger('page') || 1) - 1; + const payload = buildQueueReply(client, interaction.guild.id, page); + await InteractionHelper.safeEditReply(interaction, { + embeds: payload.embeds, + components: payload.components, + }); }, }; diff --git a/src/commands/Reaction_roles/reactroles.js b/src/commands/Reaction_roles/reactroles.js index 3361f8290d..692443c5e9 100644 --- a/src/commands/Reaction_roles/reactroles.js +++ b/src/commands/Reaction_roles/reactroles.js @@ -2,7 +2,7 @@ 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 { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; -import { handleInteractionError, createError, TitanBotError, ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; +import { createError, TitanBotError, ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { createReactionRoleMessage, hasDangerousPermissions, getAllReactionRoleMessages, deleteReactionRoleMessage } from '../../services/reactionRoleService.js'; import { logEvent, EVENT_TYPES } from '../../services/loggingService.js'; @@ -11,8 +11,16 @@ import { formatPanelStatusField, } from '../../utils/panelStatus.js'; import { startDashboardSession } from '../../utils/dashboardSession.js'; +import { getReactionRoleKey } from '../../utils/database/keys.js'; const DASHBOARD_EPHEMERAL = MessageFlags.Ephemeral; +const SELECT_OPTION_LABEL_LIMIT = 100; +const SELECT_OPTION_DESCRIPTION_LIMIT = 100; + +function truncateText(value, maxLength) { + const text = String(value ?? ''); + return text.length > maxLength ? text.substring(0, maxLength) : text; +} export default { data: new SlashCommandBuilder() @@ -26,6 +34,7 @@ export default { .addChannelOption(option => option.setName('channel') .setDescription('The channel to send the reaction role message to') + .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement) .setRequired(true) ) .addStringOption(option => @@ -80,19 +89,11 @@ export default { async execute(interaction) { const subcommand = interaction.options.getSubcommand(); - try { - if (subcommand === 'setup') { - await handleSetup(interaction); - } else if (subcommand === 'dashboard') { - const selectedPanelId = interaction.options.getString('panel'); - await handleDashboard(interaction, selectedPanelId); - } - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'reactroles', - subcommand: subcommand - }); + if (subcommand === 'setup') { + await handleSetup(interaction); + } else if (subcommand === 'dashboard') { + const selectedPanelId = interaction.options.getString('panel'); + await handleDashboard(interaction, selectedPanelId); } }, @@ -100,77 +101,45 @@ export default { if (interaction.commandName !== 'reactroles') return; if (interaction.options.getSubcommand() !== 'dashboard') return; + // Autocomplete must respond within 3s. Build choices from stored panel data and + // cached channels/messages only — no network fetches — to avoid DiscordAPIError 10062. try { const guildId = interaction.guild.id; const client = interaction.client; - + const guild = interaction.guild; + let panels; try { panels = await getAllReactionRoleMessages(client, guildId); - } catch (dbError) { - + } catch { await interaction.respond([]).catch(() => {}); return; } - if (!panels || panels.length === 0) { + if (!panels?.length) { await interaction.respond([]).catch(() => {}); return; } - const guild = interaction.guild; - - const validPanels = []; + const choices = []; for (const panel of panels) { - - if (!panel.messageId || !panel.channelId) { - continue; - } + if (!panel.messageId || !panel.channelId) continue; const channel = guild.channels.cache.get(panel.channelId); - if (!channel) { - await deleteReactionRoleMessage(client, guildId, panel.messageId).catch(() => {}); - continue; - } - - const msg = await channel.messages.fetch(panel.messageId).catch(() => null); - if (!msg) { - await deleteReactionRoleMessage(client, guildId, panel.messageId).catch(() => {}); - continue; - } - validPanels.push(panel); - } + if (!channel) continue; - if (validPanels.length === 0) { - await interaction.respond([]).catch(() => {}); - return; - } + const cachedTitle = channel.messages?.cache?.get(panel.messageId)?.embeds?.[0]?.title; + const roleCount = Array.isArray(panel.roles) ? panel.roles.length : 0; + const label = cachedTitle + ? `${cachedTitle} (#${channel.name})` + : `#${channel.name} · ${roleCount} role${roleCount === 1 ? '' : 's'}`; - const choices = await Promise.all( - validPanels.slice(0, 25).map(async panel => { - try { - const channel = guild.channels.cache.get(panel.channelId); - if (!channel) return null; - - const msg = await channel.messages.fetch(panel.messageId).catch(() => null); - if (!msg) return null; - - const title = msg?.embeds?.[0]?.title ?? 'Untitled Panel'; - const channelName = channel?.name ?? 'unknown'; - - return { - name: `${title} (${channelName})`.substring(0, 100), - value: panel.messageId - }; - } catch (e) { - return null; - } - }) - ); + choices.push({ name: label.substring(0, 100), value: panel.messageId }); + if (choices.length >= 25) break; + } - const validChoices = choices.filter(c => c !== null); - await interaction.respond(validChoices).catch(() => {}); - } catch (error) { + await interaction.respond(choices).catch(() => {}); + } catch { await interaction.respond([]).catch(() => {}); } } @@ -225,10 +194,16 @@ async function handleSetup(interaction) { const roles = []; const roleValidationErrors = []; + const seenRoleIds = new Set(); for (let i = 1; i <= 5; i++) { const role = interaction.options.getRole(`role${i}`); if (role) { + if (seenRoleIds.has(role.id)) { + roleValidationErrors.push(`**${role.name}** - This role was selected more than once`); + continue; + } + if (role.position >= interaction.guild.members.me.roles.highest.position) { roleValidationErrors.push(`**${role.name}** - My bot's role is positioned lower than this role in your server's role hierarchy and cannot assign it`); continue; @@ -249,6 +224,7 @@ async function handleSetup(interaction) { continue; } + seenRoleIds.add(role.id); roles.push(role); } } @@ -267,7 +243,7 @@ async function handleSetup(interaction) { await interaction.followUp({ embeds: [warningEmbed('Role Validation Warning', errorMsg)], - ephemeral: true + flags: MessageFlags.Ephemeral }); } @@ -288,8 +264,8 @@ async function handleSetup(interaction) { .setMaxValues(roles.length) .addOptions( roles.map(role => ({ - label: role.name, - description: `Add/remove the ${role.name} role`, + label: truncateText(role.name, SELECT_OPTION_LABEL_LIMIT), + description: truncateText(`Add/remove the ${role.name} role`, SELECT_OPTION_DESCRIPTION_LIMIT), value: role.id, emoji: '🎭' })) @@ -312,14 +288,21 @@ async function handleSetup(interaction) { }); const roleIds = roles.map(role => role.id); - await createReactionRoleMessage( - interaction.client, - interaction.guildId, - channel.id, - message.id, - roleIds - ); - + try { + await createReactionRoleMessage( + interaction.client, + interaction.guildId, + channel.id, + message.id, + roleIds + ); + } catch (saveError) { + // The panel is already posted but its data failed to persist, so the dropdown + // would not work. Remove the orphaned message before surfacing the error. + await message.delete().catch(() => {}); + throw saveError; + } + logger.info(`Reaction role message created: ${message.id} with ${roles.length} roles by ${interaction.user.tag}`); try { @@ -521,9 +504,9 @@ function buildReactionRoleDashboardPayload(panelData, discordMsg, guildId, guild async function migrateReactionRoleMessageId(client, guildId, panelData, newMessageId) { if (!newMessageId || panelData.messageId === newMessageId) return; - const oldKey = `reaction_roles:${guildId}:${panelData.messageId}`; + const oldKey = getReactionRoleKey(guildId, panelData.messageId); panelData.messageId = newMessageId; - await client.db.set(`reaction_roles:${guildId}:${newMessageId}`, panelData); + await client.db.set(getReactionRoleKey(guildId, newMessageId), panelData); await client.db.delete(oldKey).catch(() => {}); } @@ -842,7 +825,7 @@ async function handleAddRole(selectInteraction, rootInteraction, panelData, guil } panelData.roles.push(role.id); - const key = `reaction_roles:${guildId}:${panelData.messageId}`; + const key = getReactionRoleKey(guildId, panelData.messageId); await client.db.set(key, panelData); await rebuildLivePanelMessage(guild, panelData); @@ -971,7 +954,7 @@ async function handleRemoveRole(selectInteraction, rootInteraction, panelData, p }); } } else { - const key = `reaction_roles:${guildId}:${panelData.messageId}`; + const key = getReactionRoleKey(guildId, panelData.messageId); await client.db.set(key, panelData); await rebuildLivePanelMessage(guild, panelData); diff --git a/src/commands/Search/define.js b/src/commands/Search/define.js index 3e9c958431..1db7259dd0 100644 --- a/src/commands/Search/define.js +++ b/src/commands/Search/define.js @@ -2,7 +2,7 @@ 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 { handleInteractionError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { getColor } from '../../config/bot.js'; @@ -39,7 +39,7 @@ export default { ); if (!response.data || response.data.length === 0) { - return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'No definitions found for "${word}".' }); + return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: `No definitions found for "${word}".` }); } const data = response.data[0]; @@ -92,7 +92,7 @@ export default { }); if (error.response?.status === 404) { - await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'No definitions found for "${interaction.options.getString(\'word\')}".' }); + await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: `No definitions found for "${interaction.options.getString('word')}".` }); } else { await handleInteractionError(interaction, error, { commandName: 'define', diff --git a/src/commands/Search/google.js b/src/commands/Search/google.js index 21b172c966..947f6c7906 100644 --- a/src/commands/Search/google.js +++ b/src/commands/Search/google.js @@ -1,7 +1,6 @@ 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'; @@ -14,37 +13,23 @@ export default { .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' }); + const query = interaction.options.getString('query'); + const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}`; - 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' - }); - } + 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' + }); }, }; \ No newline at end of file diff --git a/src/commands/Search/urban.js b/src/commands/Search/urban.js index bb3d2f605f..cec17d323b 100644 --- a/src/commands/Search/urban.js +++ b/src/commands/Search/urban.js @@ -2,7 +2,7 @@ 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 { handleInteractionError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { getColor } from '../../config/bot.js'; @@ -53,7 +53,7 @@ export default { clearDeferTimer(); if (!response.data?.list?.length) { - return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'No definitions found for "${term}" on Urban Dictionary.' }); + return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: `No definitions found for "${term}" on Urban Dictionary.` }); } const definition = response.data.list[0]; @@ -117,7 +117,7 @@ export default { }); 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.' }); + 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 { diff --git a/src/commands/ServerStats/modules/serverstats_create.js b/src/commands/ServerStats/modules/serverstats_create.js index 88bd2c1fbe..e5e750a933 100644 --- a/src/commands/ServerStats/modules/serverstats_create.js +++ b/src/commands/ServerStats/modules/serverstats_create.js @@ -4,6 +4,7 @@ import { getServerCounters, saveServerCounters, updateCounter, getCounterBaseNam import { logger } from '../../../utils/logger.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; export async function handleCreate(interaction, client) { const guild = interaction.guild; const type = interaction.options.getString("type"); @@ -37,7 +38,7 @@ export async function handleCreate(interaction, client) { if (duplicateType) { const duplicateChannel = guild.channels.cache.get(duplicateType.channelId); - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: '`A **${getCounterTypeLabel(type)}** counter already exists for this server${duplicateChannel ? ` in ${duplicateChannel}` : \'\'}. Delete it first before creating another.`' }).catch(logger.error); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `A **${getCounterTypeLabel(type)}** counter already exists for this server${duplicateChannel ? ` in ${duplicateChannel}` : ''}. Delete it first before creating another.` }).catch(logger.error); return; } @@ -50,7 +51,7 @@ export async function handleCreate(interaction, client) { const existingCounter = counters.find(c => c.channelId === targetChannel.id); if (existingCounter) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'A counter already exists for channel **${targetChannel.name}**. Please delete it first or choose a different type.' }).catch(logger.error); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `A counter already exists for channel **${targetChannel.name}**. Please delete it first or choose a different type.` }).catch(logger.error); return; } @@ -79,7 +80,7 @@ export async function handleCreate(interaction, client) { } await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed(`**Counter Created Successfully!**\n\n**Type:** ${getCounterTypeLabel(type)}\n**Channel Type:** ${targetChannel.type === ChannelType.GuildVoice ? 'voice' : 'text'}\n**Category:** ${category}\n**Channel:** ${targetChannel}\n**Channel Name:** ${targetChannel.name}\n**Counter ID:** \`${newCounter.id}\`\n\nThe counter will automatically update every 15 minutes.\n\nUse \`/counter list\` to view all counters.`)] + embeds: [successEmbed(`**Counter Created Successfully!**\n\n**Type:** ${getCounterTypeLabel(type)}\n**Channel Type:** ${targetChannel.type === ChannelType.GuildVoice ? 'voice' : 'text'}\n**Category:** ${category}\n**Channel:** ${targetChannel}\n**Channel Name:** ${targetChannel.name}\n**Counter ID:** \`${newCounter.id}\`\n\nThe counter will automatically update every 15 minutes.\n\nUse \`/serverstats list\` to view all counters.`)] }).catch(logger.error); } catch (error) { diff --git a/src/commands/ServerStats/modules/serverstats_delete.js b/src/commands/ServerStats/modules/serverstats_delete.js index cd63104be0..54e3e9ccfc 100644 --- a/src/commands/ServerStats/modules/serverstats_delete.js +++ b/src/commands/ServerStats/modules/serverstats_delete.js @@ -5,6 +5,7 @@ import { getServerCounters, saveServerCounters, getCounterEmoji, getCounterTypeL import { logger } from '../../../utils/logger.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { replyUserError, ErrorTypes, createError, wrapServiceBoundary } from '../../../utils/errorHandler.js'; export async function handleDelete(interaction, client) { const guild = interaction.guild; const counterId = interaction.options.getString("counter-id"); @@ -31,7 +32,7 @@ export async function handleDelete(interaction, client) { const counterToDelete = counters.find(c => c.id === counterId); if (!counterToDelete) { - await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Counter with ID \\`${counterId}\\` not found. Use \\`/counter list\\` to see all counters.' }).catch(logger.error); + await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: `Counter with ID \`${counterId}\` not found. Use \`/serverstats list\` to see all counters.` }).catch(logger.error); return; } @@ -62,63 +63,59 @@ export async function handleDelete(interaction, client) { } } -export async function performDeletionByCounterId(client, guild, counterId) { - try { - const counters = await getServerCounters(client, guild.id); - - const counter = counters.find(c => c.id === counterId); - if (!counter) { - return { - success: false, - message: `Counter with ID \`${counterId}\` was not found.` - }; - } +export const performDeletionByCounterId = wrapServiceBoundary(async function performDeletionByCounterId(client, guild, counterId) { + const counters = await getServerCounters(client, guild.id); - const updatedCounters = counters.filter(c => c.id !== counter.id); + const counter = counters.find(c => c.id === counterId); + if (!counter) { + throw createError( + 'Counter not found', + ErrorTypes.USER_INPUT, + `Counter with ID \`${counterId}\` was not found.`, + { guildId: guild.id, counterId, operation: 'performDeletionByCounterId' } + ); + } - const saved = await saveServerCounters(client, guild.id, updatedCounters); - if (!saved) { - return { - success: false, - message: "Failed to delete counter. Please try again." - }; - } + const updatedCounters = counters.filter(c => c.id !== counter.id); - const channel = guild.channels.cache.get(counter.channelId); - let channelDeleted = false; + const saved = await saveServerCounters(client, guild.id, updatedCounters); + if (!saved) { + throw createError( + 'Counter delete failed', + ErrorTypes.DATABASE, + 'Failed to delete counter. Please try again.', + { guildId: guild.id, counterId, operation: 'performDeletionByCounterId' } + ); + } - if (channel) { - try { - await channel.delete(`Counter deleted - removing channel: ${counter.id}`); - channelDeleted = true; - } catch (error) { - logger.error("Error deleting channel:", error); - } - } + const channel = guild.channels.cache.get(counter.channelId); + let channelDeleted = false; - let message = `✅ **Counter Deleted Successfully!**\n\n**ID:** \`${counter.id}\`\n**Type:** ${getCounterTypeDisplay(counter.type)}`; - - if (channelDeleted) { - message += `\n**Channel:** ${channel.name} (deleted)`; - } else if (channel) { - message += `\n**Channel:** ${channel.name} (failed to delete)`; - } else { - message += `\n**Channel:** Already deleted`; + if (channel) { + try { + await channel.delete(`Counter deleted - removing channel: ${counter.id}`); + channelDeleted = true; + } catch (error) { + logger.error("Error deleting channel:", error); } + } - return { - success: true, - message - }; + let message = `✅ **Counter Deleted Successfully!**\n\n**ID:** \`${counter.id}\`\n**Type:** ${getCounterTypeDisplay(counter.type)}`; - } catch (error) { - logger.error("Error deleting counter:", error); - return { - success: false, - message: "An error occurred while deleting the counter. Please try again." - }; + if (channelDeleted) { + message += `\n**Channel:** ${channel.name} (deleted)`; + } else if (channel) { + message += `\n**Channel:** ${channel.name} (failed to delete)`; + } else { + message += `\n**Channel:** Already deleted`; } -} + + return { message }; +}, { + service: 'serverstats', + operation: 'performDeletionByCounterId', + userMessage: 'An error occurred while deleting the counter. Please try again.', +}); function getCounterTypeDisplay(type) { return `${getCounterEmoji(type)} ${getCounterTypeLabel(type)}`; diff --git a/src/commands/ServerStats/modules/serverstats_list.js b/src/commands/ServerStats/modules/serverstats_list.js index 0fede246ee..ad6d789227 100644 --- a/src/commands/ServerStats/modules/serverstats_list.js +++ b/src/commands/ServerStats/modules/serverstats_list.js @@ -5,6 +5,7 @@ import { getServerCounters, saveServerCounters, getCounterEmoji as getCounterTyp import { logger } from '../../../utils/logger.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; export async function handleList(interaction, client) { const guild = interaction.guild; @@ -45,7 +46,7 @@ export async function handleList(interaction, client) { if (validCounters.length === 0) { const embed = createEmbed({ title: "Server Counters", - description: "No counters have been set up for this server yet.\n\nUse `/counter create` to set up your first counter!", + description: "No counters have been set up for this server yet.\n\nUse `/serverstats create` to set up your first counter!", color: getColor('warning') }); @@ -57,7 +58,7 @@ export async function handleList(interaction, client) { embed.addFields({ name: "**Usage Examples**", - value: "`/counter create type:members channel_type:voice category:Stats`\n`/counter create type:bots channel_type:text category:Server Info`\n`/counter list`", + value: "`/serverstats create type:members channel_type:voice category:Stats`\n`/serverstats create type:bots channel_type:text category:Server Info`\n`/serverstats list`", inline: false }); @@ -106,7 +107,7 @@ export async function handleList(interaction, client) { embed.addFields({ name: "**Management Commands**", - value: "`/counter create` - Create new counter\n`/counter update` - Update existing counter\n`/counter delete` - Delete counter", + value: "`/serverstats create` - Create new counter\n`/serverstats update` - Update existing counter\n`/serverstats delete` - Delete counter", inline: false }); diff --git a/src/commands/ServerStats/modules/serverstats_update.js b/src/commands/ServerStats/modules/serverstats_update.js index 61029fa54c..b2ebb45434 100644 --- a/src/commands/ServerStats/modules/serverstats_update.js +++ b/src/commands/ServerStats/modules/serverstats_update.js @@ -4,6 +4,7 @@ import { getServerCounters, saveServerCounters, updateCounter, getCounterEmoji, import { logger } from '../../../utils/logger.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; export async function handleUpdate(interaction, client) { const guild = interaction.guild; const counterId = interaction.options.getString("counter-id"); @@ -31,7 +32,7 @@ export async function handleUpdate(interaction, client) { const counterIndex = counters.findIndex(c => c.id === counterId); if (counterIndex === -1) { - await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Counter with ID \\`${counterId}\\` not found. Use \\`/counter list\\` to see all counters.' }).catch(logger.error); + await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: `Counter with ID \`${counterId}\` not found. Use \`/serverstats list\` to see all counters.` }).catch(logger.error); return; } @@ -47,7 +48,7 @@ export async function handleUpdate(interaction, client) { const existingTypeCounter = counters.find(c => c.type === newType && c.id !== counter.id); if (existingTypeCounter) { const existingChannel = guild.channels.cache.get(existingTypeCounter.channelId); - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: '`A **${getCounterTypeLabel(newType)}** counter already exists for this server${existingChannel ? ` in ${existingChannel}` : \'\'}. Delete it first before reusing that type.`' }).catch(logger.error); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `A **${getCounterTypeLabel(newType)}** counter already exists for this server${existingChannel ? ` in ${existingChannel}` : ''}. Delete it first before reusing that type.` }).catch(logger.error); return; } } diff --git a/src/commands/ServerStats/serverstats.js b/src/commands/ServerStats/serverstats.js index 36f6829266..9fe3dda3b8 100644 --- a/src/commands/ServerStats/serverstats.js +++ b/src/commands/ServerStats/serverstats.js @@ -9,6 +9,7 @@ import { handleUpdate } from './modules/serverstats_update.js'; import { handleDelete } from './modules/serverstats_delete.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; export default { data: new SlashCommandBuilder() .setName("serverstats") @@ -89,37 +90,21 @@ export default { async execute(interaction, guildConfig, client) { const subcommand = interaction.options.getSubcommand(); - try { - switch (subcommand) { - case "create": - await handleCreate(interaction, client); - break; - case "list": - await handleList(interaction, client); - break; - case "update": - await handleUpdate(interaction, client); - break; - case "delete": - await handleDelete(interaction, client); - break; - default: - await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Unknown subcommand.' }); - } - } catch (error) { - logger.error(`Error in serverstats ${subcommand}:`, error); - - const errorEmbedMsg = createEmbed({ - title: "❌ Error", - description: "An error occurred while processing your request.", - color: getColor('error') - }); - - if (!interaction.replied && !interaction.deferred) { - await InteractionHelper.safeReply(interaction, { embeds: [errorEmbedMsg], flags: MessageFlags.Ephemeral }).catch(logger.error); - } else { - await interaction.followUp({ embeds: [errorEmbedMsg], flags: MessageFlags.Ephemeral }).catch(logger.error); - } + switch (subcommand) { + case "create": + await handleCreate(interaction, client); + break; + case "list": + await handleList(interaction, client); + break; + case "update": + await handleUpdate(interaction, client); + break; + case "delete": + await handleDelete(interaction, client); + break; + default: + await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Unknown subcommand.' }); } } }; \ No newline at end of file diff --git a/src/commands/Ticket/claim.js b/src/commands/Ticket/claim.js index 4c120c651d..d1d1c1f938 100644 --- a/src/commands/Ticket/claim.js +++ b/src/commands/Ticket/claim.js @@ -2,9 +2,9 @@ import { getColor } from '../../config/bot.js'; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; import { successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; +import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getTicketPermissionContext } from '../../utils/ticketPermissions.js'; +import { getTicketPermissionContext } from '../../utils/ticket/ticketPermissions.js'; import { claimTicket } from '../../services/ticket.js'; export default { data: new SlashCommandBuilder() @@ -13,66 +13,38 @@ export default { .setDMPermission(false), async execute(interaction, guildConfig, 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.' }); - } + const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + if (!deferred) { + return; + } - if (!permissionContext.canManageTicket) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the `Manage Channels` permission or the configured `Ticket Staff Role` to claim tickets.' }); - } + const permissionContext = await getTicketPermissionContext({ client, interaction }); + if (!permissionContext.ticketData) { + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'This command can only be used in a valid ticket channel.' }); + } - const channel = interaction.channel; - const result = await claimTicket(channel, interaction.user); - - if (!result.success) { - logger.warn('Ticket claim failed - not a valid ticket channel', { - userId: interaction.user.id, - channelId: channel.id, - guildId: interaction.guildId, - error: result.error - }); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'result.error || "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 `Manage Channels` permission or the configured `Ticket Staff Role` to claim tickets.' }); + } - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "Ticket Claimed!", - "You have successfully claimed this ticket.", - ), - ], - }); + await claimTicket(interaction.channel, interaction.user); - logger.info('Ticket claimed successfully', { - userId: interaction.user.id, - userTag: interaction.user.tag, - channelId: channel.id, - channelName: channel.name, - guildId: interaction.guildId, - commandName: 'claim' - }); + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + successEmbed( + "Ticket Claimed!", + "You have successfully claimed this ticket.", + ), + ], + }); - } catch (error) { - logger.error('Error executing claim command', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - channelId: interaction.channel?.id, - guildId: interaction.guildId, - commandName: 'claim' - }); - await handleInteractionError(interaction, error, { - commandName: 'claim', - source: 'ticket_claim_command' - }); - } + logger.info('Ticket claimed successfully', { + userId: interaction.user.id, + userTag: interaction.user.tag, + channelId: interaction.channel.id, + channelName: interaction.channel.name, + guildId: interaction.guildId, + commandName: 'claim' + }); }, }; \ No newline at end of file diff --git a/src/commands/Ticket/close.js b/src/commands/Ticket/close.js index 1d69e3dddc..056a699b9b 100644 --- a/src/commands/Ticket/close.js +++ b/src/commands/Ticket/close.js @@ -2,9 +2,9 @@ import { getColor } from '../../config/bot.js'; import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, MessageFlags } from 'discord.js'; import { successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; -import { handleInteractionError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; +import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getTicketPermissionContext } from '../../utils/ticketPermissions.js'; +import { getTicketPermissionContext } from '../../utils/ticket/ticketPermissions.js'; import { closeTicket } from '../../services/ticket.js'; export default { data: new SlashCommandBuilder() @@ -19,71 +19,43 @@ export default { ), async execute(interaction, guildConfig, 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.canCloseTicket) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the `Manage Channels` permission, the configured `Ticket Staff Role`, or be the ticket creator to close this ticket.' }); - } - - const channel = interaction.channel; - const reason = - interaction.options?.getString("reason") || - "Closed via command without a specific reason."; - - const result = await closeTicket(channel, interaction.user, reason); - - if (!result.success) { - logger.warn('Ticket close failed - not a valid ticket channel', { - userId: interaction.user.id, - channelId: channel.id, - guildId: interaction.guildId, - error: result.error - }); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: result.error || 'This command can only be used in a valid ticket channel.' }); - } - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "Ticket Closed!", - "This ticket has been closed successfully.", - ), - ], - }); + const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + if (!deferred) { + return; + } - logger.info('Ticket closed successfully', { - userId: interaction.user.id, - userTag: interaction.user.tag, - channelId: channel.id, - channelName: channel.name, - guildId: interaction.guildId, - reason: reason, - commandName: 'close' - }); + const permissionContext = await getTicketPermissionContext({ client, interaction }); + if (!permissionContext.ticketData) { + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'This command can only be used in a valid ticket channel.' }); + } - } catch (error) { - logger.error('Error executing close command', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - channelId: interaction.channel?.id, - guildId: interaction.guildId, - commandName: 'close' - }); - await handleInteractionError(interaction, error, { - commandName: 'close', - source: 'ticket_close_command' - }); + if (!permissionContext.canCloseTicket) { + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the `Manage Channels` permission, the configured `Ticket Staff Role`, or be the ticket creator to close this ticket.' }); } + + const reason = + interaction.options?.getString("reason") || + "Closed via command without a specific reason."; + + await closeTicket(interaction.channel, interaction.user, reason); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + successEmbed( + "Ticket Closed!", + "This ticket has been closed successfully.", + ), + ], + }); + + logger.info('Ticket closed successfully', { + userId: interaction.user.id, + userTag: interaction.user.tag, + channelId: interaction.channel.id, + channelName: interaction.channel.name, + guildId: interaction.guildId, + reason: reason, + commandName: 'close' + }); }, -}; \ No newline at end of file +}; diff --git a/src/commands/Ticket/modules/ticket_dashboard.js b/src/commands/Ticket/modules/ticket_dashboard.js index 1a97966d53..17f3f43f88 100644 --- a/src/commands/Ticket/modules/ticket_dashboard.js +++ b/src/commands/Ticket/modules/ticket_dashboard.js @@ -20,8 +20,7 @@ import { InteractionHelper } from '../../../utils/interactionHelper.js'; import { successEmbed, infoEmbed } from '../../../utils/embeds.js'; import { logger } from '../../../utils/logger.js'; import { TitanBotError, ErrorTypes, replyUserError } from '../../../utils/errorHandler.js'; -import { getGuildConfig } from '../../../services/guildConfig.js'; -import { getGuildConfigKey } from '../../../utils/database.js'; +import { getGuildConfig, setGuildConfig } from '../../../services/config/guildConfig.js'; import { getGuildTicketStats } from '../../../utils/database/tickets.js'; import { getUserTicketCount } from '../../../services/ticket.js'; import { @@ -76,7 +75,7 @@ async function persistPanelMessageId(client, guildId, guildConfig, messageId) { if (!messageId || guildConfig.ticketPanelMessageId === messageId) return; guildConfig.ticketPanelMessageId = messageId; if (client.db) { - await client.db.set(getGuildConfigKey(guildId), guildConfig); + await setGuildConfig(client, guildId, guildConfig); } } @@ -379,7 +378,7 @@ async function handlePanelMessage(selectInteraction, rootInteraction, guildConfi const newMessage = submitted.fields.getTextInputValue('panel_msg_input').trim(); guildConfig.ticketPanelMessage = newMessage; - await client.db.set(getGuildConfigKey(guildId), guildConfig); + await setGuildConfig(client, guildId, guildConfig); const panelUpdated = await updateLivePanel(client, rootInteraction.guild, guildConfig, guildId); @@ -432,7 +431,7 @@ async function handleButtonLabel(selectInteraction, rootInteraction, guildConfig const newLabel = submitted.fields.getTextInputValue('btn_label_input').trim(); guildConfig.ticketButtonLabel = newLabel; - await client.db.set(getGuildConfigKey(guildId), guildConfig); + await setGuildConfig(client, guildId, guildConfig); const panelUpdated = await updateLivePanel(client, rootInteraction.guild, guildConfig, guildId); @@ -489,7 +488,7 @@ async function handleStaffRole(selectInteraction, rootInteraction, guildConfig, const role = roleInteraction.roles.first(); guildConfig.ticketStaffRoleId = role.id; - await client.db.set(getGuildConfigKey(guildId), guildConfig); + await setGuildConfig(client, guildId, guildConfig); await roleInteraction.followUp({ embeds: [successEmbed('Staff Role Updated', `Staff role set to ${role}.`)], @@ -544,7 +543,7 @@ async function handleOpenCategory(selectInteraction, rootInteraction, guildConfi const category = catInteraction.channels.first(); guildConfig.ticketCategoryId = category.id; - await client.db.set(getGuildConfigKey(guildId), guildConfig); + await setGuildConfig(client, guildId, guildConfig); await catInteraction.followUp({ embeds: [ @@ -604,7 +603,7 @@ async function handleClosedCategory(selectInteraction, rootInteraction, guildCon const category = catInteraction.channels.first(); guildConfig.ticketClosedCategoryId = category.id; - await client.db.set(getGuildConfigKey(guildId), guildConfig); + await setGuildConfig(client, guildId, guildConfig); await catInteraction.followUp({ embeds: [ @@ -671,7 +670,7 @@ async function handleMaxTickets(selectInteraction, rootInteraction, guildConfig, } guildConfig.maxTicketsPerUser = newMax; - await client.db.set(getGuildConfigKey(guildId), guildConfig); + await setGuildConfig(client, guildId, guildConfig); await submitted.reply({ embeds: [ @@ -691,7 +690,7 @@ async function handleDmOnClose(btnInteraction, rootInteraction, guildConfig, gui const newState = guildConfig.dmOnClose === false; guildConfig.dmOnClose = newState; - await client.db.set(getGuildConfigKey(guildId), guildConfig); + await setGuildConfig(client, guildId, guildConfig); await btnInteraction.followUp({ embeds: [ @@ -738,7 +737,7 @@ async function handleLogsChannel(selectInteraction, rootInteraction, guildConfig const channel = channelInteraction.channels.first(); guildConfig.ticketLogsChannelId = channel.id; - await client.db.set(getGuildConfigKey(guildId), guildConfig); + await setGuildConfig(client, guildId, guildConfig); await channelInteraction.followUp({ embeds: [successEmbed('Logs Channel Updated', `Ticket logs will be sent to ${channel}`)], @@ -790,7 +789,7 @@ async function handleTranscriptChannel(selectInteraction, rootInteraction, guild const channel = channelInteraction.channels.first(); guildConfig.ticketTranscriptChannelId = channel.id; - await client.db.set(getGuildConfigKey(guildId), guildConfig); + await setGuildConfig(client, guildId, guildConfig); await channelInteraction.followUp({ embeds: [successEmbed('Transcript Channel Updated', `Transcripts will be sent to ${channel}`)], @@ -982,7 +981,7 @@ async function handleDeleteSystem(btnInteraction, rootInteraction, guildConfig, } try { - const { pgConfig } = await import('../../../config/postgres.js'); + const { pgConfig } = await import('../../../config/database/postgres.js'); if (client.db?.db?.pool && typeof client.db.db.isAvailable === 'function' && client.db.db.isAvailable()) { await client.db.db.pool.query( `DELETE FROM ${pgConfig.tables.tickets} WHERE guild_id = $1`, @@ -996,7 +995,7 @@ async function handleDeleteSystem(btnInteraction, rootInteraction, guildConfig, for (const key of keysToDelete) { delete guildConfig[key]; } - await client.db.set(getGuildConfigKey(guildId), guildConfig); + await setGuildConfig(client, guildId, guildConfig); await submitted.followUp({ embeds: [ diff --git a/src/commands/Ticket/priority.js b/src/commands/Ticket/priority.js index ec5f2968e4..fde4195156 100644 --- a/src/commands/Ticket/priority.js +++ b/src/commands/Ticket/priority.js @@ -2,9 +2,9 @@ import { getColor } from '../../config/bot.js'; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; import { successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; +import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getTicketPermissionContext } from '../../utils/ticketPermissions.js'; +import { getTicketPermissionContext } from '../../utils/ticket/ticketPermissions.js'; import { updateTicketPriority } from '../../services/ticket.js'; export default { @@ -28,67 +28,40 @@ export default { category: "Ticket", async execute(interaction, guildConfig, 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.' }); - } + const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + if (!deferred) { + return; + } - if (!permissionContext.canManageTicket) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the `Manage Channels` permission or the configured `Ticket Staff Role` to change ticket priority.' }); - } + const permissionContext = await getTicketPermissionContext({ client, interaction }); + if (!permissionContext.ticketData) { + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'This command can only be used in a valid ticket channel.' }); + } - const priorityLevel = interaction.options.getString("level"); - const result = await updateTicketPriority(interaction.channel, priorityLevel, interaction.user); - - if (!result.success) { - logger.warn('Priority update failed - not a valid ticket channel', { - userId: interaction.user.id, - channelId: interaction.channel.id, - guildId: interaction.guildId, - error: result.error - }); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'result.error || "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 `Manage Channels` permission or the configured `Ticket Staff Role` to change ticket priority.' }); + } - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "Priority Updated", - `Ticket priority set to **${priorityLevel.toUpperCase()}**.`, - ), - ], - }); + const priorityLevel = interaction.options.getString("level"); + await updateTicketPriority(interaction.channel, priorityLevel, interaction.user); - logger.info('Ticket priority updated successfully', { - userId: interaction.user.id, - userTag: interaction.user.tag, - channelId: interaction.channel.id, - channelName: interaction.channel.name, - guildId: interaction.guildId, - priority: priorityLevel, - commandName: 'priority' - }); + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + successEmbed( + "Priority Updated", + `Ticket priority set to **${priorityLevel.toUpperCase()}**.`, + ), + ], + }); - } catch (error) { - logger.error('Error executing priority command', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - channelId: interaction.channel?.id, - guildId: interaction.guildId, - commandName: 'priority' - }); - await handleInteractionError(interaction, error, { - commandName: 'priority', - source: 'ticket_priority_command' - }); - } + logger.info('Ticket priority updated successfully', { + userId: interaction.user.id, + userTag: interaction.user.tag, + channelId: interaction.channel.id, + channelName: interaction.channel.name, + guildId: interaction.guildId, + priority: priorityLevel, + commandName: 'priority' + }); }, -}; \ No newline at end of file +}; diff --git a/src/commands/Ticket/ticket.js b/src/commands/Ticket/ticket.js index d7f4e9a2c1..982747b118 100644 --- a/src/commands/Ticket/ticket.js +++ b/src/commands/Ticket/ticket.js @@ -1,7 +1,7 @@ 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 { getGuildConfig, setGuildConfig } from '../../services/config/guildConfig.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; @@ -94,27 +94,25 @@ export default { category: "ticket", async execute(interaction, config, client) { - try { - - const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - if (!deferred) { - return; - } + const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + 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' - }); - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the `Manage Channels` permission for this action.' }); - } + if ( + !interaction.member.permissions.has( + PermissionFlagsBits.ManageChannels, + ) + ) { + logger.warn('Ticket command permission denied', { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'ticket' + }); + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the `Manage Channels` permission for this action.' }); + } - const subcommand = interaction.options.getSubcommand(); + const subcommand = interaction.options.getSubcommand(); if (subcommand === "dashboard") { return ticketConfig.execute(interaction, config, client); @@ -170,18 +168,20 @@ description: panelMessage, currentConfig.maxTicketsPerUser = maxTicketsPerUser; currentConfig.dmOnClose = dmOnClose; - 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 - }); - } + await setGuildConfig(client, interaction.guildId, currentConfig); + logger.info('Ticket configuration saved', { + guildId: interaction.guildId, + categoryId: categoryChannel?.id, + closedCategoryId: closedCategoryChannel?.id, + staffRoleId: staffRole?.id, + maxTickets: maxTicketsPerUser, + dmOnClose: dmOnClose, + }); + } else { + logger.error('Ticket setup: database unavailable, panel sent but configuration was NOT saved', { + guildId: interaction.guildId, + }); + } let successMessage = `The ticket creation panel has been sent to ${panelChannel}.`; @@ -295,18 +295,5 @@ description: panelMessage, } } } - } catch (error) { - logger.error('Error executing ticket command', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'ticket' - }); - await handleInteractionError(interaction, error, { - commandName: 'ticket', - source: 'ticket_command_main' - }); - } } }; \ No newline at end of file diff --git a/src/commands/Tools/baseconvert.js b/src/commands/Tools/baseconvert.js index e1f5f8cc50..1ef560dc7e 100644 --- a/src/commands/Tools/baseconvert.js +++ b/src/commands/Tools/baseconvert.js @@ -1,7 +1,7 @@ import { SlashCommandBuilder } 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 { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { getColor } from '../../config/bot.js'; @@ -124,115 +124,107 @@ export default { return; } - try { - const numberStr = interaction.options.getString('number').trim(); - const fromBase = interaction.options.getString('from'); - const toBase = interaction.options.getString('to'); - - const { prefix: fromPrefix, name: fromName } = BASE_ALPHABETS[fromBase]; - - const cleanNumber = fromPrefix && numberStr.startsWith(fromPrefix) - ? numberStr.slice(fromPrefix.length) - : numberStr; - - if (!cleanNumber) { - return replyUserError(interaction, { - type: ErrorTypes.VALIDATION, - message: 'You must provide a number to convert.\n\n**Example:** `/baseconvert number:1010 from:BIN to:DEC`', - }); + const numberStr = interaction.options.getString('number').trim(); + const fromBase = interaction.options.getString('from'); + const toBase = interaction.options.getString('to'); + + const { prefix: fromPrefix, name: fromName } = BASE_ALPHABETS[fromBase]; + + const cleanNumber = fromPrefix && numberStr.startsWith(fromPrefix) + ? numberStr.slice(fromPrefix.length) + : numberStr; + + if (!cleanNumber) { + return replyUserError(interaction, { + type: ErrorTypes.VALIDATION, + message: 'You must provide a number to convert.\n\n**Example:** `/baseconvert number:1010 from:BIN to:DEC`', + }); + } + + const alphabet = BASE_ALPHABETS[fromBase].alphabet; + const regex = new RegExp(`^[${alphabet}]+$`, 'i'); + + if (!regex.test(cleanNumber)) { + let examples = ''; + if (fromBase === 'BIN') { + examples = '\n\n**Valid:** 101, 1010, 11111 | **Invalid:** 5 (digit 5 not allowed)'; + } else if (fromBase === 'OCT') { + examples = '\n\n**Valid:** 77, 123, 755 | **Invalid:** 8 (only 0-7 allowed)'; + } else if (fromBase === 'DEC') { + examples = '\n\n**Valid:** 42, 123, 999 | **Invalid:** 12.34 (no decimals)'; + } else if (fromBase === 'HEX') { + examples = '\n\n**Valid:** FF, A1B2, DEADBEEF | **Invalid:** G (only 0-9, A-F)'; } - - const alphabet = BASE_ALPHABETS[fromBase].alphabet; - const regex = new RegExp(`^[${alphabet}]+$`, 'i'); - - if (!regex.test(cleanNumber)) { - let examples = ''; - if (fromBase === 'BIN') { - examples = '\n\n**Valid:** 101, 1010, 11111 | **Invalid:** 5 (digit 5 not allowed)'; - } else if (fromBase === 'OCT') { - examples = '\n\n**Valid:** 77, 123, 755 | **Invalid:** 8 (only 0-7 allowed)'; - } else if (fromBase === 'DEC') { - examples = '\n\n**Valid:** 42, 123, 999 | **Invalid:** 12.34 (no decimals)'; - } else if (fromBase === 'HEX') { - examples = '\n\n**Valid:** FF, A1B2, DEADBEEF | **Invalid:** G (only 0-9, A-F)'; - } - logger.warn(`Invalid base conversion input: ${cleanNumber} for base ${fromBase}`); - return replyUserError(interaction, { - type: ErrorTypes.VALIDATION, - message: `You provided: \`${cleanNumber}\`\n\nValid characters: \`${alphabet}\`${examples}`, - }); + logger.warn(`Invalid base conversion input: ${cleanNumber} for base ${fromBase}`); + return replyUserError(interaction, { + type: ErrorTypes.VALIDATION, + message: `You provided: \`${cleanNumber}\`\n\nValid characters: \`${alphabet}\`${examples}`, + }); + } + + let decimalValue; + try { + if (fromBase === 'B64') { + decimalValue = parseBigIntFromBase(cleanNumber, fromBase); + } else { + decimalValue = parseBigIntFromBase(cleanNumber, fromBase); } - - let decimalValue; + } catch (error) { + logger.error('Base conversion parse error:', error); + return replyUserError(interaction, { + type: ErrorTypes.VALIDATION, + message: 'The number is too large to process.\n\nTry with a smaller number.', + }); + } + + if (toBase) { + const { prefix: toPrefix, name: toName } = BASE_ALPHABETS[toBase]; + let result; + try { - if (fromBase === 'B64') { - decimalValue = parseBigIntFromBase(cleanNumber, fromBase); - } else { - decimalValue = parseBigIntFromBase(cleanNumber, fromBase); - } + result = formatBigIntToBase(decimalValue, toBase); + + const embed = successEmbed( + '🔄 Base Conversion Result', + `**From ${fromName} (${fromBase}):** \`${fromPrefix}${cleanNumber}\`\n` + + `**To ${toName} (${toBase}):** \`${toPrefix}${result}\`\n` + + `**Decimal:** \`${decimalValue.toLocaleString()}\`` + ); + embed.setColor(getColor('success')); + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + } catch (error) { - logger.error('Base conversion parse error:', error); - return replyUserError(interaction, { + logger.error(`Base conversion error to ${toName}:`, error); + await replyUserError(interaction, { type: ErrorTypes.VALIDATION, - message: 'The number is too large to process.\n\nTry with a smaller number.', + message: 'The result would be too large or incompatible.\n\nTry with a smaller number or different target base.', }); } - - if (toBase) { - const { prefix: toPrefix, name: toName } = BASE_ALPHABETS[toBase]; - let result; - + + } else { + let description = `**Input (${fromName}):** \`${fromPrefix}${cleanNumber}\`\n`; + description += `**Decimal:** \`${decimalValue.toLocaleString()}\`\n\n`; + + for (const [baseKey, { prefix, name }] of Object.entries(BASE_ALPHABETS)) { + if (baseKey === fromBase) continue; + try { - result = formatBigIntToBase(decimalValue, toBase); - - const embed = successEmbed( - '🔄 Base Conversion Result', - `**From ${fromName} (${fromBase}):** \`${fromPrefix}${cleanNumber}\`\n` + - `**To ${toName} (${toBase}):** \`${toPrefix}${result}\`\n` + - `**Decimal:** \`${decimalValue.toLocaleString()}\`` - ); - embed.setColor(getColor('success')); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - + let value = formatBigIntToBase(decimalValue, baseKey); + + description += `**${name} (${baseKey}):** \`${prefix}${value}\`\n`; } catch (error) { - logger.error(`Base conversion error to ${toName}:`, error); - await replyUserError(interaction, { - type: ErrorTypes.VALIDATION, - message: 'The result would be too large or incompatible.\n\nTry with a smaller number or different target base.', - }); + description += `**${name} (${baseKey}):** *Too large to convert*\n`; } - - } else { - let description = `**Input (${fromName}):** \`${fromPrefix}${cleanNumber}\`\n`; - description += `**Decimal:** \`${decimalValue.toLocaleString()}\`\n\n`; - - for (const [baseKey, { prefix, name }] of Object.entries(BASE_ALPHABETS)) { - if (baseKey === fromBase) continue; - - try { - let value = formatBigIntToBase(decimalValue, baseKey); - - description += `**${name} (${baseKey}):** \`${prefix}${value}\`\n`; - } catch (error) { - description += `**${name} (${baseKey}):** *Too large to convert*\n`; - } - } - - const embed = successEmbed( - '🔄 Base Conversion Results', - description - ); - embed.setColor(getColor('primary')); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); } - - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'baseconvert' - }); + + const embed = successEmbed( + '🔄 Base Conversion Results', + description + ); + embed.setColor(getColor('primary')); + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); } }, }; \ No newline at end of file diff --git a/src/commands/Tools/calculate.js b/src/commands/Tools/calculate.js index 730a00cc61..233b88ab8f 100644 --- a/src/commands/Tools/calculate.js +++ b/src/commands/Tools/calculate.js @@ -1,7 +1,7 @@ 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 { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { evaluateMathExpression } from '../../utils/safeMathParser.js'; @@ -40,293 +40,295 @@ export default { return; } -try { + const expression = interaction.options.getString("expression"); - 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' + + '✅ Supported: Numbers, decimals, + - * / ^ %, sin cos tan sqrt abs log exp, pi e, ()\n' + + '❌ Not supported: Brackets, curly braces, and other symbols' + }); + } - 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.VALIDATION, + message: '**Contains blocked code patterns.**\n\n' + + '🚫 **Blocked:** import, require, eval, Function, setTimeout, setInterval, process, fs, document, window, fetch, loops, async/await\n\n' + + 'Code-like syntax is not allowed in calculations.' + }); } + } - 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 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" + 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 = - "```json\n" + JSON.stringify(result, null, 2) + "\n```"; - } else { - formattedResult = String(result); + 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 userId = interaction.user.id; + if (!calculationHistory.has(userId)) { + calculationHistory.set(userId, []); + } - const history = calculationHistory.get(userId); - history.unshift({ - expression, - result: formattedResult, - timestamp: Date.now(), - }); + const history = calculationHistory.get(userId); + history.unshift({ + expression, + result: formattedResult, + timestamp: Date.now(), + }); - if (history.length > MAX_HISTORY) { - history.pop(); - } + 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 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, }); - 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"); + 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: `📜 **Your Calculation History**\n\n${historyText}`, + content: "No calculation history found.", 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 - }); + 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; + } - 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; - } + let operator = ""; + + switch (operation) { + case "add": + operator = "+"; + break; + case "subtract": + operator = "-"; + break; + case "multiply": + operator = "*"; + break; + case "divide": + operator = "/"; + break; + } - } catch (error) { - logger.error("Button interaction error:", error); - if (!i.deferred && !i.replied) { - await i.followUp({ - content: "An error occurred while processing your request.", + 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; } - }); - 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), - ), + } 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] }) - .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.'; + interaction + .editReply({ + components: [disabledRow], + content: + "⏱️ This calculator has expired. Use the command again to perform more calculations.", + }) + .catch(console.error); } else { - errorMessage += 'Please check the syntax and try again.'; + const disabledRow = ActionRowBuilder.from( + row, + ).setComponents( + row.components.map((component) => + ButtonBuilder.from(component).setDisabled(true), + ), + ); + + interaction + .editReply({ components: [disabledRow] }) + .catch(console.error); } - - await replyUserError(interaction, { - type: ErrorTypes.VALIDATION, - message: errorMessage, - }); - } + }); } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'calculate' + 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, }); } }, diff --git a/src/commands/Tools/countdown.js b/src/commands/Tools/countdown.js index 80a85ecf92..d7561ddbf3 100644 --- a/src/commands/Tools/countdown.js +++ b/src/commands/Tools/countdown.js @@ -1,7 +1,6 @@ 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'; @@ -47,58 +46,51 @@ export default { 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 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; + const totalSeconds = minutes * 60 + seconds; - if (totalSeconds <= 0) { - throw new Error("Please specify a duration of at least 1 second."); - } + 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."); - } + 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 endTime = Date.now() + totalSeconds * 1000; + const countdownId = `${interaction.channelId}-${Date.now()}`; - const row = createControlButtons(countdownId); + const row = createControlButtons(countdownId); - const initialEmbed = successEmbed( - `⏱️ ${title}`, - `Time remaining: **${formatTime(totalSeconds)}**`, - ); + const initialEmbed = successEmbed( + `⏱️ ${title}`, + `Time remaining: **${formatTime(totalSeconds)}**`, + ); - const message = await interaction.channel.send({ - embeds: [initialEmbed], - components: [row], - }); + 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, - }; + const countdownData = { + message, + endTime, + remainingTime: totalSeconds * 1000, + isPaused: false, + title, + lastUpdate: Date.now(), + interval: null, + }; - activeCountdowns.set(countdownId, countdownData); - startCountdown(countdownId, countdownData, activeCountdowns); + 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' - }); - } + await InteractionHelper.safeEditReply(interaction, { + content: "✅ Countdown started!", + flags: MessageFlags.Ephemeral, + }); }, }; \ No newline at end of file diff --git a/src/commands/Tools/generatepassword.js b/src/commands/Tools/generatepassword.js index 1d524e34a6..51628bfed0 100644 --- a/src/commands/Tools/generatepassword.js +++ b/src/commands/Tools/generatepassword.js @@ -1,8 +1,9 @@ +import { webcrypto as crypto } from 'node:crypto'; import { getColor } from '../../config/bot.js'; import { SlashCommandBuilder, MessageFlags } 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 { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() @@ -41,112 +42,105 @@ export default { return; } - try { - const length = interaction.options.getInteger('length') || 16; - const includeUppercase = interaction.options.getBoolean('uppercase') ?? true; - const includeNumbers = interaction.options.getBoolean('numbers') ?? true; - const includeSymbols = interaction.options.getBoolean('symbols') ?? true; - - if (length < 8 || length > 50) { - await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: '\'Password must be 8-50 characters. You provided:\' + length' }); - return; - } - - const lowercase = 'abcdefghijklmnopqrstuvwxyz'; - const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; - const numbers = '0123456789'; - const symbols = '!@#$%^&*()_+-=[]{}|;:,.<>?'; - - let chars = lowercase; - if (includeUppercase) chars += uppercase; - if (includeNumbers) chars += numbers; - if (includeSymbols) chars += symbols; - - let password = ''; - const randomValues = new Uint32Array(length); - crypto.getRandomValues(randomValues); - - for (let i = 0; i < length; i++) { - const randomIndex = randomValues[i] % chars.length; - password += chars[randomIndex]; - } - - if (includeUppercase && !/[A-Z]/.test(password)) { - const randomIndex = Math.floor(Math.random() * length); - const randomUpper = uppercase[Math.floor(Math.random() * uppercase.length)]; - password = password.substring(0, randomIndex) + randomUpper + password.substring(randomIndex + 1); - } - - if (includeNumbers && !/[0-9]/.test(password)) { - const randomIndex = Math.floor(Math.random() * length); - const randomNumber = numbers[Math.floor(Math.random() * numbers.length)]; - password = password.substring(0, randomIndex) + randomNumber + password.substring(randomIndex + 1); - } - - if (includeSymbols && !/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(password)) { - const randomIndex = Math.floor(Math.random() * length); - const randomSymbol = symbols[Math.floor(Math.random() * symbols.length)]; - password = password.substring(0, randomIndex) + randomSymbol + password.substring(randomIndex + 1); - } - - let strength = 'Weak'; - let strengthEmoji = '🔴'; -let strengthColor = getColor('error'); - - const hasLower = /[a-z]/.test(password); - const hasUpper = /[A-Z]/.test(password); - const hasNumber = /[0-9]/.test(password); - const hasSymbol = /[^a-zA-Z0-9]/.test(password); - - const uniqueChars = new Set(password).size; - const uniqueRatio = uniqueChars / password.length; - - let score = 0; - score += password.length * 4; - score += (password.length - (password.match(/[a-z]/g) || []).length) * 2; - score += (password.length - (password.match(/[A-Z]/g) || []).length) * 2; - score += (password.match(/[0-9]/g) || []).length * 4; - score += (password.match(/[^a-zA-Z0-9]/g) || []).length * 6; - - if (uniqueRatio < 0.5) score *= 0.7; - if (hasLower && hasUpper) score *= 1.2; - if (hasNumber) score *= 1.2; - if (hasSymbol) score *= 1.3; - - if (score > 80) { - strength = 'Very Strong'; - strengthEmoji = '🟢'; -strengthColor = getColor('success'); - } else if (score > 60) { - strength = 'Strong'; - strengthEmoji = '🟢'; -strengthColor = getColor('success'); - } else if (score > 40) { - strength = 'Good'; - strengthEmoji = '🟡'; -strengthColor = getColor('warning'); - } else if (score > 20) { - strength = 'Weak'; - strengthEmoji = '🟠'; -strengthColor = getColor('warning'); - } - - const embed = successEmbed( - '🔑 Generated Password', - `**Password:** ||\`${password}\`||\n` + - `**Length:** ${password.length} characters\n` + - `**Strength:** ${strengthEmoji} ${strength}\n` + - `**Contains:** ${hasLower ? 'Lowercase' : ''}${hasUpper ? ', Uppercase' : ''}${hasNumber ? ', Numbers' : ''}${hasSymbol ? ', Symbols' : ''}` - ).setColor(strengthColor); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - }); - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'generatepassword' - }); + const length = interaction.options.getInteger('length') || 16; + const includeUppercase = interaction.options.getBoolean('uppercase') ?? true; + const includeNumbers = interaction.options.getBoolean('numbers') ?? true; + const includeSymbols = interaction.options.getBoolean('symbols') ?? true; + + if (length < 8 || length > 50) { + await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: `Password must be 8-50 characters. You provided: ${length}` }); + return; + } + + const lowercase = 'abcdefghijklmnopqrstuvwxyz'; + const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + const numbers = '0123456789'; + const symbols = '!@#$%^&*()_+-=[]{}|;:,.<>?'; + + let chars = lowercase; + if (includeUppercase) chars += uppercase; + if (includeNumbers) chars += numbers; + if (includeSymbols) chars += symbols; + + let password = ''; + const randomValues = new Uint32Array(length); + crypto.getRandomValues(randomValues); + + for (let i = 0; i < length; i++) { + const randomIndex = randomValues[i] % chars.length; + password += chars[randomIndex]; + } + + if (includeUppercase && !/[A-Z]/.test(password)) { + const randomIndex = Math.floor(Math.random() * length); + const randomUpper = uppercase[Math.floor(Math.random() * uppercase.length)]; + password = password.substring(0, randomIndex) + randomUpper + password.substring(randomIndex + 1); + } + + if (includeNumbers && !/[0-9]/.test(password)) { + const randomIndex = Math.floor(Math.random() * length); + const randomNumber = numbers[Math.floor(Math.random() * numbers.length)]; + password = password.substring(0, randomIndex) + randomNumber + password.substring(randomIndex + 1); + } + + if (includeSymbols && !/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(password)) { + const randomIndex = Math.floor(Math.random() * length); + const randomSymbol = symbols[Math.floor(Math.random() * symbols.length)]; + password = password.substring(0, randomIndex) + randomSymbol + password.substring(randomIndex + 1); } + + let strength = 'Weak'; + let strengthEmoji = '🔴'; + let strengthColor = getColor('error'); + + const hasLower = /[a-z]/.test(password); + const hasUpper = /[A-Z]/.test(password); + const hasNumber = /[0-9]/.test(password); + const hasSymbol = /[^a-zA-Z0-9]/.test(password); + + const uniqueChars = new Set(password).size; + const uniqueRatio = uniqueChars / password.length; + + let score = 0; + score += password.length * 4; + score += (password.length - (password.match(/[a-z]/g) || []).length) * 2; + score += (password.length - (password.match(/[A-Z]/g) || []).length) * 2; + score += (password.match(/[0-9]/g) || []).length * 4; + score += (password.match(/[^a-zA-Z0-9]/g) || []).length * 6; + + if (uniqueRatio < 0.5) score *= 0.7; + if (hasLower && hasUpper) score *= 1.2; + if (hasNumber) score *= 1.2; + if (hasSymbol) score *= 1.3; + + if (score > 80) { + strength = 'Very Strong'; + strengthEmoji = '🟢'; + strengthColor = getColor('success'); + } else if (score > 60) { + strength = 'Strong'; + strengthEmoji = '🟢'; + strengthColor = getColor('success'); + } else if (score > 40) { + strength = 'Good'; + strengthEmoji = '🟡'; + strengthColor = getColor('warning'); + } else if (score > 20) { + strength = 'Weak'; + strengthEmoji = '🟠'; + strengthColor = getColor('warning'); + } + + const embed = successEmbed( + '🔑 Generated Password', + `**Password:** ||\`${password}\`||\n` + + `**Length:** ${password.length} characters\n` + + `**Strength:** ${strengthEmoji} ${strength}\n` + + `**Contains:** ${hasLower ? 'Lowercase' : ''}${hasUpper ? ', Uppercase' : ''}${hasNumber ? ', Numbers' : ''}${hasSymbol ? ', Symbols' : ''}` + ).setColor(strengthColor); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [embed], + }); }, }; \ No newline at end of file diff --git a/src/commands/Tools/hexcolor.js b/src/commands/Tools/hexcolor.js index 0f3e2a3f5f..36736e3a6e 100644 --- a/src/commands/Tools/hexcolor.js +++ b/src/commands/Tools/hexcolor.js @@ -4,6 +4,7 @@ import { logger } from '../../utils/logger.js'; import { getColor } from '../../config/bot.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; export default { data: new SlashCommandBuilder() .setName('hexcolor') diff --git a/src/commands/Tools/poll.js b/src/commands/Tools/poll.js index fe9df4eecf..772f1500c5 100644 --- a/src/commands/Tools/poll.js +++ b/src/commands/Tools/poll.js @@ -1,7 +1,6 @@ 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'; const EMOJIS = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣', '🔟']; @@ -70,51 +69,44 @@ export default { return; } - try { - const question = interaction.options.getString('question'); - const isAnonymous = interaction.options.getBoolean('anonymous') || false; - - const options = []; - for (let i = 1; i <= MAX_OPTIONS; i++) { - const option = interaction.options.getString(`option${i}`); - if (option) options.push(option); - } - - if (options.length < 2) { - throw new Error("You must provide at least 2 options for the poll."); - } - - let description = `**${question}**\n\n`; - options.forEach((option, index) => { - description += `${EMOJIS[index]} ${option}\n`; - }); - - if (isAnonymous) { - description += '\n*This is an anonymous poll. Votes are not tracked to users.*'; - } else { - description += '\n*React with the emoji to vote!*'; - } - - const embed = successEmbed( - `📊 ${isAnonymous ? 'Anonymous ' : ''}Poll`, - description - ); - - const message = await interaction.channel.send({ embeds: [embed] }); - - for (let i = 0; i < options.length; i++) { - await message.react(EMOJIS[i]); - await new Promise(resolve => setTimeout(resolve, 500)); - } - - await InteractionHelper.safeEditReply(interaction, { - content: '✅ Poll created successfully!', - }); - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'poll' - }); + const question = interaction.options.getString('question'); + const isAnonymous = interaction.options.getBoolean('anonymous') || false; + + const options = []; + for (let i = 1; i <= MAX_OPTIONS; i++) { + const option = interaction.options.getString(`option${i}`); + if (option) options.push(option); } + + if (options.length < 2) { + throw new Error("You must provide at least 2 options for the poll."); + } + + let description = `**${question}**\n\n`; + options.forEach((option, index) => { + description += `${EMOJIS[index]} ${option}\n`; + }); + + if (isAnonymous) { + description += '\n*This is an anonymous poll. Votes are not tracked to users.*'; + } else { + description += '\n*React with the emoji to vote!*'; + } + + const embed = successEmbed( + `📊 ${isAnonymous ? 'Anonymous ' : ''}Poll`, + description + ); + + const message = await interaction.channel.send({ embeds: [embed] }); + + for (let i = 0; i < options.length; i++) { + await message.react(EMOJIS[i]); + await new Promise(resolve => setTimeout(resolve, 500)); + } + + await InteractionHelper.safeEditReply(interaction, { + content: '✅ Poll created successfully!', + }); }, }; \ No newline at end of file diff --git a/src/commands/Tools/randomuser.js b/src/commands/Tools/randomuser.js index c61a4bb98f..4dc15a6fd1 100644 --- a/src/commands/Tools/randomuser.js +++ b/src/commands/Tools/randomuser.js @@ -1,7 +1,7 @@ import { SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } 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 { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { getColor } from '../../config/bot.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; @@ -37,165 +37,157 @@ export default { return; } -try { - if (!interaction.guild) { - return replyUserError(interaction, { - type: ErrorTypes.VALIDATION, - message: 'This command can only be used in a server/guild.', - }); - } - - const role = interaction.options.getRole('role'); - const includeBots = interaction.options.getBoolean('bots') || false; - const onlineOnly = interaction.options.getBoolean('online') || false; - const shouldMention = interaction.options.getBoolean('mention') || false; - - let members = interaction.guild.members.cache.filter(member => { - if (member.user.bot && !includeBots) return false; - - if (onlineOnly && member.presence?.status === 'offline') return false; - - if (role && !member.roles.cache.has(role.id)) return false; - - return true; + if (!interaction.guild) { + return replyUserError(interaction, { + type: ErrorTypes.VALIDATION, + message: 'This command can only be used in a server/guild.', }); - - let memberArray = Array.from(members.values()); - - if (!includeBots) { - memberArray = memberArray.filter(member => !member.user.bot); - } - - if (memberArray.length === 0) { - let errorMessage = 'Could not find any users matching your filters:'; - if (role) errorMessage = `No users have the **${role.name}** role.`; - if (onlineOnly) errorMessage = 'No users are currently online.'; - if (role && onlineOnly) errorMessage = `No **${role.name}** members are online.`; - - return replyUserError(interaction, { - type: ErrorTypes.USER_INPUT, - message: errorMessage + '\n\nTry adjusting your filters.', - }); - } - - const randomIndex = Math.floor(Math.random() * memberArray.length); - const selectedMember = memberArray[randomIndex]; - - const user = selectedMember.user; - const joinDate = selectedMember.joinedAt; - const roles = selectedMember.roles.cache -.filter(role => role.id !== interaction.guild.id) - .sort((a, b) => b.position - a.position) - .map(role => role.toString()) -.slice(0, 10); - - const embed = successEmbed( - '🎲 Random User Selected', - shouldMention ? `${selectedMember}` : `**${user.username}**` - ) - .setThumbnail(user.displayAvatarURL({ dynamic: true, size: 256 })) - .addFields( - { name: 'Username', value: user.username, inline: true }, - { name: 'Bot', value: user.bot ? 'Yes' : 'No', inline: true }, - { name: `Roles (${roles.length})`, value: roles.length > 0 ? roles.slice(0, 5).join('') + (roles.length > 5 ? `+${roles.length - 5} more` : '') : 'No roles', inline: false } - ) - .setColor('primary'); - - const row = new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setCustomId(`randomuser_${interaction.user.id}_again`) - .setLabel('🎲 Pick Another User') - .setStyle(ButtonStyle.Primary) - ); - - const response = await interaction.editReply({ - content: shouldMention ? `${selectedMember}, you've been chosen!` : null, - embeds: [embed], - components: [row], - allowedMentions: { users: shouldMention ? [user.id] : [] } + } + + const role = interaction.options.getRole('role'); + const includeBots = interaction.options.getBoolean('bots') || false; + const onlineOnly = interaction.options.getBoolean('online') || false; + const shouldMention = interaction.options.getBoolean('mention') || false; + + let members = interaction.guild.members.cache.filter(member => { + if (member.user.bot && !includeBots) return false; + + if (onlineOnly && member.presence?.status === 'offline') return false; + + if (role && !member.roles.cache.has(role.id)) return false; + + return true; + }); + + let memberArray = Array.from(members.values()); + + if (!includeBots) { + memberArray = memberArray.filter(member => !member.user.bot); + } + + if (memberArray.length === 0) { + let errorMessage = 'Could not find any users matching your filters:'; + if (role) errorMessage = `No users have the **${role.name}** role.`; + if (onlineOnly) errorMessage = 'No users are currently online.'; + if (role && onlineOnly) errorMessage = `No **${role.name}** members are online.`; + + return replyUserError(interaction, { + type: ErrorTypes.USER_INPUT, + message: errorMessage + '\n\nTry adjusting your filters.', }); - - const filter = (i) => i.customId === `randomuser_${interaction.user.id}_again` && i.user.id === interaction.user.id; -const collector = response.createMessageComponentCollector({ filter, time: 300000 }); - - collector.on('collect', async (i) => { - try { - let newMembers = interaction.guild.members.cache.filter(member => { - if (member.user.bot && !includeBots) return false; - - if (onlineOnly && member.presence?.status === 'offline') return false; - - if (role && !member.roles.cache.has(role.id)) return false; - - return true; - }); - - let newMemberArray = Array.from(newMembers.values()); - - if (!includeBots) { - newMemberArray = newMemberArray.filter(member => !member.user.bot); - } - - if (newMemberArray.length === 0) { - await replyUserError(i, { - type: ErrorTypes.USER_INPUT, - message: 'No users found matching the criteria.', - }); - return; - } - - const newRandomIndex = Math.floor(Math.random() * newMemberArray.length); - const newSelectedMember = newMemberArray[newRandomIndex]; - const newUser = newSelectedMember.user; - - const newRoles = newSelectedMember.roles.cache - .filter(r => r.id !== interaction.guild.id) - .sort((a, b) => b.position - a.position) - .map(r => r.toString()) - .slice(0, 10); - - const newEmbed = successEmbed( - '🎲 Random User Selected', - shouldMention ? `${newSelectedMember}` : `**${newUser.username}**` - ) - .setThumbnail(newUser.displayAvatarURL({ dynamic: true, size: 256 })) - .addFields( - { name: 'Username', value: newUser.username, inline: true }, - { name: 'Bot', value: newUser.bot ? 'Yes' : 'No', inline: true }, - { name: `Roles (${newRoles.length})`, value: newRoles.length > 0 ? newRoles.slice(0, 5).join('') + (newRoles.length > 5 ? `+${newRoles.length - 5} more` : '') : 'No roles', inline: false } - ) - .setColor(newSelectedMember.displayHexColor || '#3498db'); - - await i.update({ - content: shouldMention ? `${newSelectedMember}, you've been chosen!` : null, - embeds: [newEmbed], - components: [row], - allowedMentions: { users: shouldMention ? [newUser.id] : [] } - }); - - } catch (error) { - logger.error('Button interaction error:', error); - await i.reply({ - content: 'An error occurred while selecting another user.', - flags: ['Ephemeral'] + } + + const randomIndex = Math.floor(Math.random() * memberArray.length); + const selectedMember = memberArray[randomIndex]; + + const user = selectedMember.user; + const joinDate = selectedMember.joinedAt; + const roles = selectedMember.roles.cache + .filter(role => role.id !== interaction.guild.id) + .sort((a, b) => b.position - a.position) + .map(role => role.toString()) + .slice(0, 10); + + const embed = successEmbed( + '🎲 Random User Selected', + shouldMention ? `${selectedMember}` : `**${user.username}**` + ) + .setThumbnail(user.displayAvatarURL({ dynamic: true, size: 256 })) + .addFields( + { name: 'Username', value: user.username, inline: true }, + { name: 'Bot', value: user.bot ? 'Yes' : 'No', inline: true }, + { name: `Roles (${roles.length})`, value: roles.length > 0 ? roles.slice(0, 5).join('') + (roles.length > 5 ? `+${roles.length - 5} more` : '') : 'No roles', inline: false } + ) + .setColor('primary'); + + const row = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId(`randomuser_${interaction.user.id}_again`) + .setLabel('🎲 Pick Another User') + .setStyle(ButtonStyle.Primary) + ); + + const response = await interaction.editReply({ + content: shouldMention ? `${selectedMember}, you've been chosen!` : null, + embeds: [embed], + components: [row], + allowedMentions: { users: shouldMention ? [user.id] : [] } + }); + + const filter = (i) => i.customId === `randomuser_${interaction.user.id}_again` && i.user.id === interaction.user.id; + const collector = response.createMessageComponentCollector({ filter, time: 300000 }); + + collector.on('collect', async (i) => { + try { + let newMembers = interaction.guild.members.cache.filter(member => { + if (member.user.bot && !includeBots) return false; + + if (onlineOnly && member.presence?.status === 'offline') return false; + + if (role && !member.roles.cache.has(role.id)) return false; + + return true; + }); + + let newMemberArray = Array.from(newMembers.values()); + + if (!includeBots) { + newMemberArray = newMemberArray.filter(member => !member.user.bot); + } + + if (newMemberArray.length === 0) { + await replyUserError(i, { + type: ErrorTypes.USER_INPUT, + message: 'No users found matching the criteria.', }); + return; } - }); - - collector.on('end', () => { - const disabledRow = ActionRowBuilder.from(row).setComponents( - ButtonBuilder.from(row.components[0]).setDisabled(true) - ); - - interaction.editReply({ components: [disabledRow] }).catch(console.error); - }); - - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'randomuser' - }); - } + + const newRandomIndex = Math.floor(Math.random() * newMemberArray.length); + const newSelectedMember = newMemberArray[newRandomIndex]; + const newUser = newSelectedMember.user; + + const newRoles = newSelectedMember.roles.cache + .filter(r => r.id !== interaction.guild.id) + .sort((a, b) => b.position - a.position) + .map(r => r.toString()) + .slice(0, 10); + + const newEmbed = successEmbed( + '🎲 Random User Selected', + shouldMention ? `${newSelectedMember}` : `**${newUser.username}**` + ) + .setThumbnail(newUser.displayAvatarURL({ dynamic: true, size: 256 })) + .addFields( + { name: 'Username', value: newUser.username, inline: true }, + { name: 'Bot', value: newUser.bot ? 'Yes' : 'No', inline: true }, + { name: `Roles (${newRoles.length})`, value: newRoles.length > 0 ? newRoles.slice(0, 5).join('') + (newRoles.length > 5 ? `+${newRoles.length - 5} more` : '') : 'No roles', inline: false } + ) + .setColor(newSelectedMember.displayHexColor || '#3498db'); + + await i.update({ + content: shouldMention ? `${newSelectedMember}, you've been chosen!` : null, + embeds: [newEmbed], + components: [row], + allowedMentions: { users: shouldMention ? [newUser.id] : [] } + }); + + } catch (error) { + logger.error('Button interaction error:', error); + await i.reply({ + content: 'An error occurred while selecting another user.', + flags: ['Ephemeral'] + }); + } + }); + + collector.on('end', () => { + const disabledRow = ActionRowBuilder.from(row).setComponents( + ButtonBuilder.from(row.components[0]).setDisabled(true) + ); + + interaction.editReply({ components: [disabledRow] }).catch(console.error); + }); }, }; \ No newline at end of file diff --git a/src/commands/Tools/shorten.js b/src/commands/Tools/shorten.js index 474e1ccc92..4b5b9af5f5 100644 --- a/src/commands/Tools/shorten.js +++ b/src/commands/Tools/shorten.js @@ -1,7 +1,7 @@ import { SlashCommandBuilder, MessageFlags } 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 { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { getColor } from '../../config/bot.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; @@ -37,93 +37,86 @@ export default { return; } + const url = interaction.options.getString("url"); + const custom = interaction.options.getString("custom"); + try { - const url = interaction.options.getString("url"); - const custom = interaction.options.getString("custom"); + new URL(url); + } catch (e) { + return replyUserError(interaction, { + type: ErrorTypes.VALIDATION, + message: 'Invalid URL format. Include http:// or https://', + }); + } - try { - new URL(url); - } catch (e) { - return replyUserError(interaction, { - type: ErrorTypes.VALIDATION, - message: 'Invalid URL format. Include http:// or https://', - }); - } + if (custom && !/^[a-zA-Z0-9_-]+$/.test(custom)) { + return replyUserError(interaction, { + type: ErrorTypes.VALIDATION, + message: 'Custom URL can only contain letters, numbers, underscores, and hyphens.', + }); + } - if (custom && !/^[a-zA-Z0-9_-]+$/.test(custom)) { - return replyUserError(interaction, { - type: ErrorTypes.VALIDATION, - message: 'Custom URL can only contain letters, numbers, underscores, and hyphens.', - }); - } + let apiUrl = `https://is.gd/create.php?format=simple&url=${encodeURIComponent(url)}`; + if (custom) { + apiUrl += `&shorturl=${encodeURIComponent(custom)}`; + } - let apiUrl = `https://is.gd/create.php?format=simple&url=${encodeURIComponent(url)}`; - if (custom) { - apiUrl += `&shorturl=${encodeURIComponent(custom)}`; - } + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 10000); + let response; + try { + response = await fetch(apiUrl, { + signal: controller.signal, + headers: { + 'User-Agent': 'TitanBot URL Shortener/1.0' + } + }); + } catch (networkError) { + const message = networkError?.name === 'AbortError' + ? 'The URL shortener timed out. Please try again in a moment.' + : 'Unable to reach the URL shortener service right now. Please try again later.'; + return replyUserError(interaction, { + type: ErrorTypes.NETWORK, + message, + }); + } finally { + clearTimeout(timeout); + } - let response; - try { - response = await fetch(apiUrl, { - signal: controller.signal, - headers: { - 'User-Agent': 'TitanBot URL Shortener/1.0' - } - }); - } catch (networkError) { - const message = networkError?.name === 'AbortError' - ? 'The URL shortener timed out. Please try again in a moment.' - : 'Unable to reach the URL shortener service right now. Please try again later.'; - return replyUserError(interaction, { - type: ErrorTypes.NETWORK, - message, - }); - } finally { - clearTimeout(timeout); - } + if (!response.ok) { + return replyUserError(interaction, { + type: ErrorTypes.UNKNOWN, + message: `Shortener service returned HTTP ${response.status}. Please try again later.`, + }); + } + + const shortUrl = await response.text(); - if (!response.ok) { + try { + new URL(shortUrl); + } catch (e) { + if (shortUrl.includes("already exists")) { return replyUserError(interaction, { - type: ErrorTypes.UNKNOWN, - message: `Shortener service returned HTTP ${response.status}. Please try again later.`, + type: ErrorTypes.VALIDATION, + message: 'That custom URL is already taken. Try a different one.', }); - } - - const shortUrl = await response.text(); - - try { - new URL(shortUrl); - } catch (e) { - if (shortUrl.includes("already exists")) { - return replyUserError(interaction, { - type: ErrorTypes.VALIDATION, - message: 'That custom URL is already taken. Try a different one.', - }); - } else if (shortUrl.includes("invalid")) { - return replyUserError(interaction, { - type: ErrorTypes.VALIDATION, - message: 'Invalid URL. Include http:// or https://', - }); - } + } else if (shortUrl.includes("invalid")) { return replyUserError(interaction, { - type: ErrorTypes.UNKNOWN, - message: `URL shortening failed: ${shortUrl}`, + type: ErrorTypes.VALIDATION, + message: 'Invalid URL. Include http:// or https://', }); } - - const embed = successEmbed('URL Shortened', `Here's your shortened URL: ${shortUrl}`); - embed.setColor(getColor('success')); - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - }); - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'shorten' + return replyUserError(interaction, { + type: ErrorTypes.UNKNOWN, + message: `URL shortening failed: ${shortUrl}`, }); } + + const embed = successEmbed('URL Shortened', `Here's your shortened URL: ${shortUrl}`); + embed.setColor(getColor('success')); + await InteractionHelper.safeEditReply(interaction, { + embeds: [embed], + }); }, }; \ No newline at end of file diff --git a/src/commands/Utility/avatar.js b/src/commands/Utility/avatar.js index f299a86f1f..b9caf87daf 100644 --- a/src/commands/Utility/avatar.js +++ b/src/commands/Utility/avatar.js @@ -1,7 +1,6 @@ import { SlashCommandBuilder, MessageFlags } from 'discord.js'; import { createEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { @@ -17,34 +16,20 @@ export default { ), async execute(interaction) { - try { - const user = interaction.options.getUser("target") || interaction.user; - const avatarUrl = user.displayAvatarURL({ size: 2048, dynamic: true }); + const user = interaction.options.getUser("target") || interaction.user; + const avatarUrl = user.displayAvatarURL({ size: 2048, dynamic: true }); - const embed = createEmbed({ - title: `${user.username}'s Avatar`, - description: `[Download Link](${avatarUrl})` - }) - .setImage(avatarUrl); + const embed = createEmbed({ + title: `${user.username}'s Avatar`, + description: `[Download Link](${avatarUrl})` + }) + .setImage(avatarUrl); - await InteractionHelper.safeReply(interaction, { embeds: [embed] }); - logger.info(`Avatar command executed`, { - userId: interaction.user.id, - targetUserId: user.id, - guildId: interaction.guildId - }); - } catch (error) { - logger.error(`Avatar command execution failed`, { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'avatar' - }); - await handleInteractionError(interaction, error, { - commandName: 'avatar', - source: 'avatar_command' - }); - } + await InteractionHelper.safeReply(interaction, { embeds: [embed] }); + logger.info(`Avatar command executed`, { + userId: interaction.user.id, + targetUserId: user.id, + guildId: interaction.guildId + }); } }; \ No newline at end of file diff --git a/src/commands/Utility/firstmsg.js b/src/commands/Utility/firstmsg.js index 8c4a589ece..1624921387 100644 --- a/src/commands/Utility/firstmsg.js +++ b/src/commands/Utility/firstmsg.js @@ -2,7 +2,6 @@ import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelT import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; export default { data: new SlashCommandBuilder() .setName("firstmsg") @@ -12,66 +11,51 @@ export default { category: "Utility", async execute(interaction, config, client) { - try { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`FirstMsg interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'firstmsg' - }); - return; - } - - const messages = await interaction.channel.messages.fetch({ - limit: 1, - after: '1', - cache: false - }); - - const firstMessage = messages.first(); - - if (!firstMessage) { - logger.info(`FirstMsg - no messages found in channel`, { - userId: interaction.user.id, - channelId: interaction.channelId, - guildId: interaction.guildId - }); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed('First Message', "No messages found in this channel!")], - }); - } - - const messageLink = `https://discord.com/channels/${interaction.guildId}/${interaction.channelId}/${firstMessage.id}`; - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "First Message in #" + interaction.channel.name, - `Message Link: ${messageLink}` - ), - ], + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`FirstMsg interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'firstmsg' }); + return; + } + + const messages = await interaction.channel.messages.fetch({ + limit: 1, + after: '1', + cache: false + }); + + const firstMessage = messages.first(); - logger.info(`FirstMsg command executed`, { + if (!firstMessage) { + logger.info(`FirstMsg - no messages found in channel`, { userId: interaction.user.id, channelId: interaction.channelId, - messageId: firstMessage.id, guildId: interaction.guildId }); - } catch (error) { - logger.error(`FirstMsg command execution failed`, { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - channelId: interaction.channelId, - guildId: interaction.guildId, - commandName: 'firstmsg' - }); - await handleInteractionError(interaction, error, { - commandName: 'firstmsg', - source: 'firstmsg_command' + return await InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('First Message', "No messages found in this channel!")], }); } + + const messageLink = `https://discord.com/channels/${interaction.guildId}/${interaction.channelId}/${firstMessage.id}`; + + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + successEmbed( + "First Message in #" + interaction.channel.name, + `Message Link: ${messageLink}` + ), + ], + }); + + logger.info(`FirstMsg command executed`, { + userId: interaction.user.id, + channelId: interaction.channelId, + messageId: firstMessage.id, + guildId: interaction.guildId + }); }, }; \ No newline at end of file diff --git a/src/commands/Utility/modules/report.js b/src/commands/Utility/modules/report.js index c54c9906e1..86b34b7773 100644 --- a/src/commands/Utility/modules/report.js +++ b/src/commands/Utility/modules/report.js @@ -1,9 +1,9 @@ import { createEmbed } from '../../../utils/embeds.js'; -import { getGuildConfig } from '../../../services/guildConfig.js'; +import { getGuildConfig } from '../../../services/config/guildConfig.js'; import { logEvent, EVENT_TYPES, resolveLogChannel } from '../../../services/loggingService.js'; -import { formatLogLine, resolveUserAuthor } from '../../../utils/logEmbeds.js'; +import { formatLogLine, resolveUserAuthor } from '../../../utils/logging/logEmbeds.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { handleInteractionError } from '../../../utils/errorHandler.js'; +import { replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; import { logger } from '../../../utils/logger.js'; export default { @@ -25,45 +25,40 @@ export default { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'The report channel has not been set up. Ask a moderator to use `/logging dashboard` or `/logging channel`.' }); } - try { - const ownerMention = interaction.guild.ownerId - ? `<@${interaction.guild.ownerId}> New report!` - : 'New report!'; + const ownerMention = interaction.guild.ownerId + ? `<@${interaction.guild.ownerId}> New report!` + : 'New report!'; - await logEvent({ - client, - guildId, - eventType: EVENT_TYPES.REPORT_FILE, - content: ownerMention, - data: { - title: 'User Report', - lines: [ - formatLogLine('Reported User', `${targetUser.tag} (\`${targetUser.id}\`)`), - formatLogLine('Reported By', `${interaction.user.tag} (\`${interaction.user.id}\`)`), - formatLogLine('Channel', interaction.channel.toString()), - ], - blockFields: [{ name: 'Reason', value: reason }], - author: await resolveUserAuthor(client, targetUser.id), - thumbnail: targetUser.displayAvatarURL(), - }, - }); + await logEvent({ + client, + guildId, + eventType: EVENT_TYPES.REPORT_FILE, + content: ownerMention, + data: { + title: 'User Report', + lines: [ + formatLogLine('Reported User', `${targetUser.tag} (\`${targetUser.id}\`)`), + formatLogLine('Reported By', `${interaction.user.tag} (\`${interaction.user.id}\`)`), + formatLogLine('Channel', interaction.channel.toString()), + ], + blockFields: [{ name: 'Reason', value: reason }], + author: await resolveUserAuthor(client, targetUser.id), + thumbnail: targetUser.displayAvatarURL(), + }, + }); - await InteractionHelper.safeEditReply(interaction, { - embeds: [createEmbed({ - title: 'Report Submitted', - description: `Your report against **${targetUser.tag}** has been successfully filed and sent to the moderation team. Thank you!`, - })], - }); + await InteractionHelper.safeEditReply(interaction, { + embeds: [createEmbed({ + title: 'Report Submitted', + description: `Your report against **${targetUser.tag}** has been successfully filed and sent to the moderation team. Thank you!`, + })], + }); - logger.info('Report submitted', { - userId: interaction.user.id, - reportedUserId: targetUser.id, - guildId, - reasonLength: reason.length, - }); - } catch (error) { - logger.error('report error:', error); - await handleInteractionError(interaction, error, { commandName: 'report', source: 'report' }); - } + logger.info('Report submitted', { + userId: interaction.user.id, + reportedUserId: targetUser.id, + guildId, + reasonLength: reason.length, + }); }, }; diff --git a/src/commands/Utility/modules/report_setchannel.js b/src/commands/Utility/modules/report_setchannel.js index 4068b0673e..0da04de754 100644 --- a/src/commands/Utility/modules/report_setchannel.js +++ b/src/commands/Utility/modules/report_setchannel.js @@ -4,6 +4,7 @@ import { setLogChannel } from '../../../services/loggingService.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; import { logger } from '../../../utils/logger.js'; +import { replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; export default { async execute(interaction, config, client) { if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { diff --git a/src/commands/Utility/report.js b/src/commands/Utility/report.js index 06518c390f..561aaf983c 100644 --- a/src/commands/Utility/report.js +++ b/src/commands/Utility/report.js @@ -1,6 +1,5 @@ import { SlashCommandBuilder, ChannelType } from 'discord.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; +import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import report from './modules/report.js'; @@ -44,21 +43,16 @@ export default { category: 'Utility', async execute(interaction, config, client) { - try { - const subcommand = interaction.options.getSubcommand(); + const subcommand = interaction.options.getSubcommand(); - if (subcommand === 'file') { - return await report.execute(interaction, config, client); - } - - if (subcommand === 'setchannel') { - return await reportSetchannel.execute(interaction, config, client); - } + if (subcommand === 'file') { + return await report.execute(interaction, config, client); + } - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Unknown subcommand.' }); - } catch (error) { - logger.error('report command error:', error); - await handleInteractionError(interaction, error, { commandName: 'report', source: 'report_command' }); + if (subcommand === 'setchannel') { + return await reportSetchannel.execute(interaction, config, client); } + + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Unknown subcommand.' }); }, }; \ No newline at end of file diff --git a/src/commands/Utility/serverinfo.js b/src/commands/Utility/serverinfo.js index 97035db430..fd417d6845 100644 --- a/src/commands/Utility/serverinfo.js +++ b/src/commands/Utility/serverinfo.js @@ -1,7 +1,6 @@ import { SlashCommandBuilder } from 'discord.js'; import { createEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { @@ -10,64 +9,50 @@ export default { .setDescription("Get detailed information about the server"), async execute(interaction) { - try { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`ServerInfo interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'serverinfo' - }); - return; - } - - const guild = interaction.guild; - const owner = await guild.fetchOwner(); - - const createdTimestamp = Math.floor(guild.createdAt.getTime() / 1000); - - const embed = createEmbed({ title: `Server Info: ${guild.name}`, description: `Server ID: ${guild.id}` }) - .setThumbnail(guild.iconURL({ size: 256 })) - .addFields( - { name: "Owner", value: owner.user.tag, inline: true }, - { name: "Members", value: `${guild.memberCount}`, inline: true }, - { - name: "Channels", - value: `${guild.channels.cache.size}`, - inline: true, - }, - { name: "Roles", value: `${guild.roles.cache.size}`, inline: true }, - { - name: "Boosts", - value: `Level ${guild.premiumTier} (${guild.premiumSubscriptionCount})`, - inline: true, - }, - { - name: "Creation Date", - value: ``, - inline: true, - }, - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.info(`ServerInfo command executed`, { - userId: interaction.user.id, - guildId: guild.id, - guildName: guild.name, - memberCount: guild.memberCount - }); - } catch (error) { - logger.error(`ServerInfo command execution failed`, { - error: error.message, - stack: error.stack, + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`ServerInfo interaction defer failed`, { userId: interaction.user.id, guildId: interaction.guildId, commandName: 'serverinfo' }); - await handleInteractionError(interaction, error, { - commandName: 'serverinfo', - source: 'serverinfo_command' - }); + return; } + + const guild = interaction.guild; + const owner = await guild.fetchOwner(); + + const createdTimestamp = Math.floor(guild.createdAt.getTime() / 1000); + + const embed = createEmbed({ title: `Server Info: ${guild.name}`, description: `Server ID: ${guild.id}` }) + .setThumbnail(guild.iconURL({ size: 256 })) + .addFields( + { name: "Owner", value: owner.user.tag, inline: true }, + { name: "Members", value: `${guild.memberCount}`, inline: true }, + { + name: "Channels", + value: `${guild.channels.cache.size}`, + inline: true, + }, + { name: "Roles", value: `${guild.roles.cache.size}`, inline: true }, + { + name: "Boosts", + value: `Level ${guild.premiumTier} (${guild.premiumSubscriptionCount})`, + inline: true, + }, + { + name: "Creation Date", + value: ``, + inline: true, + }, + ); + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + logger.info(`ServerInfo command executed`, { + userId: interaction.user.id, + guildId: guild.id, + guildName: guild.name, + memberCount: guild.memberCount + }); }, }; \ No newline at end of file diff --git a/src/commands/Utility/todo.js b/src/commands/Utility/todo.js index 8444f972a1..af82306340 100644 --- a/src/commands/Utility/todo.js +++ b/src/commands/Utility/todo.js @@ -2,7 +2,7 @@ import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelT import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { getFromDb, setInDb } from '../../utils/database.js'; import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; +import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import crypto from 'crypto'; @@ -169,347 +169,333 @@ export default { return listData; } - try { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Todo interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'todo' - }); - return; - } + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`Todo interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'todo' + }); + return; + } + + if (shareSubcommand) { + switch (shareSubcommand) { + case 'create': { + const listName = interaction.options.getString('name'); + const listId = generateShareId(); + + await getOrCreateSharedList(listId, userId, listName); + + const userSharedLists = await getFromDb(`user_shared_lists_${userId}`, []); + const sharedListsArray = Array.isArray(userSharedLists) ? userSharedLists : []; + if (!sharedListsArray.includes(listId)) { + sharedListsArray.push(listId); + await setInDb(`user_shared_lists_${userId}`, sharedListsArray); + } + + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + successEmbed( + "Shared List Created", + `Created shared list "${listName}" with ID: \`${listId}\`\n` + + `Use \`/todo share add list_id:${listId} user:@username\` to add members.` + ) + ] + }); + } + + case 'add': { + const listId = interaction.options.getString('list_id'); + const memberToAdd = interaction.options.getUser('user'); - if (shareSubcommand) { - switch (shareSubcommand) { - case 'create': { - const listName = interaction.options.getString('name'); - const listId = generateShareId(); - - await getOrCreateSharedList(listId, userId, listName); - - const userSharedLists = await getFromDb(`user_shared_lists_${userId}`, []); - const sharedListsArray = Array.isArray(userSharedLists) ? userSharedLists : []; - if (!sharedListsArray.includes(listId)) { - sharedListsArray.push(listId); - await setInDb(`user_shared_lists_${userId}`, sharedListsArray); + const listData = await getOrCreateSharedList(listId); + if (!listData) { + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Shared list not found.' }); + } + + if (listData.creatorId !== userId) { + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Only the list creator can add members.' }); + } + + if (!listData.members.includes(memberToAdd.id)) { + listData.members.push(memberToAdd.id); + await setInDb(`shared_todo_${listId}`, listData); + + const memberLists = await getFromDb(`user_shared_lists_${memberToAdd.id}`, []); + const memberListsArray = Array.isArray(memberLists) ? memberLists : []; + if (!memberListsArray.includes(listId)) { + memberListsArray.push(listId); + await setInDb(`user_shared_lists_${memberToAdd.id}`, memberListsArray); } - + return await InteractionHelper.safeEditReply(interaction, { embeds: [ - successEmbed( - "Shared List Created", - `Created shared list "${listName}" with ID: \`${listId}\`\n` + - `Use \`/todo share add list_id:${listId} user:@username\` to add members.` + successEmbed('Member Added', + `Added ${memberToAdd.username} to the shared list "${listData.name}"` ) ] }); + } else { + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'User is already a member of this list.' }); } - - case 'add': { - const listId = interaction.options.getString('list_id'); - const memberToAdd = interaction.options.getUser('user'); - - const listData = await getOrCreateSharedList(listId); - if (!listData) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Shared list not found.' }); - } - - if (listData.creatorId !== userId) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Only the list creator can add members.' }); - } - - if (!listData.members.includes(memberToAdd.id)) { - listData.members.push(memberToAdd.id); - await setInDb(`shared_todo_${listId}`, listData); - - const memberLists = await getFromDb(`user_shared_lists_${memberToAdd.id}`, []); - const memberListsArray = Array.isArray(memberLists) ? memberLists : []; - if (!memberListsArray.includes(listId)) { - memberListsArray.push(listId); - await setInDb(`user_shared_lists_${memberToAdd.id}`, memberListsArray); - } - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed('Member Added', - `Added ${memberToAdd.username} to the shared list "${listData.name}"` - ) - ] - }); - } else { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'User is already a member of this list.' }); - } + } + + case 'view': { + const listId = interaction.options.getString('list_id'); + const listData = await getOrCreateSharedList(listId); + + if (!listData) { + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Shared list not found.' }); + } + + if (!listData.members.includes(userId)) { + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You don\'t have access to this list.' }); } - - case 'view': { - const listId = interaction.options.getString('list_id'); - const listData = await getOrCreateSharedList(listId); - - if (!listData) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Shared list not found.' }); - } - - if (!listData.members.includes(userId)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You don\'t have access to this list.' }); - } - - if (listData.tasks.length === 0) { - const memberList = listData.members.map(memberId => { - const member = interaction.guild.members.cache.get(memberId); - return member ? member.user.username : `<@${memberId}>`; - }).join(','); - - const owner = interaction.guild.members.cache.get(listData.creatorId); - const ownerName = owner ? owner.user.username : `<@${listData.creatorId}>`; - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - `📋 **${listData.name}**\n\n` + - `👑 **Owner:** ${ownerName}\n` + - `👥 **Members:** ${memberList}\n\n` + - `*This list is currently empty. Use the "Add Task" button to add tasks!*`, - `Shared List (ID: \`${listId}\`)` - ) - ], - components: [ - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`shared_todo_add_${listId}`) - .setLabel('Add Task') - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId(`shared_todo_complete_${listId}`) - .setLabel('Complete Task') - .setStyle(ButtonStyle.Success), - new ButtonBuilder() - .setCustomId(`shared_todo_remove_${listId}`) - .setLabel('Remove Task') - .setStyle(ButtonStyle.Danger) - ) - ] - }); - } - - const taskList = listData.tasks - .map(task => - `${task.completed ? '✅' : '📝'} #${task.id} ${task.text}` + - `\`[${new Date(task.createdAt).toLocaleDateString()}]` + - (task.completed ? `• Completed by ${task.completedBy}` : '') + '`' - ) - .join('\n'); + if (listData.tasks.length === 0) { const memberList = listData.members.map(memberId => { const member = interaction.guild.members.cache.get(memberId); return member ? member.user.username : `<@${memberId}>`; }).join(','); - + const owner = interaction.guild.members.cache.get(listData.creatorId); const ownerName = owner ? owner.user.username : `<@${listData.creatorId}>`; - const fullListDisplay = `📋 **${listData.name}**\n\n` + - `👑 **Owner:** ${ownerName}\n` + - `👥 **Members:** ${memberList}\n\n` + - `**Tasks:**\n${taskList}`; - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed(`Shared List (ID: \`${listId}\`)`, fullListDisplay) - ], - components: [ - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`shared_todo_add_${listId}`) - .setLabel('Add Task') - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId(`shared_todo_complete_${listId}`) - .setLabel('Complete Task') - .setStyle(ButtonStyle.Success), - new ButtonBuilder() - .setCustomId(`shared_todo_remove_${listId}`) - .setLabel('Remove Task') - .setStyle(ButtonStyle.Danger) - ) - ] - }); - } - - case 'addtask': { - const listId = interaction.options.getString('list_id'); - const taskText = interaction.options.getString('task'); - - const listData = await getOrCreateSharedList(listId); - - if (!listData) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Shared list not found.' }); - } - - if (!listData.members.includes(userId)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You don\'t have access to this list.' }); - } - - const newTask = { - id: listData.nextId++, - text: taskText, - completed: false, - createdAt: new Date().toISOString(), - createdBy: userId - }; - - listData.tasks.push(newTask); - await setInDb(`shared_todo_${listId}`, listData); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed('Task Added', `Added "${taskText}" to the shared list "${listData.name}"`) - ] - }); + embeds: [ + successEmbed( + `📋 **${listData.name}**\n\n` + + `👑 **Owner:** ${ownerName}\n` + + `👥 **Members:** ${memberList}\n\n` + + `*This list is currently empty. Use the "Add Task" button to add tasks!*`, + `Shared List (ID: \`${listId}\`)` + ) + ], + components: [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`shared_todo_add_${listId}`) + .setLabel('Add Task') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId(`shared_todo_complete_${listId}`) + .setLabel('Complete Task') + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`shared_todo_remove_${listId}`) + .setLabel('Remove Task') + .setStyle(ButtonStyle.Danger) + ) + ] + }); } - case 'remove': { - const listId = interaction.options.getString('list_id'); - const taskNumber = interaction.options.getInteger('number'); + const taskList = listData.tasks + .map(task => + `${task.completed ? '✅' : '📝'} #${task.id} ${task.text}` + + `\`[${new Date(task.createdAt).toLocaleDateString()}]` + + (task.completed ? `• Completed by ${task.completedBy}` : '') + '`' + ) + .join('\n'); - const listData = await getOrCreateSharedList(listId); + const memberList = listData.members.map(memberId => { + const member = interaction.guild.members.cache.get(memberId); + return member ? member.user.username : `<@${memberId}>`; + }).join(','); - if (!listData) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Shared list not found.' }); - } + const owner = interaction.guild.members.cache.get(listData.creatorId); + const ownerName = owner ? owner.user.username : `<@${listData.creatorId}>`; - if (!listData.members.includes(userId)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You don\'t have access to this list.' }); - } + const fullListDisplay = `📋 **${listData.name}**\n\n` + + `👑 **Owner:** ${ownerName}\n` + + `👥 **Members:** ${memberList}\n\n` + + `**Tasks:**\n${taskList}`; - const taskIndex = listData.tasks.findIndex(task => task.id === taskNumber); - if (taskIndex === -1) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Task not found.' }); - } + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + successEmbed(`Shared List (ID: \`${listId}\`)`, fullListDisplay) + ], + components: [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`shared_todo_add_${listId}`) + .setLabel('Add Task') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId(`shared_todo_complete_${listId}`) + .setLabel('Complete Task') + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`shared_todo_remove_${listId}`) + .setLabel('Remove Task') + .setStyle(ButtonStyle.Danger) + ) + ] + }); + } - const [removedTask] = listData.tasks.splice(taskIndex, 1); - await setInDb(`shared_todo_${listId}`, listData); + case 'addtask': { + const listId = interaction.options.getString('list_id'); + const taskText = interaction.options.getString('task'); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed('Task Removed', `Removed "${removedTask.text}" from the shared list "${listData.name}".`) - ] - }); + const listData = await getOrCreateSharedList(listId); + + if (!listData) { + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Shared list not found.' }); } - } - return; - } - const dbKey = `todo_${userId}`; - - const userData = await getFromDb(dbKey, { - tasks: [], - nextId: 1 - }); - - if (!userData.tasks) userData.tasks = []; - if (!userData.nextId) userData.nextId = 1; + if (!listData.members.includes(userId)) { + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You don\'t have access to this list.' }); + } - switch (subcommand) { - case 'add': { - const taskText = interaction.options.getString('task'); - const newTask = { - id: userData.nextId++, + id: listData.nextId++, text: taskText, completed: false, - createdAt: new Date().toISOString() + createdAt: new Date().toISOString(), + createdBy: userId }; - - userData.tasks.push(newTask); - await setInDb(dbKey, userData); - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "Task Added", - `Added "${taskText}" to your to-do list.` - ), - ], - }); - } - - case 'list': { - if (userData.tasks.length === 0) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed('Your to-do list is empty!', "Your To-Do List")], - }); - } - const taskList = userData.tasks - .map(task => - `${task.completed ? '✅' : '📝'} #${task.id} ${task.text}` + - `\`[${new Date(task.createdAt).toLocaleDateString()}\`` - ) - .join('\n'); + listData.tasks.push(newTask); + await setInDb(`shared_todo_${listId}`, listData); return await InteractionHelper.safeEditReply(interaction, { embeds: [ - successEmbed('Your To-Do List', taskList) - ], + successEmbed('Task Added', `Added "${taskText}" to the shared list "${listData.name}"`) + ] }); } - case 'complete': { + case 'remove': { + const listId = interaction.options.getString('list_id'); const taskNumber = interaction.options.getInteger('number'); - const task = userData.tasks.find(t => t.id === taskNumber); - - if (!task) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Task not found.' }); + + const listData = await getOrCreateSharedList(listId); + + if (!listData) { + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Shared list not found.' }); } - if (task.completed) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Task #${task.id} is already completed.' }); + if (!listData.members.includes(userId)) { + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You don\'t have access to this list.' }); } - - task.completed = true; - await setInDb(`todo_${userId}`, userData); - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed('Task Completed', `Marked "${task.text}" as complete!`) - ], - }); - } - case 'remove': { - const taskNumber = interaction.options.getInteger('number'); - const taskIndex = userData.tasks.findIndex(t => t.id === taskNumber); - + const taskIndex = listData.tasks.findIndex(task => task.id === taskNumber); if (taskIndex === -1) { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Task not found.' }); } - - const [removedTask] = userData.tasks.splice(taskIndex, 1); - await setInDb(`todo_${userId}`, userData); - + + const [removedTask] = listData.tasks.splice(taskIndex, 1); + await setInDb(`shared_todo_${listId}`, listData); + return await InteractionHelper.safeEditReply(interaction, { embeds: [ - successEmbed('Task Removed', `Removed "${removedTask.text}" from your to-do list.`) - ], + successEmbed('Task Removed', `Removed "${removedTask.text}" from the shared list "${listData.name}".`) + ] }); } + } + return; + } + + const dbKey = `todo_${userId}`; + + const userData = await getFromDb(dbKey, { + tasks: [], + nextId: 1 + }); + + if (!userData.tasks) userData.tasks = []; + if (!userData.nextId) userData.nextId = 1; - default: - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Invalid subcommand.' }); + switch (subcommand) { + case 'add': { + const taskText = interaction.options.getString('task'); + + const newTask = { + id: userData.nextId++, + text: taskText, + completed: false, + createdAt: new Date().toISOString() + }; + + userData.tasks.push(newTask); + await setInDb(dbKey, userData); + + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + successEmbed( + "Task Added", + `Added "${taskText}" to your to-do list.` + ), + ], + }); } - } catch (error) { - logger.error(`Todo command execution failed`, { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'todo' - }); - await handleInteractionError(interaction, error, { - commandName: 'todo', - source: 'todo_command' - }); + + case 'list': { + if (userData.tasks.length === 0) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('Your to-do list is empty!', "Your To-Do List")], + }); + } + + const taskList = userData.tasks + .map(task => + `${task.completed ? '✅' : '📝'} #${task.id} ${task.text}` + + `\`[${new Date(task.createdAt).toLocaleDateString()}\`` + ) + .join('\n'); + + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + successEmbed('Your To-Do List', taskList) + ], + }); + } + + case 'complete': { + const taskNumber = interaction.options.getInteger('number'); + const task = userData.tasks.find(t => t.id === taskNumber); + + if (!task) { + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Task not found.' }); + } + + if (task.completed) { + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `Task #${task.id} is already completed.` }); + } + + task.completed = true; + await setInDb(`todo_${userId}`, userData); + + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + successEmbed('Task Completed', `Marked "${task.text}" as complete!`) + ], + }); + } + + case 'remove': { + const taskNumber = interaction.options.getInteger('number'); + const taskIndex = userData.tasks.findIndex(t => t.id === taskNumber); + + if (taskIndex === -1) { + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Task not found.' }); + } + + const [removedTask] = userData.tasks.splice(taskIndex, 1); + await setInDb(`todo_${userId}`, userData); + + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + successEmbed('Task Removed', `Removed "${removedTask.text}" from your to-do list.`) + ], + }); + } + + default: + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Invalid subcommand.' }); } }, }; \ No newline at end of file diff --git a/src/commands/Utility/userinfo.js b/src/commands/Utility/userinfo.js index 30013c39cd..2455b0f4a9 100644 --- a/src/commands/Utility/userinfo.js +++ b/src/commands/Utility/userinfo.js @@ -1,7 +1,6 @@ import { SlashCommandBuilder } from 'discord.js'; import { createEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() @@ -14,74 +13,60 @@ export default { ), async execute(interaction) { - try { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`UserInfo interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'userinfo' - }); - return; - } - - const user = interaction.options.getUser("target") || interaction.user; - const member = interaction.guild.members.cache.get(user.id); - - const createdTimestamp = Math.floor(user.createdAt.getTime() / 1000); - const joinedTimestamp = member?.joinedAt ? Math.floor(member.joinedAt.getTime() / 1000) : null; - - const embed = createEmbed({ title: `User Info: ${user.username}` }) - .setThumbnail(user.displayAvatarURL({ size: 256 })) - .addFields( - { name: "ID", value: user.id, inline: true }, - { name: "Bot", value: user.bot ? "Yes" : "No", inline: true }, - { - name: "Roles", - value: - member && member.roles.cache.size > 1 - ? member.roles.cache - .map((r) => r.name) - .slice(0, 5) - .join(",") - : "None", - inline: true, - }, - { - name: "Account Created", - value: ``, - inline: false, - }, - { - name: "Joined Server", - value: joinedTimestamp ? `` : "Not in server", - inline: false, - }, - { - name: "Highest Role", - value: member?.roles?.highest?.name || "None", - inline: true, - }, - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.info(`UserInfo command executed`, { - userId: interaction.user.id, - targetUserId: user.id, - guildId: interaction.guildId - }); - } catch (error) { - logger.error(`UserInfo command execution failed`, { - error: error.message, - stack: error.stack, + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`UserInfo interaction defer failed`, { userId: interaction.user.id, guildId: interaction.guildId, commandName: 'userinfo' }); - await handleInteractionError(interaction, error, { - commandName: 'userinfo', - source: 'userinfo_command' - }); + return; } + + const user = interaction.options.getUser("target") || interaction.user; + const member = interaction.guild.members.cache.get(user.id); + + const createdTimestamp = Math.floor(user.createdAt.getTime() / 1000); + const joinedTimestamp = member?.joinedAt ? Math.floor(member.joinedAt.getTime() / 1000) : null; + + const embed = createEmbed({ title: `User Info: ${user.username}` }) + .setThumbnail(user.displayAvatarURL({ size: 256 })) + .addFields( + { name: "ID", value: user.id, inline: true }, + { name: "Bot", value: user.bot ? "Yes" : "No", inline: true }, + { + name: "Roles", + value: + member && member.roles.cache.size > 1 + ? member.roles.cache + .map((r) => r.name) + .slice(0, 5) + .join(",") + : "None", + inline: true, + }, + { + name: "Account Created", + value: ``, + inline: false, + }, + { + name: "Joined Server", + value: joinedTimestamp ? `` : "Not in server", + inline: false, + }, + { + name: "Highest Role", + value: member?.roles?.highest?.name || "None", + inline: true, + }, + ); + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + logger.info(`UserInfo command executed`, { + userId: interaction.user.id, + targetUserId: user.id, + guildId: interaction.guildId + }); }, }; \ No newline at end of file diff --git a/src/commands/Utility/weather.js b/src/commands/Utility/weather.js index 1e542e183d..a76c3eeb09 100644 --- a/src/commands/Utility/weather.js +++ b/src/commands/Utility/weather.js @@ -1,7 +1,7 @@ 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 { replyUserError, ErrorTypes } 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"; @@ -18,104 +18,90 @@ export default { ), 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 deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`Weather interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'weather' + }); + return; + } - const weatherResponse = await fetch( - `${WEATHER_URL}?latitude=${latitude}&longitude=${longitude}¤t_weather=true`, - ); - const weatherData = await weatherResponse.json(); + const city = interaction.options.getString("city"); - 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 geoResponse = await fetch( + `${GEOCODING_URL}?name=${encodeURIComponent(city)}`, + ); + const geoData = await geoResponse.json(); - 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; + 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 condition = getWeatherDescription(weatherCode); + const { latitude, longitude, name, country } = geoData.results[0]; + const cityDisplay = name; - 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)}`, - }); + const weatherResponse = await fetch( + `${WEATHER_URL}?latitude=${latitude}&longitude=${longitude}¤t_weather=true`, + ); + const weatherData = await weatherResponse.json(); - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.info(`Weather command executed`, { + if (weatherData.error) { + logger.error(`Weather API error`, { + error: weatherData.reason, + city: city, 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' - }); + 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 + }); }, }; diff --git a/src/commands/Utility/wipedata.js b/src/commands/Utility/wipedata.js index a475868b01..941265dca8 100644 --- a/src/commands/Utility/wipedata.js +++ b/src/commands/Utility/wipedata.js @@ -2,7 +2,6 @@ import { SlashCommandBuilder, MessageFlags } from 'discord.js'; import { createEmbed, errorEmbed, warningEmbed } from '../../utils/embeds.js'; import { getConfirmationButtons } from '../../utils/components.js'; import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { @@ -12,45 +11,31 @@ export default { .setDescription('Delete all your personal data from the bot (irreversible)'), async execute(interaction, guildConfig, client) { - try { - const warningMessage = - `⚠️ **THIS ACTION IS IRREVERSIBLE!** ⚠️\n\n` + - `This will permanently delete **ALL** your data from this server including:\n` + - `• 💰 Economy balance (wallet & bank)\n` + - `• 📊 Levels and XP\n` + - `• 🎒 Inventory items\n` + - `• 🛍️ Shop purchases\n` + - `• 🎂 Birthday information\n` + - `• 🔢 Counter data\n` + - `• 📋 All other personal data\n\n` + - `**This cannot be undone. Are you absolutely sure?**`; + const warningMessage = + `⚠️ **THIS ACTION IS IRREVERSIBLE!** ⚠️\n\n` + + `This will permanently delete **ALL** your data from this server including:\n` + + `• 💰 Economy balance (wallet & bank)\n` + + `• 📊 Levels and XP\n` + + `• 🎒 Inventory items\n` + + `• 🛍️ Shop purchases\n` + + `• 🎂 Birthday information\n` + + `• 🔢 Counter data\n` + + `• 📋 All other personal data\n\n` + + `**This cannot be undone. Are you absolutely sure?**`; - const embed = warningEmbed('Wipe All Data', warningMessage); + const embed = warningEmbed('Wipe All Data', warningMessage); - const confirmButtons = getConfirmationButtons('wipedata'); + const confirmButtons = getConfirmationButtons('wipedata'); - await InteractionHelper.safeReply(interaction, { - embeds: [embed], - components: [confirmButtons], - flags: MessageFlags.Ephemeral - }); + await InteractionHelper.safeReply(interaction, { + embeds: [embed], + components: [confirmButtons], + flags: MessageFlags.Ephemeral + }); - logger.info(`Wipedata command executed - confirmation prompt shown`, { - userId: interaction.user.id, - guildId: interaction.guildId - }); - } catch (error) { - logger.error(`Wipedata command execution failed`, { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'wipedata' - }); - await handleInteractionError(interaction, error, { - commandName: 'wipedata', - source: 'wipedata_command' - }); - } + logger.info(`Wipedata command executed - confirmation prompt shown`, { + userId: interaction.user.id, + guildId: interaction.guildId + }); } }; \ No newline at end of file diff --git a/src/commands/Verification/modules/autoVerify.js b/src/commands/Verification/modules/autoVerify.js index 65635299bf..31ca0e881d 100644 --- a/src/commands/Verification/modules/autoVerify.js +++ b/src/commands/Verification/modules/autoVerify.js @@ -1,7 +1,7 @@ import { botConfig, getColor } from '../../../config/bot.js'; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed, infoEmbed } from '../../../utils/embeds.js'; -import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; +import { getGuildConfig, setGuildConfig } from '../../../services/config/guildConfig.js'; import { withErrorHandling, createError, ErrorTypes } from '../../../utils/errorHandler.js'; import { validateAutoVerifyCriteria } from '../../../services/verificationService.js'; import { logger } from '../../../utils/logger.js'; diff --git a/src/commands/Verification/modules/autoVerifyDashboard.js b/src/commands/Verification/modules/autoVerifyDashboard.js index dfd9575e0a..7209da79fd 100644 --- a/src/commands/Verification/modules/autoVerifyDashboard.js +++ b/src/commands/Verification/modules/autoVerifyDashboard.js @@ -17,7 +17,7 @@ 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 { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; +import { getGuildConfig, setGuildConfig } from '../../../services/config/guildConfig.js'; import { getWelcomeConfig } from '../../../utils/database.js'; import { validateAutoVerifyCriteria } from '../../../services/verificationService.js'; import { botHasPermission } from '../../../utils/permissionGuard.js'; @@ -488,7 +488,7 @@ async function handleAccountAge(selectInteraction, rootInteraction, guildConfig, const days = parseInt(inputValue, 10); if (isNaN(days) || days < minAccountAgeDays || days > maxAccountAgeDays) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Please enter a number between ${minAccountAgeDays} and ${maxAccountAgeDays}.' }); + await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: `Please enter a number between ${minAccountAgeDays} and ${maxAccountAgeDays}.` }); return; } diff --git a/src/commands/Verification/modules/verification_dashboard.js b/src/commands/Verification/modules/verification_dashboard.js index 6683df0feb..ab32689527 100644 --- a/src/commands/Verification/modules/verification_dashboard.js +++ b/src/commands/Verification/modules/verification_dashboard.js @@ -19,7 +19,7 @@ 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 { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; +import { getGuildConfig, setGuildConfig } from '../../../services/config/guildConfig.js'; import { getWelcomeConfig } from '../../../utils/database.js'; import { botHasPermission } from '../../../utils/permissionGuard.js'; import { diff --git a/src/commands/Verification/verification.js b/src/commands/Verification/verification.js index a29826d528..8906809fe7 100644 --- a/src/commands/Verification/verification.js +++ b/src/commands/Verification/verification.js @@ -1,8 +1,8 @@ import { botConfig, getColor } from '../../config/bot.js'; import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js'; import { createEmbed, infoEmbed, successEmbed } from '../../utils/embeds.js'; -import { getGuildConfig, setGuildConfig } from '../../services/guildConfig.js'; -import { handleInteractionError, withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; +import { getGuildConfig, setGuildConfig } from '../../services/config/guildConfig.js'; +import { withErrorHandling, createError, ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; import { removeVerification, verifyUser } from '../../services/verificationService.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; @@ -225,37 +225,26 @@ async function handleSetup(interaction, guild, client) { async function handleRemove(interaction, guild, client) { const targetUser = interaction.options.getUser("user"); - - try { - const result = await removeVerification(client, guild.id, targetUser.id, { - moderatorId: interaction.user.id, - reason: 'admin_removal' - }); - - if (!result.success) { - if (result.notVerified) { - return await InteractionHelper.safeReply(interaction, { - embeds: [infoEmbed('Not Verified', `${targetUser.tag} does not currently have the verified role.`)], - flags: MessageFlags.Ephemeral - }); - } - } - logger.info('Verification removed via command', { - guildId: guild.id, - targetUserId: targetUser.id, - moderatorId: interaction.user.id - }); + const result = await removeVerification(client, guild.id, targetUser.id, { + moderatorId: interaction.user.id, + reason: 'admin_removal' + }); + if (result.status === 'not_verified') { return await InteractionHelper.safeReply(interaction, { - embeds: [successEmbed('Verification Removed', `Verification removed from ${targetUser.tag}.`)] + embeds: [infoEmbed('Not Verified', `${targetUser.tag} does not currently have the verified role.`)], + flags: MessageFlags.Ephemeral }); - - } catch (error) { - await handleInteractionError( - interaction, - error, - { command: 'verification', subcommand: 'remove' } - ); } + + logger.info('Verification removed via command', { + guildId: guild.id, + targetUserId: targetUser.id, + moderatorId: interaction.user.id + }); + + return await InteractionHelper.safeReply(interaction, { + embeds: [successEmbed('Verification Removed', `Verification removed from ${targetUser.tag}.`)] + }); } \ No newline at end of file diff --git a/src/commands/Verification/verify.js b/src/commands/Verification/verify.js index 3f3149beb8..7c203ac8d6 100644 --- a/src/commands/Verification/verify.js +++ b/src/commands/Verification/verify.js @@ -1,8 +1,7 @@ import { SlashCommandBuilder, MessageFlags } from 'discord.js'; import { infoEmbed, successEmbed } from '../../utils/embeds.js'; -import { withErrorHandling } from '../../utils/errorHandler.js'; +import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { verifyUser } from '../../services/verificationService.js'; -import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { @@ -11,34 +10,26 @@ export default { .setDescription('Verify yourself and gain access to the server'), async execute(interaction, config, client) { - const wrappedExecute = withErrorHandling(async () => { - const guild = interaction.guild; + const guild = interaction.guild; - const result = await verifyUser(client, guild.id, interaction.user.id, { - source: 'command_self', - moderatorId: null - }); - - if (!result.success) { - if (result.alreadyVerified) { - return await InteractionHelper.safeReply(interaction, { - embeds: [infoEmbed('Already Verified', "You are already verified.")], - flags: MessageFlags.Ephemeral - }); - } - - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred during verification. Please try again or contact an administrator.' }); - } + const result = await verifyUser(client, guild.id, interaction.user.id, { + source: 'command_self', + moderatorId: null + }); - await InteractionHelper.safeReply(interaction, { - embeds: [successEmbed( - "Verification Complete", - `You have been verified and given the **${result.roleName}** role! Welcome to the server! 🎉` - )], + if (result.status === 'already_verified') { + return await InteractionHelper.safeReply(interaction, { + embeds: [infoEmbed('Already Verified', "You are already verified.")], flags: MessageFlags.Ephemeral }); - }, { command: 'verify' }); + } - return await wrappedExecute(interaction, config, client); + await InteractionHelper.safeReply(interaction, { + embeds: [successEmbed( + "Verification Complete", + `You have been verified and given the **${result.roleName}** role! Welcome to the server! 🎉` + )], + flags: MessageFlags.Ephemeral + }); } -}; \ No newline at end of file +}; diff --git a/src/commands/Welcome/autorole.js b/src/commands/Welcome/autorole.js index 1d7a578b26..81764e4c46 100644 --- a/src/commands/Welcome/autorole.js +++ b/src/commands/Welcome/autorole.js @@ -3,7 +3,7 @@ import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, EmbedBuilder, Me import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getGuildConfig } from '../../services/guildConfig.js'; +import { getGuildConfig } from '../../services/config/guildConfig.js'; import { ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; function createAutoroleInfoEmbed(description) { diff --git a/src/commands/Welcome/goodbye.js b/src/commands/Welcome/goodbye.js index 2bd7ebac79..3d45b93309 100644 --- a/src/commands/Welcome/goodbye.js +++ b/src/commands/Welcome/goodbye.js @@ -61,7 +61,7 @@ export default { const existingConfig = await getWelcomeConfig(client, guild.id); if (existingConfig?.goodbyeChannelId) { logger.info(`[Goodbye] Setup blocked because config already exists in channel ${existingConfig.goodbyeChannelId} for guild ${guild.id}`); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `Goodbye is already configured for <#${existingConfig.goodbyeChannelId}>. Use **/goodbye config** to customize channel, message, ping, or image.` }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `Goodbye is already configured for <#${existingConfig.goodbyeChannelId}>. Use **/greet dashboard** to customize channel, message, ping, or image.` }); } if (!message || message.trim().length === 0) { @@ -109,7 +109,7 @@ export default { { name: 'Ping User', value: ping ? 'Yes' : 'No' }, { name: 'Status', value: 'Enabled' } ) - .setFooter({ text: 'Tip: Use /goodbye config to customize goodbye settings' }); + .setFooter({ text: 'Tip: Use /greet dashboard to customize goodbye settings' }); if (image) { embed.setImage(image); diff --git a/src/commands/Welcome/welcome.js b/src/commands/Welcome/welcome.js index cacafb2d06..840ba7bdd3 100644 --- a/src/commands/Welcome/welcome.js +++ b/src/commands/Welcome/welcome.js @@ -66,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 **/greet dashboard** to customize channel, message, ping, or image.` }); } if (!message || message.trim().length === 0) { @@ -108,7 +108,7 @@ export default { { name: 'Ping User', value: ping ? 'Yes' : 'No' }, { name: 'Status', value: 'Enabled' } ) - .setFooter({ text: 'Tip: Use /welcome config to customize welcome settings' }); + .setFooter({ text: 'Tip: Use /greet dashboard to customize welcome settings' }); if (image) { embed.setImage(image); diff --git a/src/config/application.js b/src/config/application.js index 9e058bc3ab..159b9eb295 100644 --- a/src/config/application.js +++ b/src/config/application.js @@ -2,7 +2,7 @@ import { fileURLToPath } from "url"; import path from "path"; import botConfig, { validateConfig } from "./bot.js"; import { shopConfig as shop } from "./shop/index.js"; -import { pgConfig } from "./postgres.js"; +import { pgConfig } from "./database/postgres.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -22,8 +22,8 @@ const appConfig = { ...botConfig, token: process.env.DISCORD_TOKEN || process.env.TOKEN, clientId: process.env.CLIENT_ID, + // Retained for tutorial/setup compatibility; not used for command registration. guildId: process.env.GUILD_ID, - multiGuild: process.env.MULTI_GUILD === 'true', shop: { ...botConfig.shop, @@ -73,30 +73,8 @@ const appConfig = { shop, features: { - - economy: true, - leveling: true, - moderation: true, - logging: true, - welcome: true, - - tickets: true, - giveaways: true, - birthday: true, - counter: true, - - verification: true, - reactionRoles: true, - joinToCreate: true, - - voice: true, - search: true, - tools: true, - utility: true, - community: true, - fun: true, - - music: true, + ...botConfig.features, + music: botConfig.features?.music ?? true, }, env: process.env.NODE_ENV || "development", diff --git a/src/config/bot.js b/src/config/bot.js index 989657a1be..bd42c98669 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -37,7 +37,7 @@ export const botConfig = { commands: { // Bot owner user IDs (comma-separated in OWNER_IDS env var). // Owners can access owner/admin-level bot commands. - owners: process.env.OWNER_IDS?.split(",") || [], + owners: process.env.OWNER_IDS?.split(",").map((id) => id.trim()).filter(Boolean) || [], // Default wait time between command uses (in seconds). defaultCooldown: 3, @@ -45,9 +45,12 @@ export const botConfig = { // If true, old commands are removed before re-registering. deleteCommands: false, - // Optional server ID used for testing slash commands quickly. + // Optional server ID retained for tutorial compatibility; not used for command registration. testGuildId: process.env.TEST_GUILD_ID, + // When true (or MAINTENANCE_MODE=true), only bot owners can run commands. + maintenanceMode: process.env.MAINTENANCE_MODE === "true", + // Command prefix for text-based commands (e.g., "!" for "!ping"). // Supports both slash commands and prefix commands. prefix: process.env.PREFIX || "!", @@ -183,6 +186,14 @@ export const botConfig = { begMin: 5, begMax: 50, + // Command cooldowns (milliseconds). + cooldowns: { + daily: 24 * 60 * 60 * 1000, + work: 60 * 60 * 1000, + crime: 2 * 60 * 60 * 1000, + rob: 4 * 60 * 60 * 1000, + }, + // Chance to succeed when robbing (0.4 = 40%). robSuccessRate: 0.4, @@ -463,6 +474,7 @@ export const botConfig = { utility: true, community: true, fun: true, + music: true, }, }; @@ -488,14 +500,20 @@ export function validateConfig(config) { } if (process.env.NODE_ENV === 'production') { - if (!process.env.POSTGRES_HOST) { - errors.push("PostgreSQL host is required in production (POSTGRES_HOST environment variable)"); - } - if (!process.env.POSTGRES_USER) { - errors.push("PostgreSQL user is required in production (POSTGRES_USER environment variable)"); - } - if (!process.env.POSTGRES_PASSWORD) { - errors.push("PostgreSQL password is required in production (POSTGRES_PASSWORD environment variable)"); + // A full connection URL (DATABASE_URL / POSTGRES_URL) satisfies all Postgres + // requirements, matching how src/config/database/postgres.js resolves the pool config. + const hasConnectionUrl = Boolean(process.env.POSTGRES_URL || process.env.DATABASE_URL); + + if (!hasConnectionUrl) { + if (!process.env.POSTGRES_HOST) { + errors.push("PostgreSQL connection is required in production (set DATABASE_URL/POSTGRES_URL, or POSTGRES_HOST)"); + } + if (!process.env.POSTGRES_USER) { + errors.push("PostgreSQL user is required in production (set DATABASE_URL/POSTGRES_URL, or POSTGRES_USER)"); + } + if (!process.env.POSTGRES_PASSWORD) { + errors.push("PostgreSQL password is required in production (set DATABASE_URL/POSTGRES_URL, or POSTGRES_PASSWORD)"); + } } } @@ -512,6 +530,98 @@ if (configErrors.length > 0) { export const BotConfig = botConfig; +const COMMAND_CATEGORY_FEATURE_MAP = { + birthday: "birthday", + community: "community", + economy: "economy", + fun: "fun", + giveaway: "giveaways", + jointocreate: "joinToCreate", + leveling: "leveling", + logging: "logging", + moderation: "moderation", + music: "music", + reaction_roles: "reactionRoles", + search: "search", + serverstats: "counter", + ticket: "tickets", + tools: "tools", + utility: "utility", + verification: "verification", + welcome: "welcome", +}; + +function normalizeCategoryKey(category) { + return String(category || "").trim().toLowerCase().replace(/\s+/g, "_"); +} + +export function getCommandPrefix() { + return botConfig.commands?.prefix ?? "!"; +} + +export function getBotOwners() { + return (botConfig.commands?.owners ?? []) + .map((id) => String(id).trim()) + .filter(Boolean); +} + +export function isBotOwner(userId) { + if (!userId) { + return false; + } + + return getBotOwners().includes(String(userId)); +} + +export function isMaintenanceMode() { + return botConfig.commands?.maintenanceMode === true; +} + +export function getBotMessage(key, replacements = {}) { + let message = botConfig.messages?.[key] || key; + + for (const [placeholder, value] of Object.entries(replacements)) { + message = message.replace(new RegExp(`\\{${placeholder}\\}`, "g"), String(value)); + } + + return message; +} + +export function isFeatureEnabled(featureKey) { + if (!featureKey) { + return true; + } + + return botConfig.features?.[featureKey] !== false; +} + +export function isCommandCategoryEnabled(category) { + const normalized = normalizeCategoryKey(category); + + if (!normalized || normalized === "core") { + return true; + } + + const featureKey = COMMAND_CATEGORY_FEATURE_MAP[normalized]; + if (!featureKey) { + return true; + } + + return isFeatureEnabled(featureKey); +} + +export function getApplicationStatusColor(status) { + const colors = botConfig.applications?.statusColors || {}; + const hex = colors[status]; + return hex ? getColor(hex) : getColor(status === "approved" ? "success" : status === "denied" ? "error" : "warning"); +} + +export function getDefaultApplicationQuestions() { + return (botConfig.applications?.defaultQuestions || []).map((entry) => + typeof entry === "string" ? entry : entry.question, + ).filter(Boolean); +} + export function getColor(path, fallback = "#99AAB5") { if (typeof path === "number") return path; diff --git a/src/config/commandAliases.js b/src/config/commands/commandAliases.js similarity index 100% rename from src/config/commandAliases.js rename to src/config/commands/commandAliases.js diff --git a/src/config/commandCategories.js b/src/config/commands/commandCategories.js similarity index 100% rename from src/config/commandCategories.js rename to src/config/commands/commandCategories.js diff --git a/src/config/prefixRestrictions.js b/src/config/commands/prefixRestrictions.js similarity index 100% rename from src/config/prefixRestrictions.js rename to src/config/commands/prefixRestrictions.js diff --git a/src/config/postgres.js b/src/config/database/postgres.js similarity index 98% rename from src/config/postgres.js rename to src/config/database/postgres.js index 06ceee0fb2..79d92dbdc1 100644 --- a/src/config/postgres.js +++ b/src/config/database/postgres.js @@ -1,4 +1,4 @@ -import { assertAllowlistedIdentifier } from '../utils/sqlIdentifiers.js'; +import { assertAllowlistedIdentifier } from '../../utils/sqlIdentifiers.js'; import { EXPECTED_SCHEMA_LABEL, EXPECTED_SCHEMA_VERSION } from './schemaVersion.js'; const configuredTables = { diff --git a/src/config/schemaVersion.js b/src/config/database/schemaVersion.js similarity index 100% rename from src/config/schemaVersion.js rename to src/config/database/schemaVersion.js diff --git a/src/config/guild/guildConfigDefaults.js b/src/config/guild/guildConfigDefaults.js new file mode 100644 index 0000000000..ef60a90be6 --- /dev/null +++ b/src/config/guild/guildConfigDefaults.js @@ -0,0 +1,15 @@ +import { BotConfig, getCommandPrefix } from '../bot.js'; +import { DEFAULT_GUILD_CONFIG } from '../../utils/constants.js'; + +/** + * Single source of truth for guild config default values. + * Used by the guild config service and database read path. + */ +export const GUILD_CONFIG_DEFAULTS = { + ...DEFAULT_GUILD_CONFIG, + prefix: getCommandPrefix(), + welcomeMessage: BotConfig.welcome?.defaultWelcomeMessage || 'Welcome {user} to {server}!', + dmOnClose: true, + disabledCommands: {}, + disabledCategories: {}, +}; diff --git a/src/config/lavalink.js b/src/config/music/lavalink.js similarity index 59% rename from src/config/lavalink.js rename to src/config/music/lavalink.js index 8121828a8b..e464557a9e 100644 --- a/src/config/lavalink.js +++ b/src/config/music/lavalink.js @@ -1,3 +1,9 @@ +import { existsSync, readFileSync } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); + function parseBoolean(value, defaultValue = false) { if (value === undefined || value === null || value === '') { return defaultValue; @@ -19,12 +25,43 @@ function parseNodesFromEnv() { } } +function parseNodesPayload(parsed) { + if (Array.isArray(parsed)) { + return parsed; + } + if (Array.isArray(parsed?.nodes)) { + return parsed.nodes; + } + return null; +} + +function loadNodesFromFile() { + const nodesFile = process.env.LAVALINK_NODES_FILE?.trim() + || path.join(projectRoot, 'lavalink', 'nodes.json'); + + if (!existsSync(nodesFile)) { + return null; + } + + try { + const parsed = JSON.parse(readFileSync(nodesFile, 'utf8')); + return parseNodesPayload(parsed); + } catch { + return null; + } +} + export function getLavalinkNodes() { const fromJson = parseNodesFromEnv(); if (fromJson?.length) { return fromJson; } + const fromFile = loadNodesFromFile(); + if (fromFile?.length) { + return fromFile; + } + const host = process.env.LAVALINK_HOST || 'localhost'; const port = Number(process.env.LAVALINK_PORT || 2333); const password = process.env.LAVALINK_PASSWORD || 'youshallnotpass'; diff --git a/src/events/guildCreate.js b/src/events/guildCreate.js index 99a8c8c0c3..7972714a59 100644 --- a/src/events/guildCreate.js +++ b/src/events/guildCreate.js @@ -1,6 +1,6 @@ import { Events } from 'discord.js'; import { logger } from '../utils/logger.js'; -import { getGuildConfig, setGuildConfig } from '../services/guildConfig.js'; +import { getGuildConfig, setGuildConfig } from '../services/config/guildConfig.js'; export default { name: Events.GuildCreate, diff --git a/src/events/guildMemberAdd.js b/src/events/guildMemberAdd.js index 8bb4be1163..cb77b477b4 100644 --- a/src/events/guildMemberAdd.js +++ b/src/events/guildMemberAdd.js @@ -1,6 +1,6 @@ import { Events, EmbedBuilder, PermissionFlagsBits } from 'discord.js'; -import { getColor } from '../config/bot.js'; -import { getGuildConfig } from '../services/guildConfig.js'; +import { getColor, botConfig } from '../config/bot.js'; +import { getGuildConfig } from '../services/config/guildConfig.js'; import { getWelcomeConfig } from '../utils/database.js'; import { formatWelcomeMessage } from '../utils/welcome.js'; import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; @@ -24,16 +24,14 @@ export default { 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; - } - + const me = guild.members.me; + const permissions = channel?.isTextBased?.() && me ? channel.permissionsFor(me) : null; + // Skip only the welcome message if permissions are missing; the rest of the + // join pipeline (auto-role, verification, logging, counters) must still run. + if (permissions?.has([PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages])) { const formatData = { user, guild, member }; const welcomeMessage = formatWelcomeMessage( - welcomeConfig.welcomeMessage || welcomeConfig.welcomeEmbed?.description || 'Welcome {user} to {server}!', + welcomeConfig.welcomeMessage || welcomeConfig.welcomeEmbed?.description || botConfig.welcome?.defaultWelcomeMessage || 'Welcome {user} to {server}!', formatData ); diff --git a/src/events/guildMemberRemove.js b/src/events/guildMemberRemove.js index 3f2329da18..a550082635 100644 --- a/src/events/guildMemberRemove.js +++ b/src/events/guildMemberRemove.js @@ -1,11 +1,11 @@ import { Events, EmbedBuilder, PermissionFlagsBits } from 'discord.js'; -import { getColor } from '../config/bot.js'; +import { getColor, botConfig } from '../config/bot.js'; import { getWelcomeConfig, getUserApplications, deleteApplication } 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 { getGuildBirthdays, deleteBirthday } from '../utils/database.js'; -import { deleteUserLevelData } from '../services/leveling.js'; +import { deleteUserLevelData } from '../services/leveling/leveling.js'; import { logger } from '../utils/logger.js'; export default { @@ -31,7 +31,7 @@ export default { const formatData = { user, guild, member }; const goodbyeMessage = formatWelcomeMessage( - welcomeConfig.leaveMessage || welcomeConfig.leaveEmbed?.description || '{user.tag} has left the server.', + welcomeConfig.leaveMessage || welcomeConfig.leaveEmbed?.description || botConfig.welcome?.defaultGoodbyeMessage || '{user} has left the server.', formatData ); diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js index 1de0004000..50a468b73e 100644 --- a/src/events/interactionCreate.js +++ b/src/events/interactionCreate.js @@ -1,9 +1,15 @@ import { Events, MessageFlags } from 'discord.js'; import { logger } from '../utils/logger.js'; -import { getGuildConfig } from '../services/guildConfig.js'; +import { getGuildConfig } from '../services/config/guildConfig.js'; +import { + getBotMessage, + isBotOwner, + isCommandCategoryEnabled, + isMaintenanceMode, +} from '../config/bot.js'; +import botConfig from '../config/bot.js'; import { handleApplicationModal } from '../commands/Community/apply.js'; -import { handleApplicationReviewModal } from '../commands/Community/app-admin.js'; -import { handleInteractionError, createError, ErrorTypes } from '../utils/errorHandler.js'; +import { handleInteractionError, createError, ErrorTypes, ErrorCodes } from '../utils/errorHandler.js'; import { InteractionHelper } from '../utils/interactionHelper.js'; import { createInteractionTraceContext, runWithTraceContext } from '../utils/logger.js'; import { validateChatInputPayloadOrThrow } from '../utils/commandInputValidation.js'; @@ -14,6 +20,22 @@ import { isCollectorManagedComponent } from '../utils/collectorComponents.js'; import { ResponseCoordinator } from '../utils/responseCoordinator.js'; import { enforceDefaultCommandPermissions } from '../utils/permissionGuard.js'; +const COMMAND_ERROR_SUBTYPES = { + warn: 'warn_failed', + kick: 'kick_failed', + ban: 'ban_failed', + unban: 'unban_failed', + timeout: 'timeout_failed', + untimeout: 'untimeout_failed', + warnings: 'warnings_view_failed', + ticket: 'ticket_failed', + serverstats: 'serverstats_failed', + gcreate: 'giveaway_failed', + gend: 'giveaway_failed', + gdelete: 'giveaway_failed', + greroll: 'giveaway_failed', +}; + function withTraceContext(context = {}, traceContext = {}) { return { traceId: traceContext.traceId, @@ -62,6 +84,42 @@ export default { ); } + if (isMaintenanceMode() && !isBotOwner(interaction.user.id)) { + throw createError( + 'Bot is in maintenance mode', + ErrorTypes.CONFIGURATION, + getBotMessage('maintenanceMode'), + withTraceContext({ commandName: interaction.commandName }, interactionTraceContext) + ); + } + + if (!isCommandCategoryEnabled(command.category)) { + throw createError( + `Feature disabled for category ${command.category}`, + ErrorTypes.CONFIGURATION, + getBotMessage('commandDisabled'), + withTraceContext({ commandName: interaction.commandName, category: command.category }, interactionTraceContext) + ); + } + + const defaultCooldownSec = Number(botConfig.commands?.defaultCooldown) || 0; + if (defaultCooldownSec > 0 && !isBotOwner(interaction.user.id)) { + const cooldownKey = `${interaction.user.id}:${interaction.commandName}`; + const expiresAt = client.cooldowns.get(cooldownKey); + + if (expiresAt && Date.now() < expiresAt) { + const remainingSec = Math.ceil((expiresAt - Date.now()) / 1000); + throw createError( + `Default command cooldown active for ${interaction.commandName}`, + ErrorTypes.RATE_LIMIT, + getBotMessage('cooldownActive', { time: `${remainingSec}s` }), + withTraceContext({ commandName: interaction.commandName, remainingSec }, interactionTraceContext) + ); + } + + client.cooldowns.set(cooldownKey, Date.now() + defaultCooldownSec * 1000); + } + const abuseProtection = await enforceAbuseProtection(interaction, command, interaction.commandName); if (!abuseProtection.allowed) { const formattedCooldown = formatCooldownDuration(abuseProtection.remainingMs); @@ -106,7 +164,8 @@ export default { } catch (error) { await handleInteractionError(interaction, error, withTraceContext({ type: 'command', - commandName: interaction.commandName + commandName: interaction.commandName, + subtype: COMMAND_ERROR_SUBTYPES[interaction.commandName] || error?.context?.subtype, }, interactionTraceContext)); } } else if (interaction.isAutocomplete()) { @@ -339,21 +398,9 @@ export default { return; } - if (interaction.customId.startsWith('app_review_')) { - try { - await handleApplicationReviewModal(interaction); - } catch (error) { - await handleInteractionError(interaction, error, withTraceContext({ - type: 'modal', - customId: interaction.customId, - handler: 'application_review' - }, interactionTraceContext)); - } - return; - } - if ( - interaction.customId.startsWith('jtc_') + interaction.customId.startsWith('app_review_') + || interaction.customId.startsWith('jtc_') || interaction.customId.startsWith('config_wizard_modal:') || interaction.customId.startsWith('log_dash_channel_modal:') || interaction.customId.startsWith('log_dash_filter_modal:') @@ -395,7 +442,7 @@ export default { } catch (error) { logger.error('Unhandled error in interactionCreate:', { event: 'interaction.unhandled_error', - errorCode: 'INTERACTION_UNHANDLED_ERROR', + errorCode: ErrorCodes.INTERACTION_UNHANDLED, error, traceId: interactionTraceContext.traceId, interactionId: interaction.id, @@ -413,7 +460,7 @@ export default { } catch (replyError) { logger.error('Failed to send fallback error response:', { event: 'interaction.error_response_failed', - errorCode: 'INTERACTION_ERROR_RESPONSE_FAILED', + errorCode: ErrorCodes.INTERACTION_RESPONSE_FAILED, error: replyError, traceId: interactionTraceContext.traceId }); diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index 323a2f4103..fb1a165f31 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -1,13 +1,14 @@ import { Events } from 'discord.js'; import { logger } from '../utils/logger.js'; -import { getLevelingConfig, getUserLevelData } from '../services/leveling.js'; -import { addXp } from '../services/xpSystem.js'; +import { getLevelingConfig, getUserLevelData } from '../services/leveling/leveling.js'; +import { addXp } from '../services/leveling/xpSystem.js'; import { checkRateLimit } from '../utils/rateLimiter.js'; import { parsePrefixCommand } from '../utils/prefixParser.js'; import { supportsPrefixExecution, executePrefixCommand, resolvePrefixAccessKey } from '../utils/messageAdapter.js'; -import { resolveCommandAlias, resolveSubcommandAlias } from '../config/commandAliases.js'; -import { getPrefixRestriction } from '../config/prefixRestrictions.js'; -import { getGuildConfig } from '../services/guildConfig.js'; +import { resolveCommandAlias, resolveSubcommandAlias } from '../config/commands/commandAliases.js'; +import { getPrefixRestriction } from '../config/commands/prefixRestrictions.js'; +import { getGuildConfig } from '../services/config/guildConfig.js'; +import { getCommandPrefix, getBotMessage, isBotOwner, isCommandCategoryEnabled, isMaintenanceMode } from '../config/bot.js'; import { enforceAbuseProtection, formatCooldownDuration } from '../utils/abuseProtection.js'; import { createEmbed } from '../utils/embeds.js'; import { isCommandEnabled } from '../services/commandAccessService.js'; @@ -46,7 +47,7 @@ export default { async function handlePrefixCommand(message, client) { try { const guildConfig = await getGuildConfig(client, message.guild.id); - const prefix = guildConfig?.prefix || client.config.bot.prefix || '!'; + const prefix = guildConfig?.prefix || getCommandPrefix(); const parsed = parsePrefixCommand(message.content, prefix); if (!parsed) { @@ -72,6 +73,28 @@ async function handlePrefixCommand(message, client) { return; } + if (isMaintenanceMode() && !isBotOwner(message.author.id)) { + await message.channel.send({ + embeds: [createEmbed({ + title: 'Maintenance Mode', + description: getBotMessage('maintenanceMode'), + color: 'warning', + })], + }).catch(() => {}); + return; + } + + if (!isCommandCategoryEnabled(command.category)) { + await message.channel.send({ + embeds: [createEmbed({ + title: 'Feature Disabled', + description: getBotMessage('commandDisabled'), + color: 'error', + })], + }).catch(() => {}); + return; + } + const restriction = getPrefixRestriction(command, args, resolveSubcommandAlias); if (!supportsPrefixExecution(command) || restriction.blocked) { if (restriction.blocked && restriction.reason) { @@ -218,8 +241,8 @@ async function handleLeveling(message, client) { } const result = await addXp(client, message.guild, message.member, finalXP); - - if (result.success && result.leveledUp) { + + if (result?.leveledUp) { logger.info( `${message.author.tag} leveled up to level ${result.level} in ${message.guild.name}` ); diff --git a/src/events/messageDelete.js b/src/events/messageDelete.js index 3339cd90d7..54d7a9c1db 100644 --- a/src/events/messageDelete.js +++ b/src/events/messageDelete.js @@ -2,7 +2,7 @@ import { Events } from 'discord.js'; import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; import { logger } from '../utils/logger.js'; import { getReactionRoleMessage, deleteReactionRoleMessage } from '../services/reactionRoleService.js'; -import { formatLogLine } from '../utils/logEmbeds.js'; +import { formatLogLine } from '../utils/logging/logEmbeds.js'; const MAX_LOGGED_MESSAGE_CONTENT_LENGTH = 1024; diff --git a/src/events/messageUpdate.js b/src/events/messageUpdate.js index 40154f44fd..18761fd553 100644 --- a/src/events/messageUpdate.js +++ b/src/events/messageUpdate.js @@ -1,7 +1,7 @@ import { Events } from 'discord.js'; import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; import { logger } from '../utils/logger.js'; -import { formatLogLine } from '../utils/logEmbeds.js'; +import { formatLogLine } from '../utils/logging/logEmbeds.js'; const MAX_LOGGED_EDIT_CONTENT_LENGTH = 512; diff --git a/src/events/ready.js b/src/events/ready.js index 63a22b5a2d..2be8e5e424 100644 --- a/src/events/ready.js +++ b/src/events/ready.js @@ -3,7 +3,7 @@ import { logger, startupLog } from "../utils/logger.js"; import config from "../config/application.js"; import { reconcileReactionRoleMessages } from "../services/reactionRoleService.js"; import { reconcileTicketPanels, reconcileVerificationPanels, reconcileReactionRolePanelHealth } from "../services/panelHealthService.js"; -import { reconcileLevelRoles } from "../services/levelRoleSyncService.js"; +import { reconcileLevelRoles } from "../services/leveling/levelRoleSyncService.js"; import { initRiffyAfterReady } from "../services/music/riffySetup.js"; export default { diff --git a/src/events/roleCreate.js b/src/events/roleCreate.js index ef81471433..1625974db1 100644 --- a/src/events/roleCreate.js +++ b/src/events/roleCreate.js @@ -1,7 +1,7 @@ import { Events } from 'discord.js'; import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; import { logger } from '../utils/logger.js'; -import { buildRoleAuditLines } from '../utils/logEmbeds.js'; +import { buildRoleAuditLines } from '../utils/logging/logEmbeds.js'; export default { name: Events.GuildRoleCreate, diff --git a/src/events/roleDelete.js b/src/events/roleDelete.js index bf852e2120..1e16b23dc8 100644 --- a/src/events/roleDelete.js +++ b/src/events/roleDelete.js @@ -1,7 +1,7 @@ import { Events } from 'discord.js'; import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; import { logger } from '../utils/logger.js'; -import { buildRoleAuditLines } from '../utils/logEmbeds.js'; +import { buildRoleAuditLines } from '../utils/logging/logEmbeds.js'; export default { name: Events.GuildRoleDelete, diff --git a/src/handlers/calculateModals.js b/src/handlers/calculateModals.js index 9d7b2321a5..d83d013261 100644 --- a/src/handlers/calculateModals.js +++ b/src/handlers/calculateModals.js @@ -2,6 +2,7 @@ import { successEmbed } from '../utils/embeds.js'; import { logger } from '../utils/logger.js'; import { evaluateMathExpression } from '../utils/safeMathParser.js'; +import { replyUserError, ErrorTypes } from '../utils/errorHandler.js'; function evaluate(expression) { return evaluateMathExpression(expression); } diff --git a/src/handlers/countdownButtons.js b/src/handlers/countdownButtons.js index 4c7413e84f..d8c2b2a893 100644 --- a/src/handlers/countdownButtons.js +++ b/src/handlers/countdownButtons.js @@ -1,7 +1,8 @@ -import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, PermissionFlagsBits } from 'discord.js'; import { successEmbed } from '../utils/embeds.js'; import { logger } from '../utils/logger.js'; +import { replyUserError, ErrorTypes } from '../utils/errorHandler.js'; function createControlButtons(countdownId, isPaused = false) { return new ActionRowBuilder().addComponents( new ButtonBuilder() @@ -112,7 +113,7 @@ async function countdownButtonHandler(interaction, client, args) { }); } - if (!interaction.member.permissions.has("MANAGE_MESSAGES")) { + if (!interaction.member.permissions.has(PermissionFlagsBits.ManageMessages)) { return await interaction.reply({ content: 'You need the "Manage Messages" permission to control countdowns.', flags: ["Ephemeral"], diff --git a/src/handlers/counterButtons.js b/src/handlers/counterButtons.js index 5370b2d5a7..fedd0b9370 100644 --- a/src/handlers/counterButtons.js +++ b/src/handlers/counterButtons.js @@ -2,7 +2,7 @@ import { MessageFlags } from 'discord.js'; import { createEmbed, successEmbed } from '../utils/embeds.js'; import { performDeletionByCounterId } from '../commands/ServerStats/modules/serverstats_delete.js'; import { logger } from '../utils/logger.js'; -import { ErrorTypes, replyUserError } from '../utils/errorHandler.js'; +import { ErrorTypes, replyUserError, handleInteractionError } from '../utils/errorHandler.js'; export const counterDeleteActionHandler = { name: 'counter-delete', @@ -50,24 +50,18 @@ export const counterDeleteActionHandler = { return; } - const result = await performDeletionByCounterId(client, interaction.guild, counterId); - - if (!result.success) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: result.message }).catch(logger.error); - return; - } + const { message } = await performDeletionByCounterId(client, interaction.guild, counterId); await interaction.editReply({ - embeds: [successEmbed(result.message)], + embeds: [successEmbed(message)], components: [] }).catch(logger.error); } catch (error) { - logger.error('Error handling counter-delete button:', error); - if (!interaction.replied && !interaction.deferred) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while processing this action.' }).catch(() => null); - } else { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while processing this action.' }).catch(() => null); - } + await handleInteractionError(interaction, error, { + type: 'button', + handler: 'counter_delete', + customId: interaction.customId, + }); } } }; diff --git a/src/handlers/giveawayButtons.js b/src/handlers/giveawayButtons.js index 67513154b7..ab0f46603e 100644 --- a/src/handlers/giveawayButtons.js +++ b/src/handlers/giveawayButtons.js @@ -1,7 +1,7 @@ import { MessageFlags, PermissionFlagsBits } from 'discord.js'; import { successEmbed } from '../utils/embeds.js'; import { logger } from '../utils/logger.js'; -import { TitanBotError, ErrorTypes, handleInteractionError } from '../utils/errorHandler.js'; +import { TitanBotError, ErrorTypes, handleInteractionError, replyUserError } from '../utils/errorHandler.js'; import { getGuildGiveaways, saveGiveaway, diff --git a/src/handlers/helpButtons.js b/src/handlers/help/helpButtons.js similarity index 93% rename from src/handlers/helpButtons.js rename to src/handlers/help/helpButtons.js index 86f589e8c9..b63568865f 100644 --- a/src/handlers/helpButtons.js +++ b/src/handlers/help/helpButtons.js @@ -1,10 +1,9 @@ -import { createEmbed } from '../utils/embeds.js'; +import { createEmbed } from '../../utils/embeds.js'; import { createAllCommandsMenu } from './helpSelectMenus.js'; -import { createInitialHelpMenu } from '../commands/Core/help.js'; +import { createInitialHelpMenu } from '../../commands/Core/help.js'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js'; -import { logger } from '../utils/logger.js'; +import { logger } from '../../utils/logger.js'; -const COMMAND_LIST_ID = "help-command-list"; const BACK_BUTTON_ID = "help-back-to-main"; const PAGINATION_PREFIX = "help-page"; const BUG_REPORT_BUTTON_ID = "help-bug-report"; @@ -73,14 +72,6 @@ export const helpBugReportButton = { }, }; -export const helpReportCommand = { - name: COMMAND_LIST_ID, - categoryName: null, - async execute(interaction, client) { - - } -}; - function getPaginationInfo(components) { for (const row of components || []) { for (const component of row.components || []) { diff --git a/src/handlers/helpSelectMenus.js b/src/handlers/help/helpSelectMenus.js similarity index 94% rename from src/handlers/helpSelectMenus.js rename to src/handlers/help/helpSelectMenus.js index 2ff8bc9240..960826e7de 100644 --- a/src/handlers/helpSelectMenus.js +++ b/src/handlers/help/helpSelectMenus.js @@ -1,10 +1,11 @@ -import { createEmbed } from '../utils/embeds.js'; -import { createButton, getPaginationRow } from '../utils/components.js'; +import { createEmbed } from '../../utils/embeds.js'; +import { createButton, getPaginationRow } from '../../utils/components.js'; import fs from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; import { Collection, ActionRowBuilder, MessageFlags } from 'discord.js'; -import { logger } from '../utils/logger.js'; +import { logger } from '../../utils/logger.js'; +import { handleInteractionError } from '../../utils/errorHandler.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -126,7 +127,7 @@ async function createCategoryCommandsMenu(category, client) { const categoryCommands = []; try { - const categoryPath = path.join(__dirname, "../commands", category); + const categoryPath = path.join(__dirname, "../../commands", category); const commandFiles = (await fs.readdir(categoryPath)) .filter((file) => file.endsWith(".js")) .sort(); @@ -241,7 +242,7 @@ export async function createAllCommandsMenu(page = 1, client) { const commandsPerPage = 45; const allCommands = []; - const commandsPath = path.join(__dirname, "../commands"); + const commandsPath = path.join(__dirname, "../../commands"); const categoryDirs = ( await fs.readdir(commandsPath, { withFileTypes: true }) ) @@ -253,7 +254,7 @@ export async function createAllCommandsMenu(page = 1, client) { try { const categoryPath = path.join( __dirname, - "../commands", + "../../commands", category, ); const commandFiles = (await fs.readdir(categoryPath)) @@ -404,13 +405,11 @@ export const helpCategorySelectMenu = { return; } - logger.error('Error in help category select menu handler:', error); - if (!interaction.replied && !interaction.deferred) { - await interaction.reply({ - content: 'An error occurred while loading help categories.', - flags: MessageFlags.Ephemeral, - }); - } + await handleInteractionError(interaction, error, { + type: 'select_menu', + customId: interaction.customId, + handler: 'help_category', + }); } }, }; \ No newline at end of file diff --git a/src/handlers/commandLoader.js b/src/handlers/loaders/commandLoader.js similarity index 76% rename from src/handlers/commandLoader.js rename to src/handlers/loaders/commandLoader.js index 7a7d2baa34..ecb7ad96d4 100644 --- a/src/handlers/commandLoader.js +++ b/src/handlers/loaders/commandLoader.js @@ -2,7 +2,8 @@ import fs from 'fs/promises'; import path from 'path'; import { fileURLToPath, pathToFileURL } from 'url'; import { Collection } from 'discord.js'; -import { logger } from '../utils/logger.js'; +import { logger } from '../../utils/logger.js'; +import botConfig from '../../config/bot.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -52,7 +53,7 @@ async function getAllFiles(directory, fileList = []) { export async function loadCommands(client) { client.commands = new Collection(); - const commandsPath = path.join(__dirname, '../commands'); + const commandsPath = path.join(__dirname, '../../commands'); const commandFiles = await getAllFiles(commandsPath); logger.info(`Found ${commandFiles.length} command files to load`); @@ -220,12 +221,9 @@ function validateCommands(commands) { } } -function prepareCommandsForRegistration(commands, { multiGuild = false } = {}) { +function prepareCommandsForRegistration(commands) { if (commands.length >= COMMAND_COUNT_WARN_THRESHOLD) { - logger.warn( - `Command count (${commands.length}) is near Discord's ${MAX_COMMANDS} limit` + - (multiGuild ? ' for global registration' : ' for guild registration') - ); + logger.warn(`Command count (${commands.length}) is near Discord's ${MAX_COMMANDS} global command limit`); } if (commands.length <= MAX_COMMANDS) { @@ -240,19 +238,24 @@ function prepareCommandsForRegistration(commands, { multiGuild = false } = {}) { async function registerGlobalCommands(client, clientId, commands, totalSubcommands) { if (!clientId) { - throw new Error('CLIENT_ID is required for global command registration when MULTI_GUILD=true'); + throw new Error('CLIENT_ID is required for slash command registration'); } if (!client.rest) { - throw new Error('Discord REST client is not available for global command registration'); + throw new Error('Discord REST client is not available for slash command registration'); } - logger.info(`Preparing to register ${totalSubcommands + commands.length} commands globally (multi-guild mode)`); + logger.info(`Preparing to register ${totalSubcommands + commands.length} commands globally`); logger.info('Validating commands before registration...'); validateCommands(commands); logger.info('Command validation passed'); - const commandsToRegister = prepareCommandsForRegistration(commands, { multiGuild: true }); + const commandsToRegister = prepareCommandsForRegistration(commands); + + if (botConfig.commands?.deleteCommands) { + logger.info('Clearing existing global commands before registration...'); + await client.rest.put(`/applications/${clientId}/commands`, { body: [] }); + } logger.info(`Registering ${commandsToRegister.length} global commands...`); await client.rest.put(`/applications/${clientId}/commands`, { body: commandsToRegister }); @@ -260,75 +263,12 @@ async function registerGlobalCommands(client, clientId, commands, totalSubcomman logger.info('Global commands may take up to an hour to appear in all servers on first deploy'); } -async function registerGuildCommands(client, guildId, commands, totalSubcommands) { - logger.info(`Preparing to register ${totalSubcommands + commands.length} commands for guild ${guildId}`); - logger.info('Validating commands before registration...'); - validateCommands(commands); - logger.info('Command validation passed'); - - const guild = await client.guilds.fetch(guildId); - const existingCommands = await guild.commands.fetch(); - logger.info(`Found ${existingCommands.size} existing guild commands`); - - const commandsToRegister = prepareCommandsForRegistration(commands, { multiGuild: false }); - - if (process.env.NODE_ENV !== 'production') { - logger.info(`Registering ${commandsToRegister.length} commands for guild ${guild.name} (${guild.id})`); - } - - try { - logger.info(`Registering ${commandsToRegister.length} guild commands...`); - await guild.commands.set(commandsToRegister); - logger.info(`Successfully registered ${commandsToRegister.length} guild commands`); - - const registeredCommands = await guild.commands.fetch(); - if (registeredCommands.size !== commandsToRegister.length) { - logger.warn(`Warning: Expected ${commandsToRegister.length} commands, but Discord reports ${registeredCommands.size} registered`); - } else { - logger.info(`Verification passed: ${registeredCommands.size} commands successfully registered`); - } - } catch (error) { - logger.error('Failed to register commands:', error); - - if (existingCommands.size > 0) { - logger.info('Attempting to restore previous commands due to registration failure...'); - try { - await guild.commands.set(existingCommands.map((cmd) => cmd)); - logger.info('Successfully restored previous commands'); - } catch (restoreError) { - logger.error('Failed to restore previous commands:', restoreError); - } - } - - throw error; - } -} - export async function registerCommands(client, options = {}) { - const { - clientId = null, - guildId = null, - multiGuild = false, - } = options; + const { clientId = null } = options; try { const { commands, totalSubcommands } = collectCommandPayloads(client); - - if (multiGuild) { - logger.info('Command registration mode: multi-guild (global commands)'); - await registerGlobalCommands(client, clientId, commands, totalSubcommands); - return; - } - - if (!guildId) { - logger.warn( - 'Command registration skipped: set GUILD_ID for single-server setup, or MULTI_GUILD=true for multi-server support' - ); - return; - } - - logger.info(`Command registration mode: single-guild (GUILD_ID=${guildId})`); - await registerGuildCommands(client, guildId, commands, totalSubcommands); + await registerGlobalCommands(client, clientId, commands, totalSubcommands); } catch (error) { logger.error('Error registering commands:', error); throw error; diff --git a/src/handlers/events.js b/src/handlers/loaders/events.js similarity index 93% rename from src/handlers/events.js rename to src/handlers/loaders/events.js index e941604f88..deca4eb1b3 100644 --- a/src/handlers/events.js +++ b/src/handlers/loaders/events.js @@ -2,13 +2,13 @@ import { readdir } from 'fs/promises'; import { join } from 'path'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; -import { logger } from '../utils/logger.js'; +import { logger } from '../../utils/logger.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); export default async function loadEvents(client) { - const eventsPath = join(__dirname, '../events'); + const eventsPath = join(__dirname, '../../events'); const eventFiles = await readdir(eventsPath).then(files => files.filter(file => file.endsWith('.js'))); logger.info(`Found ${eventFiles.length} event files to load`); diff --git a/src/handlers/interactions.js b/src/handlers/loaders/interactions.js similarity index 53% rename from src/handlers/interactions.js rename to src/handlers/loaders/interactions.js index e52db15701..12a276268a 100644 --- a/src/handlers/interactions.js +++ b/src/handlers/loaders/interactions.js @@ -1,43 +1,62 @@ import { readdir } from 'fs/promises'; import { join } from 'path'; -import { fileURLToPath } from 'url'; +import { fileURLToPath, pathToFileURL } from 'url'; import { dirname } from 'path'; -import { logger } from '../utils/logger.js'; +import { logger } from '../../utils/logger.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const interactionTypes = ['buttons', 'selectMenus', 'modals']; +async function getAllInteractionFiles(directory, fileList = []) { + const entries = await readdir(directory, { withFileTypes: true }); + + for (const entry of entries) { + const entryPath = join(directory, entry.name); + + if (entry.isDirectory()) { + await getAllInteractionFiles(entryPath, fileList); + } else if (entry.name.endsWith('.js')) { + fileList.push(entryPath); + } + } + + return fileList; +} + export default async (client) => { try { - const interactionsPath = join(__dirname, '../interactions'); - + const interactionsPath = join(__dirname, '../../interactions'); + for (const type of interactionTypes) { const typePath = join(interactionsPath, type); - + try { - const interactionFiles = (await readdir(typePath)).filter(file => file.endsWith('.js')); + const interactionFiles = await getAllInteractionFiles(typePath); let loadedCount = 0; - - for (const file of interactionFiles) { + + for (const filePath of interactionFiles) { + const relativePath = filePath.slice(interactionsPath.length + 1).replace(/\\/g, '/'); + const fileName = relativePath.split('/').pop(); + try { - const module = await import(`../interactions/${type}/${file}`); + const module = await import(pathToFileURL(filePath).href); const moduleExport = module.default; const interactions = Array.isArray(moduleExport) ? moduleExport : [moduleExport]; for (const interaction of interactions) { if (!interaction?.name || !interaction?.execute) { - logger.warn(`Interaction ${file} in ${type} is missing required properties.`); + logger.warn(`Interaction ${relativePath} in ${type} is missing required properties.`); continue; } client[type].set(interaction.name, interaction); loadedCount += 1; - logger.info(`Loaded ${type.slice(0, -1)}: ${interaction.name}`); + logger.info(`Loaded ${type.slice(0, -1)}: ${interaction.name} (${fileName})`); } } catch (error) { - logger.error(`Error loading interaction ${file} in ${type}:`, error); + logger.error(`Error loading interaction ${relativePath} in ${type}:`, error); } } @@ -53,4 +72,4 @@ export default async (client) => { } catch (error) { logger.error('Error loading interactions:', error); } -}; \ No newline at end of file +}; diff --git a/src/handlers/loggingButtons.js b/src/handlers/loggingButtons.js index 014cf0298f..0f5144c205 100644 --- a/src/handlers/loggingButtons.js +++ b/src/handlers/loggingButtons.js @@ -18,9 +18,9 @@ import { updateIgnoreList, getIgnoreList, } from '../services/loggingService.js'; -import { getGuildConfig } from '../services/guildConfig.js'; +import { getGuildConfig } from '../services/config/guildConfig.js'; import { successEmbed } from '../utils/embeds.js'; -import { replyUserError, ErrorTypes } from '../utils/errorHandler.js'; +import { replyUserError, ErrorTypes, handleInteractionError } from '../utils/errorHandler.js'; import { logger } from '../utils/logger.js'; import { buildLoggingDashboardView, @@ -77,11 +77,11 @@ export default { return handleToggle(interaction); } } catch (error) { - logger.error('Error in logging button handler:', error); - await interaction.reply({ - content: '❌ An error occurred while processing your request.', - ephemeral: true, - }).catch(() => {}); + await handleInteractionError(interaction, error, { + type: 'button', + customId: interaction.customId, + handler: 'logging', + }); } }, }; @@ -365,11 +365,11 @@ async function showChannelModal(interaction, destination) { if (error.code === 'INTERACTION_TIMEOUT') { return; } - logger.error('Error in logging channel modal:', error); - await interaction.followUp({ - content: '❌ Failed to update the log channel.', - flags: MessageFlags.Ephemeral, - }).catch(() => {}); + await handleInteractionError(interaction, error, { + type: 'modal', + customId: interaction.customId, + handler: 'logging_channel', + }); } } diff --git a/src/handlers/musicButtons.js b/src/handlers/musicButtons.js index a8f53170ca..13b8acd9c6 100644 --- a/src/handlers/musicButtons.js +++ b/src/handlers/musicButtons.js @@ -100,6 +100,11 @@ async function handleMusicButton(interaction, client) { await applyResume(client, interaction.guild.id); break; case MUSIC_BUTTON_IDS.SKIP: + // Under track-loop, stop() would replay the same track. Clear it so the + // skip advances; trackStart re-applies the stored loop to the next track. + if (player.loop === 'track') { + player.setLoop('none'); + } player.stop(); break; case MUSIC_BUTTON_IDS.STOP: @@ -132,7 +137,11 @@ async function handleMusicButton(interaction, client) { break; } } catch (error) { - logger.error('Music button handler error:', error); + await handleInteractionError(interaction, error, { + type: 'button', + customId: interaction.customId, + handler: 'music', + }); } } diff --git a/src/handlers/ticketButtons.js b/src/handlers/ticketButtons.js index cedc7fc3db..76909a6d1c 100644 --- a/src/handlers/ticketButtons.js +++ b/src/handlers/ticketButtons.js @@ -1,13 +1,13 @@ import { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, AttachmentBuilder, MessageFlags } from 'discord.js'; import { createEmbed, successEmbed } from '../utils/embeds.js'; import { createTicket, closeTicket, claimTicket, updateTicketPriority } from '../services/ticket.js'; -import { getGuildConfig } from '../services/guildConfig.js'; -import { logTicketEvent } from '../utils/ticketLogging.js'; +import { getGuildConfig } from '../services/config/guildConfig.js'; +import { logTicketEvent } from '../utils/ticket/ticketLogging.js'; import { logger } from '../utils/logger.js'; 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 { replyUserError, ErrorTypes, handleInteractionError, createError } from '../utils/errorHandler.js'; +import { getTicketPermissionContext } from '../utils/ticket/ticketPermissions.js'; function escapeHtml(text) { if (!text) return ''; @@ -31,47 +31,52 @@ async function ensureGuildContext(interaction) { return false; } -async function replyPermissionCheckFailure(interaction, permissionCheck) { - let type = ErrorTypes.UNKNOWN; - if (permissionCheck.error === 'Permission Denied') { - type = ErrorTypes.PERMISSION; - } else if (permissionCheck.error === 'Request Timeout') { - type = ErrorTypes.RATE_LIMIT; - } - - await replyUserError(interaction, { type, message: permissionCheck.details }); -} - -async function checkTicketPermissionWithTimeout(interaction, client, actionLabel, options = {}, timeoutMs = 2500) { +async function assertTicketPermission(interaction, client, actionLabel, options = {}, timeoutMs = 2500) { const { allowTicketCreator = false } = options; + let context; try { const contextPromise = getTicketPermissionContext({ client, interaction }); const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), timeoutMs) ); - - const context = await Promise.race([contextPromise, timeoutPromise]); - - if (!context.ticketData) { - return { success: false, error: 'Not a Ticket Channel', details: 'This action can only be used in a valid ticket channel.' }; - } - - const allowed = allowTicketCreator ? context.canCloseTicket : context.canManageTicket; - if (!allowed) { - const permissionMessage = allowTicketCreator - ? 'You must have **Manage Channels**, the configured **Ticket Staff Role**, or be the **ticket creator**.' - : 'You must have **Manage Channels** or the configured **Ticket Staff Role**.'; - return { success: false, error: 'Permission Denied', details: `${permissionMessage}\n\nYou cannot ${actionLabel}.` }; - } - - return { success: true, context }; + context = await Promise.race([contextPromise, timeoutPromise]); } catch (error) { if (error.message === 'Timeout') { - return { success: false, error: 'Request Timeout', details: 'The permission check took too long. Please try again.' }; + throw createError( + 'Ticket permission timeout', + ErrorTypes.RATE_LIMIT, + 'The permission check took too long. Please try again.' + ); } - return { success: false, error: 'Error', details: `Failed to check permissions: ${error.message}` }; + throw createError( + 'Ticket permission check failed', + ErrorTypes.UNKNOWN, + `Failed to check permissions: ${error.message}` + ); + } + + if (!context.ticketData) { + throw createError( + 'Not a ticket channel', + ErrorTypes.VALIDATION, + 'This action can only be used in a valid ticket channel.' + ); } + + const allowed = allowTicketCreator ? context.canCloseTicket : context.canManageTicket; + if (!allowed) { + const permissionMessage = allowTicketCreator + ? 'You must have **Manage Channels**, the configured **Ticket Staff Role**, or be the **ticket creator**.' + : 'You must have **Manage Channels** or the configured **Ticket Staff Role**.'; + throw createError( + 'Ticket permission denied', + ErrorTypes.PERMISSION, + `${permissionMessage}\n\nYou cannot ${actionLabel}.` + ); + } + + return context; } async function ensureTicketPermission(interaction, client, actionLabel, options = {}) { @@ -158,26 +163,20 @@ const createTicketModalHandler = { const config = await getGuildConfig(client, interaction.guildId); const categoryId = config.ticketCategoryId || null; - const result = await createTicket( + const { channel } = await createTicket( interaction.guild, interaction.member, categoryId, reason ); - - 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.' }); - } + await interaction.editReply({ + embeds: [successEmbed( + 'Ticket Created', + `Your ticket has been created in ${channel}!` + )] + }); } catch (error) { - logger.error('Error creating ticket:', error); - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while creating your ticket.' }); + await handleInteractionError(interaction, error, { type: 'button', handler: 'ticket', customId: interaction.customId }); } } }; @@ -188,18 +187,7 @@ const closeTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - const permissionCheck = await checkTicketPermissionWithTimeout( - interaction, - client, - 'close this ticket', - { allowTicketCreator: true }, - 2000 - ); - - if (!permissionCheck.success) { - await replyPermissionCheckFailure(interaction, permissionCheck); - return; - } + await assertTicketPermission(interaction, client, 'close this ticket', { allowTicketCreator: true }, 2000); const modal = new ModalBuilder() .setCustomId('ticket_close_modal') @@ -233,18 +221,7 @@ const closeTicketModalHandler = { try { if (!(await ensureGuildContext(interaction))) return; - const permissionCheck = await checkTicketPermissionWithTimeout( - interaction, - client, - 'close this ticket', - { allowTicketCreator: true }, - 2000 - ); - - if (!permissionCheck.success) { - await replyPermissionCheckFailure(interaction, permissionCheck); - return; - } + await assertTicketPermission(interaction, client, 'close this ticket', { allowTicketCreator: true }, 2000); const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; @@ -252,16 +229,8 @@ const closeTicketModalHandler = { const providedReason = interaction.fields.getTextInputValue('reason')?.trim(); const reason = providedReason || 'Closed via ticket button without a specific reason.'; - const result = await closeTicket(interaction.channel, interaction.user, reason); - - if (result.success) { - await interaction.editReply({ - embeds: [successEmbed('Ticket Closed', 'This ticket has been closed.')], - flags: MessageFlags.Ephemeral - }); - } else { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: result.error || 'Failed to close ticket.' }); - } + await closeTicket(interaction.channel, interaction.user, reason); + await interaction.editReply({ embeds: [successEmbed('Ticket Closed', 'This ticket has been closed.')] }); } catch (error) { logger.error('Error submitting close ticket modal:', error); if (!interaction.replied && !interaction.deferred) { @@ -279,32 +248,13 @@ const claimTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - const permissionCheck = await checkTicketPermissionWithTimeout( - interaction, - client, - 'claim tickets', - {}, - 2000 - ); - - if (!permissionCheck.success) { - await replyPermissionCheckFailure(interaction, permissionCheck); - return; - } + await assertTicketPermission(interaction, client, 'claim tickets', {}, 2000); const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; - const result = await claimTicket(interaction.channel, interaction.user); - - if (result.success) { - await interaction.editReply({ - embeds: [successEmbed('Ticket Claimed', 'You have successfully claimed this ticket!')], - flags: MessageFlags.Ephemeral - }); - } else { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: result.error || 'Failed to claim ticket.' }); - } + await claimTicket(interaction.channel, interaction.user); + await interaction.editReply({ embeds: [successEmbed('Ticket Claimed', 'You have claimed this ticket.')] }); } catch (error) { logger.error('Error claiming ticket:', error); if (!interaction.replied && !interaction.deferred) { @@ -322,18 +272,7 @@ const priorityTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - const permissionCheck = await checkTicketPermissionWithTimeout( - interaction, - client, - 'change ticket priority', - {}, - 2000 - ); - - if (!permissionCheck.success) { - await replyPermissionCheckFailure(interaction, permissionCheck); - return; - } + await assertTicketPermission(interaction, client, 'change ticket priority', {}, 2000); const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; @@ -344,16 +283,8 @@ const priorityTicketHandler = { return; } - const result = await updateTicketPriority(interaction.channel, priority, interaction.user); - - if (result.success) { - await interaction.editReply({ - embeds: [successEmbed('Priority Updated', `Ticket priority set to ${priority}.`)], - flags: MessageFlags.Ephemeral - }); - } else { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: result.error || 'Failed to update priority.' }); - } + await updateTicketPriority(interaction.channel, priority, interaction.user); + await interaction.editReply({ embeds: [successEmbed('Priority Updated', `Ticket priority set to **${priority.toUpperCase()}**.`)] }); } catch (error) { logger.error('Error updating ticket priority:', error); if (!interaction.replied && !interaction.deferred) { @@ -371,18 +302,7 @@ const pinTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - const permissionCheck = await checkTicketPermissionWithTimeout( - interaction, - client, - 'pin tickets', - {}, - 2000 - ); - - if (!permissionCheck.success) { - await replyPermissionCheckFailure(interaction, permissionCheck); - return; - } + await assertTicketPermission(interaction, client, 'pin tickets', {}, 2000); const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; @@ -478,33 +398,14 @@ const unclaimTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - const permissionCheck = await checkTicketPermissionWithTimeout( - interaction, - client, - 'unclaim tickets', - {}, - 2000 - ); - - if (!permissionCheck.success) { - await replyPermissionCheckFailure(interaction, permissionCheck); - return; - } + await assertTicketPermission(interaction, client, 'unclaim tickets', {}, 2000); const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; const { unclaimTicket } = await import('../services/ticket.js'); - const result = await unclaimTicket(interaction.channel, interaction.member); - - if (result.success) { - await interaction.editReply({ - embeds: [successEmbed('Ticket Unclaimed', 'You have successfully unclaimed this ticket!')], - flags: MessageFlags.Ephemeral - }); - } else { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: result.error || 'Failed to unclaim ticket.' }); - } + await unclaimTicket(interaction.channel, interaction.member); + await interaction.editReply({ embeds: [successEmbed('Ticket Unclaimed', 'This ticket has been unclaimed.')] }); } catch (error) { logger.error('Error unclaiming ticket:', error); if (!interaction.replied && !interaction.deferred) { @@ -522,38 +423,18 @@ const reopenTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - const permissionCheck = await checkTicketPermissionWithTimeout( - interaction, - client, - 'reopen tickets', - {}, - 2000 - ); - - if (!permissionCheck.success) { - await replyPermissionCheckFailure(interaction, permissionCheck); - return; - } + await assertTicketPermission(interaction, client, 'reopen tickets', {}, 2000); const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; const { reopenTicket } = await import('../services/ticket.js'); - const result = await reopenTicket(interaction.channel, interaction.member); - - if (result.success) { - let reopenMessage = 'You have successfully reopened this ticket!'; - if (result.openCategoryMoveFailed) { - reopenMessage += '\n\n⚠️ The ticket was reopened, but it could not be moved to the configured open ticket category.'; - } - - await interaction.editReply({ - embeds: [successEmbed('Ticket Reopened', reopenMessage)], - flags: MessageFlags.Ephemeral - }); - } else { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: result.error || 'Failed to reopen ticket.' }); + const { movedToOpenCategory, openCategoryMoveFailed } = await reopenTicket(interaction.channel, interaction.member); + let reopenMessage = 'This ticket has been reopened.'; + if (openCategoryMoveFailed) { + reopenMessage += ' Note: Could not move the channel back to the open tickets category.'; } + await interaction.editReply({ embeds: [successEmbed('Ticket Reopened', reopenMessage)] }); } catch (error) { logger.error('Error reopening ticket:', error); if (!interaction.replied && !interaction.deferred) { @@ -571,33 +452,14 @@ const deleteTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - const permissionCheck = await checkTicketPermissionWithTimeout( - interaction, - client, - 'delete tickets', - {}, - 2000 - ); - - if (!permissionCheck.success) { - await replyPermissionCheckFailure(interaction, permissionCheck); - return; - } + await assertTicketPermission(interaction, client, 'delete tickets', {}, 2000); const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; const { deleteTicket } = await import('../services/ticket.js'); - const result = await deleteTicket(interaction.channel, interaction.member); - - if (result.success) { - await interaction.editReply({ - embeds: [successEmbed('Ticket Deleted', 'This ticket will be permanently deleted in 3 seconds.')], - flags: MessageFlags.Ephemeral - }); - } else { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: result.error || 'Failed to delete ticket.' }); - } + await deleteTicket(interaction.channel, interaction.member); + await interaction.editReply({ embeds: [successEmbed('Ticket Deleted', 'This ticket will be deleted shortly.')] }); } catch (error) { logger.error('Error deleting ticket:', error); if (!interaction.replied && !interaction.deferred) { diff --git a/src/handlers/todoButtons.js b/src/handlers/todoButtons.js index 21ee756a71..3b86997a11 100644 --- a/src/handlers/todoButtons.js +++ b/src/handlers/todoButtons.js @@ -4,6 +4,7 @@ import { getFromDb, setInDb } from '../utils/database.js'; import { checkRateLimit } from '../utils/rateLimiter.js'; import { logger } from '../utils/logger.js'; +import { replyUserError, ErrorTypes } from '../utils/errorHandler.js'; function buildSharedTodoViewPayload(listData, listId, guild) { const memberList = (listData.members || []).map(memberId => { const member = guild?.members?.cache?.get(memberId); @@ -302,7 +303,7 @@ const sharedTodoCompleteModalHandler = { } if (task.completed) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Task #${task.id} is already completed.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `Task #${task.id} is already completed.` }); } task.completed = true; diff --git a/src/handlers/verificationButtons.js b/src/handlers/verificationButtons.js index 68e253b57c..663621ec8f 100644 --- a/src/handlers/verificationButtons.js +++ b/src/handlers/verificationButtons.js @@ -1,7 +1,7 @@ import { MessageFlags } from 'discord.js'; import { successEmbed } from '../utils/embeds.js'; import { verifyUser } from '../services/verificationService.js'; -import { handleInteractionError } from '../utils/errorHandler.js'; +import { handleInteractionError, replyUserError, ErrorTypes } from '../utils/errorHandler.js'; import { logger } from '../utils/logger.js'; import { InteractionHelper } from '../utils/interactionHelper.js'; @@ -27,12 +27,8 @@ export async function handleVerificationButton(interaction, client) { moderatorId: null }); - if (!result.success) { - if (result.alreadyVerified) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You are already verified and have access to all server channels.' }); - } - - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred during verification. Please try again or contact an administrator.' }); + if (result.status === 'already_verified') { + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'You are already verified and have access to all server channels.' }); } logger.info('User verified via button', { diff --git a/src/handlers/warningHandlers.js b/src/handlers/warningHandlers.js index d9f356174b..26b5970bb2 100644 --- a/src/handlers/warningHandlers.js +++ b/src/handlers/warningHandlers.js @@ -1,9 +1,10 @@ -import { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder } from 'discord.js'; +import { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, MessageFlags } from 'discord.js'; import { successEmbed } from '../utils/embeds.js'; -import { WarningService } from '../services/warningService.js'; +import { WarningService } from '../services/moderation/warningService.js'; import { InteractionHelper } from '../utils/interactionHelper.js'; import { logger } from '../utils/logger.js'; +import { replyUserError, ErrorTypes } from '../utils/errorHandler.js'; const warningDeleteSpecificHandler = { name: 'warning_delete_specific', async execute(interaction, client) { @@ -96,15 +97,11 @@ async function warningDeleteModalHandler(interaction, client) { const warnings = await WarningService.getWarnings(guildId, targetUserId); if (warningNumber > warnings.length) { - return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Warning #${warningNumber} does not exist. This user only has ${warnings.length} warning(s).' }); + return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: `Warning #${warningNumber} does not exist. This user only has ${warnings.length} warning(s).` }); } const warningToDelete = warnings[warningNumber - 1]; - const result = await WarningService.removeWarning(guildId, targetUserId, warningToDelete.id); - - if (!result.success) { - throw new Error(result.error || 'Failed to delete warning'); - } + await WarningService.removeWarning(guildId, targetUserId, warningToDelete.id); const targetUser = await client.users.fetch(targetUserId).catch(() => null); const targetName = targetUser ? targetUser.username : 'the user'; @@ -138,14 +135,10 @@ async function warningClearConfirmModalHandler(interaction, client) { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You must type "DELETE" exactly to confirm clearing all warnings.' }); } - await interaction.deferReply({ flags: ['Ephemeral'] }); + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); const guildId = interaction.guildId; - const result = await WarningService.clearWarnings(guildId, targetUserId); - - if (!result.success) { - throw new Error(result.error || 'Failed to clear warnings'); - } + const { count } = await WarningService.clearWarnings(guildId, targetUserId); const targetUser = await client.users.fetch(targetUserId).catch(() => null); const targetName = targetUser ? targetUser.username : 'the user'; @@ -153,7 +146,7 @@ async function warningClearConfirmModalHandler(interaction, client) { logger.info(`[MODERATION] All warnings cleared for ${targetUserId} in ${guildId} by ${interaction.user.id}`); await interaction.editReply({ - embeds: [successEmbed('✅ Warnings Cleared', `All warnings for **${targetName}** have been cleared. **${result.count}** warning(s) removed.`)] + embeds: [successEmbed('✅ Warnings Cleared', `All warnings for **${targetName}** have been cleared. **${count}** warning(s) removed.`)] }); } catch (error) { logger.error('Warning clear confirm modal handler error:', error); diff --git a/src/handlers/wipedataButtons.js b/src/handlers/wipedataButtons.js index 891eb2c3dc..57c2d7ce17 100644 --- a/src/handlers/wipedataButtons.js +++ b/src/handlers/wipedataButtons.js @@ -3,6 +3,16 @@ import { InteractionHelper } from '../utils/interactionHelper.js'; import { MessageFlags } from 'discord.js'; import { logger } from '../utils/logger.js'; +import { replyUserError, ErrorTypes } from '../utils/errorHandler.js'; +import { + getEconomyKey, + getUserLevelKey, + getAFKKey, + getWarningsKey, + getUserNotesKey, + getEconomyPrefix, + getUserLevelPrefix, +} from '../utils/database.js'; const wipedataConfirmHandler = { name: 'wipedata_yes', async execute(interaction, client) { @@ -14,7 +24,11 @@ const wipedataConfirmHandler = { const guildId = interaction.guildId; const dataKeyPatterns = [ - `economy:${guildId}:${userId}`, + getEconomyKey(guildId, userId), + getUserLevelKey(guildId, userId), + getAFKKey(guildId, userId), + getWarningsKey(guildId, userId), + getUserNotesKey(guildId, userId), `level:${guildId}:${userId}`, `xp:${guildId}:${userId}`, `inventory:${guildId}:${userId}`, @@ -36,6 +50,7 @@ const wipedataConfirmHandler = { `lastWork:${guildId}:${userId}`, `lastCrime:${guildId}:${userId}`, `lastRob:${guildId}:${userId}`, + `${guildId}:leveling:users:${userId}`, ]; let deletedCount = 0; @@ -59,7 +74,8 @@ const wipedataConfirmHandler = { const searchPrefixes = [ `${guildId}:${userId}`, `${guildId}:`, - `economy:${guildId}:`, + getEconomyPrefix(guildId), + getUserLevelPrefix(guildId), `level:${guildId}:`, `xp:${guildId}:`, `user:${guildId}:` diff --git a/src/interactions/buttons/countdown.js b/src/interactions/buttons/countdown/countdown.js similarity index 69% rename from src/interactions/buttons/countdown.js rename to src/interactions/buttons/countdown/countdown.js index 6503f40ca5..78da057e53 100644 --- a/src/interactions/buttons/countdown.js +++ b/src/interactions/buttons/countdown/countdown.js @@ -1,4 +1,4 @@ -import countdownButtonHandler from '../../handlers/countdownButtons.js'; +import countdownButtonHandler from '../../../handlers/countdownButtons.js'; export default [ { diff --git a/src/interactions/buttons/counter/counterDelete.js b/src/interactions/buttons/counter/counterDelete.js new file mode 100644 index 0000000000..5f08ca1a04 --- /dev/null +++ b/src/interactions/buttons/counter/counterDelete.js @@ -0,0 +1,3 @@ +import counterDeleteActionHandler from '../../../handlers/counterButtons.js'; + +export default counterDeleteActionHandler; \ No newline at end of file diff --git a/src/interactions/buttons/counterDelete.js b/src/interactions/buttons/counterDelete.js deleted file mode 100644 index df171dcb10..0000000000 --- a/src/interactions/buttons/counterDelete.js +++ /dev/null @@ -1,3 +0,0 @@ -import counterDeleteActionHandler from '../../handlers/counterButtons.js'; - -export default counterDeleteActionHandler; \ No newline at end of file diff --git a/src/interactions/buttons/giveaway.js b/src/interactions/buttons/giveaway/giveaway.js similarity index 89% rename from src/interactions/buttons/giveaway.js rename to src/interactions/buttons/giveaway/giveaway.js index 962b6f5da4..a614a5cd85 100644 --- a/src/interactions/buttons/giveaway.js +++ b/src/interactions/buttons/giveaway/giveaway.js @@ -3,7 +3,7 @@ import { giveawayEndHandler, giveawayRerollHandler, giveawayViewHandler, -} from '../../handlers/giveawayButtons.js'; +} from '../../../handlers/giveawayButtons.js'; function fromCustomId(handler) { return { diff --git a/src/interactions/buttons/help.js b/src/interactions/buttons/help/help.js similarity index 88% rename from src/interactions/buttons/help.js rename to src/interactions/buttons/help/help.js index 9e7a011262..61ab55ee21 100644 --- a/src/interactions/buttons/help.js +++ b/src/interactions/buttons/help/help.js @@ -2,7 +2,7 @@ import { helpBackButton, helpBugReportButton, helpPaginationButton, -} from '../../handlers/helpButtons.js'; +} from '../../../handlers/help/helpButtons.js'; const paginationIds = [ 'help-page_first', diff --git a/src/interactions/buttons/logging.js b/src/interactions/buttons/logging/logging.js similarity index 85% rename from src/interactions/buttons/logging.js rename to src/interactions/buttons/logging/logging.js index bd389c81f1..d18d028c5a 100644 --- a/src/interactions/buttons/logging.js +++ b/src/interactions/buttons/logging/logging.js @@ -1,4 +1,4 @@ -import loggingButtonsHandler from '../../handlers/loggingButtons.js'; +import loggingButtonsHandler from '../../../handlers/loggingButtons.js'; export default [ { diff --git a/src/interactions/buttons/music.js b/src/interactions/buttons/music/music.js similarity index 78% rename from src/interactions/buttons/music.js rename to src/interactions/buttons/music/music.js index d6ce6e3bde..3a69f9f3fb 100644 --- a/src/interactions/buttons/music.js +++ b/src/interactions/buttons/music/music.js @@ -1,5 +1,5 @@ -import musicButtonHandler from '../../handlers/musicButtons.js'; -import { MUSIC_BUTTON_IDS } from '../../services/music/musicEmbeds.js'; +import musicButtonHandler from '../../../handlers/musicButtons.js'; +import { MUSIC_BUTTON_IDS } from '../../../services/music/musicEmbeds.js'; const buttonIds = [ MUSIC_BUTTON_IDS.PAUSE, diff --git a/src/interactions/buttons/ticket.js b/src/interactions/buttons/ticket/ticket.js similarity index 89% rename from src/interactions/buttons/ticket.js rename to src/interactions/buttons/ticket/ticket.js index bcc2521f71..ce1f57b6b4 100644 --- a/src/interactions/buttons/ticket.js +++ b/src/interactions/buttons/ticket/ticket.js @@ -6,7 +6,7 @@ import createTicketHandler, { unclaimTicketHandler, reopenTicketHandler, deleteTicketHandler, -} from '../../handlers/ticketButtons.js'; +} from '../../../handlers/ticketButtons.js'; export default [ createTicketHandler, diff --git a/src/interactions/buttons/ticketFeedback.js b/src/interactions/buttons/ticket/ticketFeedback.js similarity index 95% rename from src/interactions/buttons/ticketFeedback.js rename to src/interactions/buttons/ticket/ticketFeedback.js index 6759ac4b9d..38579f2817 100644 --- a/src/interactions/buttons/ticketFeedback.js +++ b/src/interactions/buttons/ticket/ticketFeedback.js @@ -1,9 +1,9 @@ import { EmbedBuilder, ModalBuilder, ActionRowBuilder, TextInputBuilder, TextInputStyle } from 'discord.js'; -import { getTicketData, saveTicketData } from '../../utils/database.js'; -import { logger } from '../../utils/logger.js'; -import { getColor } from '../../config/bot.js'; -import { logTicketFeedback } from '../../utils/ticketLogging.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { getTicketData, saveTicketData } from '../../../utils/database.js'; +import { logger } from '../../../utils/logger.js'; +import { getColor } from '../../../config/bot.js'; +import { logTicketFeedback } from '../../../utils/ticket/ticketLogging.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; const STAR_LABELS = { '1': '⭐ 1 — Poor', diff --git a/src/interactions/buttons/todoShared.js b/src/interactions/buttons/todo/todoShared.js similarity index 80% rename from src/interactions/buttons/todoShared.js rename to src/interactions/buttons/todo/todoShared.js index 02b5fb2009..b683f6bf4b 100644 --- a/src/interactions/buttons/todoShared.js +++ b/src/interactions/buttons/todo/todoShared.js @@ -1,7 +1,7 @@ import todoAddHandler, { sharedTodoCompleteHandler, sharedTodoRemoveHandler, -} from '../../handlers/todoButtons.js'; +} from '../../../handlers/todoButtons.js'; export default [ todoAddHandler, diff --git a/src/interactions/buttons/verification.js b/src/interactions/buttons/verification/verification.js similarity index 57% rename from src/interactions/buttons/verification.js rename to src/interactions/buttons/verification/verification.js index f0bc0887e3..f6b115c91e 100644 --- a/src/interactions/buttons/verification.js +++ b/src/interactions/buttons/verification/verification.js @@ -1,4 +1,4 @@ -import verificationButtonHandler from '../../handlers/verificationButtons.js'; +import verificationButtonHandler from '../../../handlers/verificationButtons.js'; export default { name: verificationButtonHandler.customId, diff --git a/src/interactions/buttons/warnings.js b/src/interactions/buttons/warnings/warnings.js similarity index 75% rename from src/interactions/buttons/warnings.js rename to src/interactions/buttons/warnings/warnings.js index f1a4acbb6c..a7b40f49a0 100644 --- a/src/interactions/buttons/warnings.js +++ b/src/interactions/buttons/warnings/warnings.js @@ -1,7 +1,7 @@ import { warningDeleteSpecificHandler, warningClearAllHandler -} from '../../handlers/warningHandlers.js'; +} from '../../../handlers/warningHandlers.js'; export default [ warningDeleteSpecificHandler, diff --git a/src/interactions/buttons/wipedata.js b/src/interactions/buttons/wipedata/wipedata.js similarity index 72% rename from src/interactions/buttons/wipedata.js rename to src/interactions/buttons/wipedata/wipedata.js index 38d910b145..d54701c7de 100644 --- a/src/interactions/buttons/wipedata.js +++ b/src/interactions/buttons/wipedata/wipedata.js @@ -1,6 +1,6 @@ import { wipedataConfirmHandler, wipedataCancelHandler, -} from '../../handlers/wipedataButtons.js'; +} from '../../../handlers/wipedataButtons.js'; export default [wipedataConfirmHandler, wipedataCancelHandler]; \ No newline at end of file diff --git a/src/interactions/modals/calculate.js b/src/interactions/modals/calculate/calculate.js similarity index 70% rename from src/interactions/modals/calculate.js rename to src/interactions/modals/calculate/calculate.js index be3ab8fb20..66193613b3 100644 --- a/src/interactions/modals/calculate.js +++ b/src/interactions/modals/calculate/calculate.js @@ -1,4 +1,4 @@ -import calculateModalHandler from '../../handlers/calculateModals.js'; +import calculateModalHandler from '../../../handlers/calculateModals.js'; const execute = typeof calculateModalHandler === 'function' ? calculateModalHandler diff --git a/src/interactions/modals/config_modal.js b/src/interactions/modals/config/config_modal.js similarity index 92% rename from src/interactions/modals/config_modal.js rename to src/interactions/modals/config/config_modal.js index 5535fe1a23..04043dbf59 100644 --- a/src/interactions/modals/config_modal.js +++ b/src/interactions/modals/config/config_modal.js @@ -1,9 +1,9 @@ -import { successEmbed } from '../../utils/embeds.js'; +import { successEmbed } from '../../../utils/embeds.js'; -import ConfigService from '../../services/configService.js'; +import ConfigService from '../../../services/config/configService.js'; import { MessageFlags } from 'discord.js'; -import { logger } from '../../utils/logger.js'; -import { ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; +import { logger } from '../../../utils/logger.js'; +import { ErrorTypes, replyUserError } from '../../../utils/errorHandler.js'; function extractId(value) { if (!value || typeof value !== 'string') return null; diff --git a/src/interactions/modals/ticket.js b/src/interactions/modals/ticket/ticket.js similarity index 74% rename from src/interactions/modals/ticket.js rename to src/interactions/modals/ticket/ticket.js index 83eb50a8e5..d79511e3e2 100644 --- a/src/interactions/modals/ticket.js +++ b/src/interactions/modals/ticket/ticket.js @@ -1,6 +1,6 @@ import { createTicketModalHandler, closeTicketModalHandler, -} from '../../handlers/ticketButtons.js'; +} from '../../../handlers/ticketButtons.js'; export default [createTicketModalHandler, closeTicketModalHandler]; \ No newline at end of file diff --git a/src/interactions/modals/ticketFeedbackComment.js b/src/interactions/modals/ticket/ticketFeedbackComment.js similarity index 92% rename from src/interactions/modals/ticketFeedbackComment.js rename to src/interactions/modals/ticket/ticketFeedbackComment.js index 40d27fd369..42123eadf2 100644 --- a/src/interactions/modals/ticketFeedbackComment.js +++ b/src/interactions/modals/ticket/ticketFeedbackComment.js @@ -1,9 +1,9 @@ import { EmbedBuilder, MessageFlags } from 'discord.js'; -import { getTicketData, saveTicketData } from '../../utils/database.js'; -import { logger } from '../../utils/logger.js'; -import { getColor } from '../../config/bot.js'; -import { logTicketFeedback } from '../../utils/ticketLogging.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { getTicketData, saveTicketData } from '../../../utils/database.js'; +import { logger } from '../../../utils/logger.js'; +import { getColor } from '../../../config/bot.js'; +import { logTicketFeedback } from '../../../utils/ticket/ticketLogging.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; function buildEmbed(title, description, color) { return new EmbedBuilder() diff --git a/src/interactions/modals/todoShared.js b/src/interactions/modals/todo/todoShared.js similarity index 83% rename from src/interactions/modals/todoShared.js rename to src/interactions/modals/todo/todoShared.js index 8b15ac337c..9624f3bad2 100644 --- a/src/interactions/modals/todoShared.js +++ b/src/interactions/modals/todo/todoShared.js @@ -2,7 +2,7 @@ import { sharedTodoAddModalHandler, sharedTodoCompleteModalHandler, sharedTodoRemoveModalHandler, -} from '../../handlers/todoButtons.js'; +} from '../../../handlers/todoButtons.js'; export default [ sharedTodoAddModalHandler, diff --git a/src/interactions/modals/warningDelete.js b/src/interactions/modals/warnings/warningDelete.js similarity index 91% rename from src/interactions/modals/warningDelete.js rename to src/interactions/modals/warnings/warningDelete.js index 55897200ff..24b2c6b0cc 100644 --- a/src/interactions/modals/warningDelete.js +++ b/src/interactions/modals/warnings/warningDelete.js @@ -1,4 +1,4 @@ -import { warningDeleteModalHandler, warningClearConfirmModalHandler } from '../../handlers/warningHandlers.js'; +import { warningDeleteModalHandler, warningClearConfirmModalHandler } from '../../../handlers/warningHandlers.js'; const deleteExecute = typeof warningDeleteModalHandler === 'function' ? warningDeleteModalHandler diff --git a/src/interactions/selectMenus/help/helpCategory.js b/src/interactions/selectMenus/help/helpCategory.js new file mode 100644 index 0000000000..f70697a080 --- /dev/null +++ b/src/interactions/selectMenus/help/helpCategory.js @@ -0,0 +1,3 @@ +import { helpCategorySelectMenu } from '../../../handlers/help/helpSelectMenus.js'; + +export default helpCategorySelectMenu; \ No newline at end of file diff --git a/src/interactions/selectMenus/helpCategory.js b/src/interactions/selectMenus/helpCategory.js deleted file mode 100644 index ce224ea566..0000000000 --- a/src/interactions/selectMenus/helpCategory.js +++ /dev/null @@ -1,3 +0,0 @@ -import { helpCategorySelectMenu } from '../../handlers/helpSelectMenus.js'; - -export default helpCategorySelectMenu; \ No newline at end of file diff --git a/src/interactions/selectMenus/logging.js b/src/interactions/selectMenus/logging/logging.js similarity index 50% rename from src/interactions/selectMenus/logging.js rename to src/interactions/selectMenus/logging/logging.js index e8396d0616..8b151db65b 100644 --- a/src/interactions/selectMenus/logging.js +++ b/src/interactions/selectMenus/logging/logging.js @@ -1,4 +1,4 @@ -import { handleLoggingMenuSelect } from '../../handlers/loggingButtons.js'; +import { handleLoggingMenuSelect } from '../../../handlers/loggingButtons.js'; export default { name: 'log_dash_menu', diff --git a/src/interactions/selectMenus/reaction_roles.js b/src/interactions/selectMenus/reactionRoles/reaction_roles.js similarity index 61% rename from src/interactions/selectMenus/reaction_roles.js rename to src/interactions/selectMenus/reactionRoles/reaction_roles.js index 8080759d84..25e9e1a803 100644 --- a/src/interactions/selectMenus/reaction_roles.js +++ b/src/interactions/selectMenus/reactionRoles/reaction_roles.js @@ -1,4 +1,4 @@ -import { handleReactionRolesSelectMenu } from '../../handlers/interactionHandlers/reactionRolesSelectMenu.js'; +import { handleReactionRolesSelectMenu } from '../../../handlers/interactionHandlers/reactionRolesSelectMenu.js'; export async function execute(interaction, client) { return handleReactionRolesSelectMenu(interaction, client); diff --git a/src/interactions/selectMenus/ticketFeedback.js b/src/interactions/selectMenus/ticket/ticketFeedback.js similarity index 94% rename from src/interactions/selectMenus/ticketFeedback.js rename to src/interactions/selectMenus/ticket/ticketFeedback.js index 5252cae334..c801f31a3f 100644 --- a/src/interactions/selectMenus/ticketFeedback.js +++ b/src/interactions/selectMenus/ticket/ticketFeedback.js @@ -1,8 +1,8 @@ import { EmbedBuilder } from 'discord.js'; -import { getTicketData, saveTicketData } from '../../utils/database.js'; -import { logger } from '../../utils/logger.js'; -import { getColor } from '../../config/bot.js'; -import { logTicketFeedback } from '../../utils/ticketLogging.js'; +import { getTicketData, saveTicketData } from '../../../utils/database.js'; +import { logger } from '../../../utils/logger.js'; +import { getColor } from '../../../config/bot.js'; +import { logTicketFeedback } from '../../../utils/ticket/ticketLogging.js'; const STAR_LABELS = { '1': '⭐ 1 — Poor', diff --git a/src/services/applicationService.js b/src/services/applicationService.js index 5dbd3ea66a..e322ad4c1a 100644 --- a/src/services/applicationService.js +++ b/src/services/applicationService.js @@ -15,9 +15,10 @@ import { getApplicationRoles, saveApplicationRoles } from '../utils/database.js'; +import botConfig from '../config/bot.js'; const applicationCooldowns = new Map(); -const APPLICATION_SUBMIT_COOLDOWN = 5 * 60 * 1000; +const APPLICATION_SUBMIT_COOLDOWN = (botConfig.applications?.applicationCooldown ?? 24) * 60 * 60 * 1000; class ApplicationService { static sanitizeApplicationText(value, maxLength) { diff --git a/src/services/birthdayService.js b/src/services/birthdayService.js index 7c8305d8ad..75e237ce2e 100644 --- a/src/services/birthdayService.js +++ b/src/services/birthdayService.js @@ -1,7 +1,7 @@ // birthdayService.js import { getGuildConfig } from './guildConfig.js'; -import { getGuildBirthdays, setBirthday as dbSetBirthday, deleteBirthday as dbDeleteBirthday, getMonthName } from '../utils/database.js'; +import { getGuildBirthdays, setBirthday as dbSetBirthday, deleteBirthday as dbDeleteBirthday, getMonthName, getBirthdayTrackingKey } from '../utils/database.js'; import { logger } from '../utils/logger.js'; import { TitanBotError, ErrorTypes } from '../utils/errorHandler.js'; @@ -82,7 +82,6 @@ export async function setBirthday(client, guildId, userId, month, day) { }); return { - success: true, data: { month, day, @@ -164,9 +163,7 @@ export async function deleteBirthday(client, guildId, userId) { if (!birthday) { return { - success: false, - notFound: true, - message: 'No birthday found to remove' + status: 'not_found', }; } @@ -187,8 +184,7 @@ export async function deleteBirthday(client, guildId, userId) { }); return { - success: true, - message: 'Birthday removed successfully' + status: 'removed', }; } catch (error) { logger.error('Error in deleteBirthday service', { @@ -289,9 +285,10 @@ export async function checkBirthdays(client) { const config = await getGuildConfig(client, guildId); const { birthdayChannelId, birthdayRoleId } = config; - if (!birthdayChannelId || !birthdayRoleId) { + // A channel is required for announcements; the birthday role is optional. + if (!birthdayChannelId) { if (process.env.NODE_ENV !== 'production') { - logger.debug(`Skipping birthday check for ${guild.name}: Missing channel or role config.`); + logger.debug(`Skipping birthday check for ${guild.name}: Missing channel config.`); } continue; } @@ -299,15 +296,17 @@ export async function checkBirthdays(client) { const channel = await guild.channels.fetch(birthdayChannelId).catch(() => null); if (!channel) continue; - const trackingKey = `bday-role-tracking-${guildId}`; + const trackingKey = getBirthdayTrackingKey(guildId); const trackingData = (await client.db.get(trackingKey)) || {}; const updatedTrackingData = { ...trackingData }; for (const userId of Object.keys(trackingData)) { try { - const member = await guild.members.fetch(userId).catch(() => null); - if (member && member.roles.cache.has(birthdayRoleId)) { - await member.roles.remove(birthdayRoleId, "Birthday role expired"); + if (birthdayRoleId) { + const member = await guild.members.fetch(userId).catch(() => null); + if (member && member.roles.cache.has(birthdayRoleId)) { + await member.roles.remove(birthdayRoleId, "Birthday role expired"); + } } delete updatedTrackingData[userId]; } catch (error) { @@ -319,19 +318,21 @@ export async function checkBirthdays(client) { await client.db.set(trackingKey, updatedTrackingData); } - const birthdaysKey = `birthdays:${guildId}`; - const birthdays = (await client.db.get(birthdaysKey)) || {}; + // Use the canonical birthday storage (guild::birthdays) that set/remove commands write to. + const birthdays = (await getGuildBirthdays(client, guildId)) || {}; const birthdayMembers = []; for (const [userId, userData] of Object.entries(birthdays)) { if (userData.month === currentMonth && userData.day === currentDay) { const member = await guild.members.fetch(userId).catch(() => null); if (member) { birthdayMembers.push(member); - try { - await member.roles.add(birthdayRoleId, "Happy Birthday! 🎉"); - updatedTrackingData[userId] = true; - } catch (error) { - logger.error(`Error adding birthday role to ${member.user.tag}:`, error); + if (birthdayRoleId) { + try { + await member.roles.add(birthdayRoleId, "Happy Birthday! 🎉"); + updatedTrackingData[userId] = true; + } catch (error) { + logger.error(`Error adding birthday role to ${member.user.tag}:`, error); + } } } } diff --git a/src/services/commandAccessService.js b/src/services/commandAccessService.js index 324195c1db..55854c6eed 100644 --- a/src/services/commandAccessService.js +++ b/src/services/commandAccessService.js @@ -6,7 +6,7 @@ import { formatCategoryName, getCategoryIcon, PROTECTED_COMMANDS, -} from '../config/commandCategories.js'; +} from '../config/commands/commandCategories.js'; function normalizeToggleRecord(raw) { if (!raw) { diff --git a/src/services/configService.js b/src/services/config/configService.js similarity index 98% rename from src/services/configService.js rename to src/services/config/configService.js index c7984e7678..465cd1b2e4 100644 --- a/src/services/configService.js +++ b/src/services/config/configService.js @@ -1,12 +1,12 @@ // configService.js -import { logger } from '../utils/logger.js'; +import { logger } from '../../utils/logger.js'; import { getGuildConfig, setGuildConfig } from './guildConfig.js'; import { PermissionFlagsBits } from 'discord.js'; -import { createError, ErrorTypes } from '../utils/errorHandler.js'; -import { wrapServiceClassMethods } from '../utils/serviceErrorBoundary.js'; +import { createError, ErrorTypes } from '../../utils/errorHandler.js'; +import { wrapServiceClassMethods } from '../../utils/serviceErrorBoundary.js'; import { z } from 'zod'; -import { LogIgnoreSchema, LoggingConfigSchema } from '../utils/schemas.js'; +import { LogIgnoreSchema, LoggingConfigSchema } from '../../utils/schemas.js'; const configChangeHistory = new Map(); const CONFIG_HISTORY_LIMIT = 100; @@ -407,7 +407,6 @@ class ConfigService { }); return { - success: true, key, oldValue, newValue: value, @@ -489,7 +488,6 @@ class ConfigService { }); return { - success: true, applied: Object.keys(validatedUpdates), failed: validationErrors, appliedCount: Object.keys(validatedUpdates).length, @@ -554,7 +552,6 @@ class ConfigService { }); return { - success: true, key, oldValue, newValue: defaultValue diff --git a/src/services/config/guildConfig.js b/src/services/config/guildConfig.js new file mode 100644 index 0000000000..e48348d397 --- /dev/null +++ b/src/services/config/guildConfig.js @@ -0,0 +1,105 @@ +// guildConfig.js — the only module that should read/write guild configuration. + +import { GUILD_CONFIG_DEFAULTS } from '../../config/guild/guildConfigDefaults.js'; +import { readGuildConfig, writeGuildConfig } from '../../utils/database/guildConfigStorage.js'; +import { normalizeGuildConfig, validateGuildConfigOrThrow } from '../../utils/schemas.js'; +import { createError, ErrorTypes, wrapServiceBoundary } from '../../utils/errorHandler.js'; + +export { GUILD_CONFIG_DEFAULTS }; + +export const getGuildConfig = wrapServiceBoundary(async function getGuildConfig(client, guildId, context = {}) { + const config = await readGuildConfig(client, guildId, context); + return normalizeGuildConfig(config, GUILD_CONFIG_DEFAULTS); +}, { + service: 'guildConfigService', + operation: 'getGuildConfig', + message: 'Failed to fetch guild configuration', + userMessage: 'Failed to load server configuration. Please try again.', +}); + +export const setGuildConfig = wrapServiceBoundary(async function setGuildConfig(client, guildId, config, context = {}) { + const normalized = normalizeGuildConfig(config, GUILD_CONFIG_DEFAULTS); + return await writeGuildConfig(client, guildId, normalized, context); +}, { + service: 'guildConfigService', + operation: 'setGuildConfig', + message: 'Failed to save guild configuration', + userMessage: 'Failed to save server configuration. Please try again.', +}); + +export const updateGuildConfig = wrapServiceBoundary(async function updateGuildConfig(client, guildId, updates, context = {}) { + const currentConfig = await readGuildConfig(client, guildId, context); + const merged = { ...currentConfig, ...updates }; + const normalized = normalizeGuildConfig(merged, GUILD_CONFIG_DEFAULTS); + return await writeGuildConfig(client, guildId, normalized, context); +}, { + service: 'guildConfigService', + operation: 'updateGuildConfig', + message: 'Failed to update guild configuration', + userMessage: 'Failed to update server configuration. Please try again.', +}); + +export const getConfigValue = wrapServiceBoundary(async function getConfigValue(client, guildId, key, defaultValue = null, context = {}) { + const config = await getGuildConfig(client, guildId, context); + return config[key] !== undefined ? config[key] : defaultValue; +}, { + service: 'guildConfigService', + operation: 'getConfigValue', + message: 'Failed to read guild configuration value', + userMessage: 'Failed to read a server setting. Please try again.', +}); + +export const setConfigValue = wrapServiceBoundary(async function setConfigValue(client, guildId, key, value, context = {}) { + return await updateGuildConfig(client, guildId, { [key]: value }, context); +}, { + service: 'guildConfigService', + operation: 'setConfigValue', + message: 'Failed to update guild configuration value', + userMessage: 'Failed to update a server setting. Please try again.', +}); + +/** + * Merge partial updates into a nested config object (e.g. verification, logging). + */ +export const patchGuildConfig = wrapServiceBoundary(async function patchGuildConfig(client, guildId, patch, context = {}) { + if (!patch || typeof patch !== 'object') { + throw createError( + 'Invalid guild config patch', + ErrorTypes.VALIDATION, + 'Invalid configuration update.', + { guildId, ...context }, + ); + } + + const currentConfig = await readGuildConfig(client, guildId, context); + const merged = deepMergeGuildConfig(currentConfig, patch); + const normalized = normalizeGuildConfig(merged, GUILD_CONFIG_DEFAULTS); + validateGuildConfigOrThrow(normalized, { guildId, ...context }); + return await writeGuildConfig(client, guildId, normalized, context); +}, { + service: 'guildConfigService', + operation: 'patchGuildConfig', + message: 'Failed to patch guild configuration', + userMessage: 'Failed to update server configuration. Please try again.', +}); + +function deepMergeGuildConfig(base, patch) { + const result = { ...base }; + + for (const [key, value] of Object.entries(patch)) { + if ( + value && + typeof value === 'object' && + !Array.isArray(value) && + base[key] && + typeof base[key] === 'object' && + !Array.isArray(base[key]) + ) { + result[key] = { ...base[key], ...value }; + } else { + result[key] = value; + } + } + + return result; +} diff --git a/src/services/economyService.js b/src/services/economyService.js index a4b4bdf126..9203754280 100644 --- a/src/services/economyService.js +++ b/src/services/economyService.js @@ -204,7 +204,6 @@ class EconomyService { }); return { - success: true, senderNewBalance: senderData.wallet, receiverNewBalance: receiverData.wallet }; diff --git a/src/services/giveawayService.js b/src/services/giveawayService.js index 915c57b235..7f07fff939 100644 --- a/src/services/giveawayService.js +++ b/src/services/giveawayService.js @@ -3,11 +3,12 @@ import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js'; import { logger } from '../utils/logger.js'; import { TitanBotError, ErrorTypes } from '../utils/errorHandler.js'; -import { getColor } from '../config/bot.js'; +import { getColor, botConfig } from '../config/bot.js'; import { getEndedGiveaways, markGiveawayEnded } from '../utils/database.js'; import { checkRateLimit, getRateLimitStatus } from '../utils/rateLimiter.js'; import { logEvent, EVENT_TYPES } from './loggingService.js'; +const GIVEAWAY_CONFIG = botConfig.giveaways || {}; const GIVEAWAY_INTERACTION_COOLDOWN = 1000; function getGiveawayInteractionKey(userId, giveawayId) { @@ -71,22 +72,22 @@ export function parseDuration(durationString) { ); } - const maxDuration = 30 * 24 * 60 * 60 * 1000; + const maxDuration = GIVEAWAY_CONFIG.maximumDuration ?? 30 * 24 * 60 * 60 * 1000; if (ms > maxDuration) { throw new TitanBotError( `Duration exceeds maximum: ${ms}ms > ${maxDuration}ms`, ErrorTypes.VALIDATION, - 'Maximum duration is 30 days.', + `Maximum duration is ${Math.floor(maxDuration / (24 * 60 * 60 * 1000))} days.`, { requestedMs: ms, maxMs: maxDuration } ); } - const minDuration = 10 * 1000; + const minDuration = GIVEAWAY_CONFIG.minimumDuration ?? 10 * 1000; if (ms < minDuration) { throw new TitanBotError( `Duration below minimum: ${ms}ms < ${minDuration}ms`, ErrorTypes.VALIDATION, - 'Minimum duration is 10 seconds.', + `Minimum duration is ${Math.ceil(minDuration / 1000)} seconds.`, { requestedMs: ms, minMs: minDuration } ); } @@ -118,12 +119,15 @@ export function validatePrize(prize) { } export function validateWinnerCount(winnerCount) { - if (!Number.isInteger(winnerCount) || winnerCount < 1 || winnerCount > 10) { + const minimumWinners = GIVEAWAY_CONFIG.minimumWinners ?? 1; + const maximumWinners = GIVEAWAY_CONFIG.maximumWinners ?? 10; + + if (!Number.isInteger(winnerCount) || winnerCount < minimumWinners || winnerCount > maximumWinners) { throw new TitanBotError( `Invalid winner count: ${winnerCount}`, ErrorTypes.VALIDATION, - 'Winner count must be between 1 and 10.', - { winnerCount } + `Winner count must be between ${minimumWinners} and ${maximumWinners}.`, + { winnerCount, minimumWinners, maximumWinners } ); } } @@ -301,7 +305,6 @@ export async function endGiveaway(client, giveaway, guildId, endedBy) { logger.info(`Ending giveaway ${giveaway.messageId}: selected ${winners.length} winners from ${participants.length} entries`); return { - success: true, giveaway: updatedGiveaway, winners: winners, participantCount: participants.length diff --git a/src/services/guildConfig.js b/src/services/guildConfig.js deleted file mode 100644 index ef991df319..0000000000 --- a/src/services/guildConfig.js +++ /dev/null @@ -1,78 +0,0 @@ -// guildConfig.js - -import { getGuildConfig as getGuildConfigDb, setGuildConfig as setGuildConfigDb } from '../utils/database.js'; -import { BotConfig } from '../config/bot.js'; -import { normalizeGuildConfig, validateGuildConfigOrThrow } from '../utils/schemas.js'; -import { wrapServiceBoundary } from '../utils/serviceErrorBoundary.js'; - -const GUILD_CONFIG_DEFAULTS = { - prefix: BotConfig.prefix, - modRole: null, - adminRole: null, - welcomeChannel: null, - welcomeMessage: 'Welcome {user} to {server}!', - autoRole: null, - dmOnClose: true, - disabledCommands: {}, - disabledCategories: {}, - logging: { - enabled: false, - channels: { audit: null, applications: null, reports: null }, - ignore: { users: [], channels: [] }, - enabledEvents: {}, - }, -}; - -export const getGuildConfig = wrapServiceBoundary(async function getGuildConfig(client, guildId, context = {}) { - const config = await getGuildConfigDb(client, guildId, context); - - return normalizeGuildConfig(config, GUILD_CONFIG_DEFAULTS); -}, { - service: 'guildConfigService', - operation: 'getGuildConfig', - message: 'Failed to fetch guild configuration', - userMessage: 'Failed to load server configuration. Please try again.' -}); - -export const setGuildConfig = wrapServiceBoundary(async function setGuildConfig(client, guildId, config, context = {}) { - const normalized = normalizeGuildConfig(config, GUILD_CONFIG_DEFAULTS); - const validated = validateGuildConfigOrThrow(normalized, { guildId, ...context }); - return await setGuildConfigDb(client, guildId, validated, context); -}, { - service: 'guildConfigService', - operation: 'setGuildConfig', - message: 'Failed to save guild configuration', - userMessage: 'Failed to save server configuration. Please try again.' -}); - -export const updateGuildConfig = wrapServiceBoundary(async function updateGuildConfig(client, guildId, updates, context = {}) { - const currentConfig = await getGuildConfigDb(client, guildId, context); - const newConfig = { ...currentConfig, ...updates }; - const normalized = normalizeGuildConfig(newConfig, GUILD_CONFIG_DEFAULTS); - const validated = validateGuildConfigOrThrow(normalized, { guildId, ...context }); - return await setGuildConfigDb(client, guildId, validated, context); -}, { - service: 'guildConfigService', - operation: 'updateGuildConfig', - message: 'Failed to update guild configuration', - userMessage: 'Failed to update server configuration. Please try again.' -}); - -export const getConfigValue = wrapServiceBoundary(async function getConfigValue(client, guildId, key, defaultValue = null, context = {}) { - const config = await getGuildConfig(client, guildId, context); - return config[key] !== undefined ? config[key] : defaultValue; -}, { - service: 'guildConfigService', - operation: 'getConfigValue', - message: 'Failed to read guild configuration value', - userMessage: 'Failed to read a server setting. Please try again.' -}); - -export const setConfigValue = wrapServiceBoundary(async function setConfigValue(client, guildId, key, value, context = {}) { - return await updateGuildConfig(client, guildId, { [key]: value }, context); -}, { - service: 'guildConfigService', - operation: 'setConfigValue', - message: 'Failed to update guild configuration value', - userMessage: 'Failed to update a server setting. Please try again.' -}); \ No newline at end of file diff --git a/src/services/joinToCreateService.js b/src/services/joinToCreateService.js index 601cb40fb9..a39318716f 100644 --- a/src/services/joinToCreateService.js +++ b/src/services/joinToCreateService.js @@ -10,7 +10,7 @@ import { import { logger } from '../utils/logger.js'; import { TitanBotError, ErrorTypes } from '../utils/errorHandler.js'; import { logEvent, EVENT_TYPES } from './loggingService.js'; -import { formatLogLine } from '../utils/logEmbeds.js'; +import { formatLogLine } from '../utils/logging/logEmbeds.js'; import { ChannelType, PermissionFlagsBits } from 'discord.js'; const CHANNEL_NAME_MAX_LENGTH = 100; diff --git a/src/services/levelRoleSyncService.js b/src/services/leveling/levelRoleSyncService.js similarity index 95% rename from src/services/levelRoleSyncService.js rename to src/services/leveling/levelRoleSyncService.js index 18f6e76230..8a27905fb6 100644 --- a/src/services/levelRoleSyncService.js +++ b/src/services/leveling/levelRoleSyncService.js @@ -1,10 +1,12 @@ -import { logger } from '../utils/logger.js'; +import { logger } from '../../utils/logger.js'; import { getLevelingConfig, getUserLevelData, saveLevelingConfig } from './leveling.js'; +import { getUserLevelPrefix } from '../../utils/database/keys.js'; + async function listLevelUserIds(client, guildId) { if (!client.db?.list) return []; - const prefixes = [`${guildId}:leveling:users:`, `guild:${guildId}:leveling:users:`]; + const prefixes = [getUserLevelPrefix(guildId), `${guildId}:leveling:users:`]; const userIds = new Set(); for (const prefix of prefixes) { diff --git a/src/services/leveling.js b/src/services/leveling/leveling.js similarity index 96% rename from src/services/leveling.js rename to src/services/leveling/leveling.js index 13bd26f73e..f301b4e19e 100644 --- a/src/services/leveling.js +++ b/src/services/leveling/leveling.js @@ -1,10 +1,11 @@ // leveling.js import { EmbedBuilder } from 'discord.js'; -import { logger } from '../utils/logger.js'; -import { getGuildConfig, setGuildConfig } from '../services/guildConfig.js'; -import { TitanBotError, ErrorTypes } from '../utils/errorHandler.js'; +import { logger } from '../../utils/logger.js'; +import { getGuildConfig, setGuildConfig } from '../config/guildConfig.js'; +import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { addXp } from './xpSystem.js'; +import { getUserLevelKey } from '../../utils/database/keys.js'; const BASE_XP = 100; const XP_MULTIPLIER = 1.5; @@ -189,7 +190,7 @@ export async function getUserLevelData(client, guildId, userId) { ); } - const key = `${guildId}:leveling:users:${userId}`; + const key = getUserLevelKey(guildId, userId); const data = await client.db.get(key); if (!data) { @@ -244,7 +245,7 @@ export async function saveUserLevelData(client, guildId, userId, data) { rank: Number(data.rank) || 0 }; - const key = `${guildId}:leveling:users:${userId}`; + const key = getUserLevelKey(guildId, userId); await client.db.set(key, sanitizedData); } catch (error) { logger.error(`Error saving user level data for ${userId}:`, error); @@ -447,7 +448,7 @@ export async function deleteUserLevelData(client, guildId, userId) { ); } - const key = `${guildId}:leveling:users:${userId}`; + const key = getUserLevelKey(guildId, userId); await client.db.delete(key); logger.debug(`Deleted level data for user ${userId} in guild ${guildId}`); diff --git a/src/services/leveling/xpSystem.js b/src/services/leveling/xpSystem.js new file mode 100644 index 0000000000..47d6eec293 --- /dev/null +++ b/src/services/leveling/xpSystem.js @@ -0,0 +1,140 @@ +// xpSystem.js + +import { logger } from '../../utils/logger.js'; +import { getLevelingConfig, getXpForLevel, getUserLevelData, saveUserLevelData } from './leveling.js'; +import { logEvent, EVENT_TYPES } from '../loggingService.js'; +import { formatLogLine } from '../../utils/logging/logEmbeds.js'; +import { Mutex } from '../../utils/mutex.js'; +import { wrapServiceBoundary } from '../../utils/errorHandler.js'; + +/** + * Award XP to a member. Returns null when XP is skipped (disabled/invalid amount). + * Throws on storage or unexpected failures. + */ +export const addXp = wrapServiceBoundary(async function addXp(client, guild, member, xpToAdd) { + const lockKey = `leveling:${guild.id}:${member.user.id}`; + return await Mutex.runExclusive(lockKey, async () => { + if (!xpToAdd || xpToAdd <= 0) { + return null; + } + + const config = await getLevelingConfig(client, guild.id); + + if (!config.enabled) { + return null; + } + + const levelData = await getUserLevelData(client, guild.id, member.user.id); + + levelData.xp += xpToAdd; + levelData.totalXp += xpToAdd; + levelData.lastMessage = Date.now(); + + let xpNeededForNextLevel = getXpForLevel(levelData.level); + let didLevelUp = false; + const initialLevel = levelData.level; + + while (levelData.xp >= xpNeededForNextLevel && levelData.level < 1000) { + levelData.xp -= xpNeededForNextLevel; + levelData.level += 1; + didLevelUp = true; + xpNeededForNextLevel = getXpForLevel(levelData.level); + + logger.info(`🎉 ${member.user.tag} leveled up to level ${levelData.level} in ${guild.name}`); + + if (config.roleRewards && config.roleRewards[levelData.level]) { + await awardRoleReward(guild, member, config.roleRewards[levelData.level], levelData.level); + } + } + + if (didLevelUp) { + if (config.announceLevelUp) { + await sendLevelUpAnnouncement(guild, member, levelData, config); + } + + try { + await logEvent({ + client, + guildId: guild.id, + eventType: EVENT_TYPES.LEVELING_LEVELUP, + data: { + title: 'Level Up', + lines: [ + formatLogLine('Member', `${member.user.tag} (\`${member.user.id}\`)`), + formatLogLine('New Level', levelData.level.toString()), + formatLogLine('Levels Gained', (levelData.level - initialLevel).toString()), + formatLogLine('Total XP', levelData.totalXp.toString()), + ], + userId: member.user.id, + }, + }); + } catch (logError) { + logger.debug('Failed to log leveling event:', logError.message); + } + } + + await saveUserLevelData(client, guild.id, member.user.id, levelData); + + return { + level: levelData.level, + xp: levelData.xp, + totalXp: levelData.totalXp, + xpNeeded: getXpForLevel(levelData.level + 1), + leveledUp: didLevelUp, + }; + }); +}, { + service: 'xpSystem', + operation: 'addXp', + userMessage: 'Failed to award XP. Please try again.', +}); + +async function awardRoleReward(guild, member, roleId, level) { + try { + const role = guild.roles.cache.get(roleId); + + if (!role) { + logger.warn(`Role ${roleId} not found for level ${level} reward in guild ${guild.id}`); + return; + } + + if (member.roles.cache.has(roleId)) { + return; + } + + await member.roles.add(role, `Level ${level} reward`); + logger.info(`✅ Awarded role ${role.name} to ${member.user.tag} for reaching level ${level}`); + } catch (error) { + logger.error(`Failed to award role reward to ${member.user.id}:`, error); + } +} + +async function sendLevelUpAnnouncement(guild, member, levelData, config) { + try { + const levelUpChannel = config.levelUpChannel + ? guild.channels.cache.get(config.levelUpChannel) + : guild.systemChannel; + + if (!levelUpChannel || !levelUpChannel.isTextBased()) { + return; + } + + const permissions = levelUpChannel.permissionsFor(guild.members.me); + if (!permissions || !permissions.has(['SendMessages', 'EmbedLinks'])) { + logger.warn(`Missing permissions to send levelup message in ${levelUpChannel.id}`); + return; + } + + const message = config.levelUpMessage + .replace(/{user}/g, member.toString()) + .replace(/{level}/g, levelData.level) + .replace(/{xp}/g, levelData.xp) + .replace(/{xpNeeded}/g, getXpForLevel(levelData.level + 1)); + + await levelUpChannel.send(message).catch(error => { + logger.error(`Failed to send level up message in channel ${levelUpChannel.id}:`, error); + }); + } catch (error) { + logger.error('Error sending level up announcement:', error); + } +} diff --git a/src/services/loggingService.js b/src/services/loggingService.js index 29c2a69a25..0d2f42f22f 100644 --- a/src/services/loggingService.js +++ b/src/services/loggingService.js @@ -9,7 +9,7 @@ import { buildStandardLogEmbed, fieldsToLines, splitComparisonFields, -} from '../utils/logEmbeds.js'; +} from '../utils/logging/logEmbeds.js'; const LOG_DESTINATIONS = ['audit', 'applications', 'reports']; diff --git a/src/services/moderationService.js b/src/services/moderation/moderationService.js similarity index 97% rename from src/services/moderationService.js rename to src/services/moderation/moderationService.js index 681342f5e7..ee99845f1b 100644 --- a/src/services/moderationService.js +++ b/src/services/moderation/moderationService.js @@ -1,9 +1,9 @@ // moderationService.js import { PermissionFlagsBits } from 'discord.js'; -import { logger } from '../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../utils/errorHandler.js'; -import { logModerationAction } from '../utils/moderation.js'; +import { logger } from '../../utils/logger.js'; +import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { logModerationAction } from '../../utils/moderation.js'; function getTargetLabel(target) { return target.user?.tag ?? target.displayName ?? 'this user'; @@ -196,7 +196,6 @@ export class ModerationService { logger.info(`User banned: ${user.tag} by ${moderator.user.tag} in ${guild.name}`); return { - success: true, caseId, user: user.tag, reason @@ -254,7 +253,6 @@ export class ModerationService { logger.info(`User kicked: ${member.user.tag} by ${moderator.user.tag} in ${guild.name}`); return { - success: true, caseId, user: member.user.tag, reason @@ -316,7 +314,6 @@ export class ModerationService { logger.info(`User timed out: ${member.user.tag} by ${moderator.user.tag} in ${guild.name}`); return { - success: true, caseId, user: member.user.tag, duration: durationMinutes, @@ -383,7 +380,6 @@ export class ModerationService { logger.info(`Timeout removed: ${member.user.tag} by ${moderator.user.tag} in ${guild.name}`); return { - success: true, user: member.user.tag }; } catch (error) { @@ -438,7 +434,6 @@ export class ModerationService { logger.info(`User unbanned: ${user.tag} by ${moderator.user.tag} in ${guild.name}`); return { - success: true, caseId, user: user.tag, reason diff --git a/src/services/moderation/warningService.js b/src/services/moderation/warningService.js new file mode 100644 index 0000000000..b0832ecfe3 --- /dev/null +++ b/src/services/moderation/warningService.js @@ -0,0 +1,124 @@ +// warningService.js + +import { db, getFromDb, setInDb, getWarningsKey, getWarningsPrefix } from '../../utils/database.js'; +import { logger } from '../../utils/logger.js'; +import { createError, ErrorTypes, wrapServiceClassMethods } from '../../utils/errorHandler.js'; + +class WarningService { + + static async addWarning({ + guildId, + userId, + moderatorId, + reason, + timestamp = Date.now() + }) { + const key = getWarningsKey(guildId, userId); + const warnings = await getFromDb(key, []); + + if (!Array.isArray(warnings)) { + logger.warn(`Warnings for ${userId} in ${guildId} corrupted, resetting`); + await setInDb(key, []); + throw createError( + 'Corrupted warning data', + ErrorTypes.DATABASE, + 'Warning data was corrupted and has been reset. Please try again.', + { guildId, userId, service: 'warningService', operation: 'addWarning' } + ); + } + + const warning = { + id: Date.now(), + guildId, + userId, + moderatorId, + reason, + timestamp, + status: 'active' + }; + + warnings.push(warning); + await setInDb(key, warnings); + + logger.info(`Warning added: ${userId} in ${guildId} by ${moderatorId}`); + + return { + id: warning.id, + totalCount: warnings.length + }; + } + + static async getWarnings(guildId, userId) { + const key = getWarningsKey(guildId, userId); + const warnings = await getFromDb(key, []); + + return Array.isArray(warnings) + ? warnings.filter(w => w && w.status !== 'deleted') + : []; + } + + static async getWarningCount(guildId, userId) { + const warnings = await this.getWarnings(guildId, userId); + return warnings.length; + } + + static async removeWarning(guildId, userId, warningId) { + const key = getWarningsKey(guildId, userId); + const warnings = await getFromDb(key, []); + + const index = warnings.findIndex(w => w.id === warningId); + if (index === -1) { + throw createError( + 'Warning not found', + ErrorTypes.USER_INPUT, + 'That warning could not be found. It may have already been removed.', + { guildId, userId, warningId, service: 'warningService', operation: 'removeWarning' } + ); + } + + warnings[index].status = 'deleted'; + await setInDb(key, warnings); + + logger.info(`Warning removed: ${warningId} for ${userId} in ${guildId}`); + return { removed: true }; + } + + static async clearWarnings(guildId, userId) { + const key = getWarningsKey(guildId, userId); + const warnings = await getFromDb(key, []); + const count = warnings.length; + + await setInDb(key, []); + + logger.info(`Warnings cleared for ${userId} in ${guildId} (${count} removed)`); + return { count }; + } + + static async getGuildWarnings(guildId, filters = {}) { + const { moderatorId, limit = 100 } = filters; + const prefix = getWarningsPrefix(guildId); + + const keys = await db.list(prefix); + const allWarnings = []; + + for (const key of Array.isArray(keys) ? keys : []) { + const warnings = await getFromDb(key, []); + if (!Array.isArray(warnings)) continue; + + for (const warning of warnings) { + if (!warning || warning.status === 'deleted') continue; + if (moderatorId && warning.moderatorId !== moderatorId) continue; + allWarnings.push(warning); + } + } + + allWarnings.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)); + + logger.debug(`Fetched guild warnings for ${guildId} with ${allWarnings.length} total`); + return allWarnings.slice(0, limit); + } +} + +wrapServiceClassMethods(WarningService); + +export { WarningService }; diff --git a/src/services/music/musicActions.js b/src/services/music/musicActions.js index 0a522aabb1..206da64956 100644 --- a/src/services/music/musicActions.js +++ b/src/services/music/musicActions.js @@ -149,7 +149,7 @@ export async function playQuery(client, interaction, query) { added += 1; } - if (!player.playing && !player.paused && !player.current) { + if (!player.playing && !player.paused) { player.play(); } @@ -181,16 +181,21 @@ export async function playQuery(client, interaction, query) { } track.info.requester = interaction.user; + + const willPlayNow = !player.playing && !player.paused; player.queue.add(track); + const queuePosition = player.queue.length; - if (!player.playing && !player.paused && !player.current) { + if (willPlayNow) { player.play(); } return { embed: successEmbed( - 'Track Added', - `**${track.info.title}**\n${track.info.author}\nPosition: #${player.queue.length} in queue`, + willPlayNow ? 'Now Playing' : 'Track Added', + willPlayNow + ? `**${track.info.title}**\n${track.info.author}` + : `**${track.info.title}**\n${track.info.author}\nPosition: #${queuePosition} in queue`, ), }; } @@ -205,6 +210,11 @@ export async function skipTrack(client, interaction) { } assertCanControl(interaction.member, player); const title = player.current.info?.title || 'Unknown'; + // Under track-loop, stop() would replay the same track. Clear it so the skip + // advances; trackStart re-applies the stored loop mode to the next track. + if (player.loop === 'track') { + player.setLoop('none'); + } player.stop(); return successEmbed('Skipped', `Skipped **${title}**.`); } @@ -244,7 +254,6 @@ export async function applyPause(client, guildId) { } player.pause(true); - getGuildMusicData(guildId).wasPaused = true; await refreshPlayerMessage(client, guildId); return true; } @@ -256,7 +265,6 @@ export async function applyResume(client, guildId) { } player.pause(false); - getGuildMusicData(guildId).wasPaused = false; await refreshPlayerMessage(client, guildId); return true; } @@ -351,7 +359,24 @@ export async function seekTrack(client, interaction, seconds) { } assertCanControl(interaction.member, player); + const info = player.current.info || {}; + if (info.isStream || info.isSeekable === false) { + throw new TitanBotError( + 'Not seekable', + ErrorTypes.USER_INPUT, + 'This track cannot be seeked (it may be a live stream).', + ); + } + const position = Math.max(0, seconds * 1000); + if (info.length && position > info.length) { + throw new TitanBotError( + 'Seek out of range', + ErrorTypes.USER_INPUT, + `You can only seek up to ${Math.floor(info.length / 1000)}s for this track.`, + ); + } + player.seek(position); await refreshPlayerMessage(client, interaction.guild.id); return successEmbed('Seeked', `Seeked to **${seconds}s**.`); @@ -371,6 +396,7 @@ export async function removeFromQueue(client, interaction, index) { const removed = player.queue[queueIndex]; player.queue.remove(queueIndex); + await refreshPlayerMessage(client, interaction.guild.id); return successEmbed('Removed', `Removed **${removed.info?.title || 'track'}** from the queue.`); } @@ -390,6 +416,7 @@ export async function moveInQueue(client, interaction, from, to) { const track = player.queue[fromIndex]; player.queue.remove(fromIndex); player.queue.splice(toIndex, 0, track); + await refreshPlayerMessage(client, interaction.guild.id); return successEmbed('Moved', `Moved **${track.info?.title || 'track'}** to position #${to}.`); } @@ -452,6 +479,8 @@ export async function destroyPlayerSession(client, guildId, player, guildData, { guildData.previousTracks = []; guildData.stopConfirmPending = null; + guildData.autoPaused = false; + guildData.queuePages?.clear(); if (guildData.playerMessageId && guildData.playerChannelId) { try { diff --git a/src/services/music/musicVoiceState.js b/src/services/music/musicVoiceState.js index 7c98ce3689..f6ba98d518 100644 --- a/src/services/music/musicVoiceState.js +++ b/src/services/music/musicVoiceState.js @@ -27,7 +27,7 @@ export async function handleMusicVoiceState(client, oldState, newState) { const hasUsers = humansInChannel.size > 0; if (!hasUsers && !player.paused && player.playing) { - guildData.wasPaused = true; + guildData.autoPaused = true; await applyPause(client, guildId); if (guildData.playerChannelId) { const channel = client.channels.cache.get(guildData.playerChannelId); @@ -38,8 +38,9 @@ export async function handleMusicVoiceState(client, oldState, newState) { return; } - if (hasUsers && guildData.wasPaused && player.paused) { + if (hasUsers && guildData.autoPaused && player.paused) { await applyResume(client, guildId); + guildData.autoPaused = false; if (guildData.playerChannelId) { const channel = client.channels.cache.get(guildData.playerChannelId); if (channel) { diff --git a/src/services/music/playerHandler.js b/src/services/music/playerHandler.js index a58947a81c..0d11611774 100644 --- a/src/services/music/playerHandler.js +++ b/src/services/music/playerHandler.js @@ -92,6 +92,12 @@ export function setupPlayerHandler(client) { try { const guildData = getGuildMusicData(player.guildId); + // Keep the Lavalink player's loop mode aligned with the stored preference. + // Skip temporarily clears track-loop so it can advance; restore it here. + if (guildData.loop && player.loop !== guildData.loop) { + player.setLoop(guildData.loop); + } + if (player.previous) { guildData.previousTracks.push(player.previous); if (guildData.previousTracks.length > 20) { @@ -178,6 +184,7 @@ export function setupPlayerHandler(client) { guildData.playerMessageId = null; guildData.playerChannelId = null; guildData.previousTracks = []; + guildData.autoPaused = false; if (guildData.idleTimeout) { clearTimeout(guildData.idleTimeout); guildData.idleTimeout = null; diff --git a/src/services/music/playerStore.js b/src/services/music/playerStore.js index 71c9e2671d..009ad3e686 100644 --- a/src/services/music/playerStore.js +++ b/src/services/music/playerStore.js @@ -13,7 +13,7 @@ export class GuildMusicData { this.queuePages = new Map(); this.updateInterval = null; this.idleTimeout = null; - this.wasPaused = false; + this.autoPaused = false; this.stopConfirmPending = null; } } diff --git a/src/services/music/riffySetup.js b/src/services/music/riffySetup.js index 11ebdac95c..4514512e7e 100644 --- a/src/services/music/riffySetup.js +++ b/src/services/music/riffySetup.js @@ -1,7 +1,7 @@ import { createRequire } from 'module'; import { GatewayDispatchEvents } from 'discord.js'; import { logger } from '../../utils/logger.js'; -import lavalinkConfig from '../../config/lavalink.js'; +import lavalinkConfig from '../../config/music/lavalink.js'; import { setupPlayerHandler } from './playerHandler.js'; const require = createRequire(import.meta.url); @@ -9,7 +9,7 @@ const { Riffy } = require('riffy'); export function initializeMusic(client) { if (!lavalinkConfig.nodes?.length) { - logger.error('No Lavalink nodes configured. Set LAVALINK_HOST (or LAVALINK_NODES) in your environment.'); + logger.error('No Lavalink nodes configured. Add lavalink/nodes.json, set LAVALINK_NODES, or set LAVALINK_HOST in your environment.'); return; } diff --git a/src/services/panelHealthService.js b/src/services/panelHealthService.js index b305df2c31..98cbd60170 100644 --- a/src/services/panelHealthService.js +++ b/src/services/panelHealthService.js @@ -1,5 +1,6 @@ import { logger } from '../utils/logger.js'; -import { getGuildConfigKey } from '../utils/database/keys.js'; +import { getReactionRoleKey } from '../utils/database/keys.js'; +import { getGuildConfig, setGuildConfig, patchGuildConfig } from './guildConfig.js'; import { getTicketPanelStatus, getVerificationPanelStatus, @@ -9,15 +10,16 @@ import { getAllReactionRoleMessages } from './reactionRoleService.js'; async function persistVerificationMessageId(client, guildId, config, messageId) { if (!messageId || config.verification?.messageId === messageId) return; - config.verification = { ...config.verification, messageId }; - await client.db.set(getGuildConfigKey(guildId), config); + await patchGuildConfig(client, guildId, { + verification: { ...config.verification, messageId }, + }); } async function persistReactionRoleMessageId(client, guildId, panelData, messageId) { if (!messageId || panelData.messageId === messageId) return; - const oldKey = `reaction_roles:${guildId}:${panelData.messageId}`; + const oldKey = getReactionRoleKey(guildId, panelData.messageId); panelData.messageId = messageId; - const newKey = `reaction_roles:${guildId}:${messageId}`; + const newKey = getReactionRoleKey(guildId, messageId); await client.db.set(newKey, panelData); await client.db.delete(oldKey).catch(() => {}); } @@ -36,7 +38,7 @@ export async function reconcileTicketPanels(client) { summary.scannedGuilds += 1; try { - const config = await client.db.get(getGuildConfigKey(guild.id)); + const config = await getGuildConfig(client, guild.id); if (!config?.ticketPanelChannelId) continue; const panelStatus = await getTicketPanelStatus(client, guild, config); @@ -44,7 +46,7 @@ export async function reconcileTicketPanels(client) { if (panelStatus.recoveredId) { summary.recoveredIds += 1; config.ticketPanelMessageId = panelStatus.recoveredId; - await client.db.set(getGuildConfigKey(guild.id), config); + await setGuildConfig(client, guild.id, config); } if (panelStatus.exists) { @@ -81,7 +83,7 @@ export async function reconcileVerificationPanels(client) { summary.scannedGuilds += 1; try { - const config = await client.db.get(getGuildConfigKey(guild.id)); + const config = await getGuildConfig(client, guild.id); const verification = config?.verification; if (!verification?.channelId || verification.enabled === false) continue; diff --git a/src/services/reactionRoleService.js b/src/services/reactionRoleService.js index ef125a9005..70c9be2134 100644 --- a/src/services/reactionRoleService.js +++ b/src/services/reactionRoleService.js @@ -2,6 +2,7 @@ import { logger } from '../utils/logger.js'; import { createError, ErrorTypes } from '../utils/errorHandler.js'; +import { getReactionRoleKey, getReactionRolesPrefix } from '../utils/database/keys.js'; const MAX_ROLES_PER_MESSAGE = 25; @@ -105,7 +106,7 @@ export async function getReactionRoleMessage(client, guildId, messageId) { validateGuildId(guildId); validateMessageId(messageId); - const key = `reaction_roles:${guildId}:${messageId}`; + const key = getReactionRoleKey(guildId, messageId); const data = await client.db.get(key); return data || null; } catch (error) { @@ -167,7 +168,7 @@ export async function createReactionRoleMessage(client, guildId, channelId, mess createdAt: new Date().toISOString() }; - const key = `reaction_roles:${guildId}:${messageId}`; + const key = getReactionRoleKey(guildId, messageId); await client.db.set(key, reactionRoleData); logger.info(`Created reaction role message ${messageId} in guild ${guildId} with ${roleIds.length} roles`); @@ -193,7 +194,7 @@ export async function addReactionRole(client, guildId, messageId, emoji, roleId) validateRoleId(roleId); await validateRoleSafety(client, guildId, roleId); - const key = `reaction_roles:${guildId}:${messageId}`; + const key = getReactionRoleKey(guildId, messageId); const data = await getReactionRoleMessage(client, guildId, messageId) || { messageId, guildId, @@ -225,7 +226,7 @@ export async function deleteReactionRoleMessage(client, guildId, messageId) { validateGuildId(guildId); validateMessageId(messageId); - const key = `reaction_roles:${guildId}:${messageId}`; + const key = getReactionRoleKey(guildId, messageId); const data = await getReactionRoleMessage(client, guildId, messageId); if (!data) { @@ -256,7 +257,7 @@ export async function removeReactionRole(client, guildId, messageId, emoji) { validateGuildId(guildId); validateMessageId(messageId); - const key = `reaction_roles:${guildId}:${messageId}`; + const key = getReactionRoleKey(guildId, messageId); const data = await getReactionRoleMessage(client, guildId, messageId); if (!data || !data.roles[emoji]) { @@ -292,7 +293,7 @@ export async function getAllReactionRoleMessages(client, guildId) { try { validateGuildId(guildId); - const prefix = `reaction_roles:${guildId}:`; + const prefix = getReactionRolesPrefix(guildId); let keys; try { @@ -388,7 +389,7 @@ export async function setReactionRoleChannel(client, guildId, messageId, channel ); } - const key = `reaction_roles:${guildId}:${messageId}`; + const key = getReactionRoleKey(guildId, messageId); const data = await getReactionRoleMessage(client, guildId, messageId) || { messageId, guildId, @@ -447,7 +448,7 @@ export async function reconcileReactionRoleMessages(client, guildId = null) { if (!guild) { for (const reactionRoleMessage of reactionRoleMessages) { summary.scannedMessages += 1; - await client.db.delete(`reaction_roles:${targetGuildId}:${reactionRoleMessage.messageId}`); + await client.db.delete(getReactionRoleKey(targetGuildId, reactionRoleMessage.messageId)); summary.removedMessages += 1; } logger.info(`Removed ${reactionRoleMessages.length} stale reaction role message(s) for unavailable guild ${targetGuildId}`); @@ -462,14 +463,14 @@ export async function reconcileReactionRoleMessages(client, guildId = null) { || await guild.channels.fetch(reactionRoleMessage.channelId).catch(() => null); if (!channel || !channel.isTextBased?.()) { - await client.db.delete(`reaction_roles:${targetGuildId}:${reactionRoleMessage.messageId}`); + await client.db.delete(getReactionRoleKey(targetGuildId, reactionRoleMessage.messageId)); summary.removedMessages += 1; continue; } const message = await channel.messages.fetch(reactionRoleMessage.messageId).catch(() => null); if (!message) { - await client.db.delete(`reaction_roles:${targetGuildId}:${reactionRoleMessage.messageId}`); + await client.db.delete(getReactionRoleKey(targetGuildId, reactionRoleMessage.messageId)); summary.removedMessages += 1; } } catch (messageCheckError) { diff --git a/src/services/serverstatsService.js b/src/services/serverstatsService.js index 3bc5ffd03c..ef8f7e8a6d 100644 --- a/src/services/serverstatsService.js +++ b/src/services/serverstatsService.js @@ -2,7 +2,9 @@ import { logger } from '../utils/logger.js'; import { logEvent, EVENT_TYPES } from './loggingService.js'; -import { formatLogLine } from '../utils/logEmbeds.js'; +import { formatLogLine } from '../utils/logging/logEmbeds.js'; +import { getServerCountersKey } from '../utils/database/keys.js'; +import botConfig from '../config/bot.js'; export const COUNTER_TYPE_CONFIG = { members: { @@ -42,6 +44,26 @@ export function getCounterEmoji(type) { return getCounterConfig(type).emoji; } +export function formatCounterChannelName(type, count) { + const template = botConfig.counters?.defaults?.channelName || '{name}-{count}'; + const baseName = getCounterBaseName(type); + return template + .replaceAll('{name}', baseName) + .replaceAll('{count}', String(count)); +} + +export function getCounterActionMessage(action, values = {}) { + const template = botConfig.counters?.messages?.[action]; + if (!template) { + return null; + } + + return Object.entries(values).reduce( + (message, [key, value]) => message.replaceAll(`{${key}}`, String(value)), + template, + ); +} + export async function getGuildCounterStats(guild) { let memberCollection = guild.members.cache; @@ -150,7 +172,7 @@ export async function updateCounter(client, guild, counter) { logger.debug(`Base name: "${baseName}", Current name: "${channel.name}"`); } - const newName = `${baseName}: ${count}`; + const newName = formatCounterChannelName(type, count); if (process.env.NODE_ENV !== 'production') { logger.debug(`New name would be: "${newName}"`); } @@ -204,7 +226,7 @@ export async function getServerCounters(client, guildId) { return []; } - const data = await client.db.get(`counters:${guildId}`); + const data = await client.db.get(getServerCountersKey(guildId)); let counters = []; @@ -248,7 +270,7 @@ export async function saveServerCounters(client, guildId, counters) { logger.debug(`Saving ${sanitizedCounters.length} counters for guild ${guildId}:`, sanitizedCounters); } - await client.db.set(`counters:${guildId}`, sanitizedCounters); + await client.db.set(getServerCountersKey(guildId), sanitizedCounters); if (process.env.NODE_ENV !== 'production') { logger.debug('Counters saved successfully'); } diff --git a/src/services/ticket.js b/src/services/ticket.js index 07a9cc8fa4..e3c9e3b69e 100644 --- a/src/services/ticket.js +++ b/src/services/ticket.js @@ -8,16 +8,46 @@ import { PermissionFlagsBits, AttachmentBuilder, } from 'discord.js'; -import { buildStandardLogEmbed, formatLogLine } from '../utils/logEmbeds.js'; +import { buildStandardLogEmbed, formatLogLine } from '../utils/logging/logEmbeds.js'; import { getGuildConfig } from './guildConfig.js'; import { getTicketData, saveTicketData, deleteTicketData, getOpenTicketCountForUser, incrementTicketCounter } from '../utils/database.js'; import { logger } from '../utils/logger.js'; import { createEmbed, errorEmbed } from '../utils/embeds.js'; -import { logTicketEvent } from '../utils/ticketLogging.js'; -import { ensureTypedServiceError } from '../utils/serviceErrorBoundary.js'; +import { logTicketEvent } from '../utils/ticket/ticketLogging.js'; +import { createError, ErrorTypes } from '../utils/errorHandler.js'; +import { ensureTypedServiceError, wrapServiceBoundary } from '../utils/serviceErrorBoundary.js'; import { PRIORITY_MAP } from '../utils/helpers.js'; const TICKET_DELETE_DELAY_MS = 3000; const TICKET_DELETE_DELAY_SECONDS = Math.floor(TICKET_DELETE_DELAY_MS / 1000); +const TICKET_SERVICE = 'ticketService'; + +function ticketUserError(message, userMessage, type = ErrorTypes.VALIDATION, context = {}) { + throw createError(message, type, userMessage, { service: TICKET_SERVICE, ...context }); +} + +function requireTicket(ticketData, channel) { + if (!ticketData) { + ticketUserError( + 'Not a ticket channel', + 'This is not a ticket channel.', + ErrorTypes.VALIDATION, + { channelId: channel?.id, guildId: channel?.guild?.id } + ); + } + return ticketData; +} + +function rethrowTicketError(error, operation, userMessage, context = {}) { + throw ensureTypedServiceError(error, { + service: TICKET_SERVICE, + operation, + message: `Ticket operation failed: ${operation}`, + userMessage, + context, + }); +} + + function buildTicketControlRow({ claimedBy = null } = {}) { return new ActionRowBuilder().addComponents( @@ -40,26 +70,14 @@ function buildTicketControlRow({ claimedBy = null } = {}) { ); } -export async function getUserTicketCount(guildId, userId) { - try { - return await getOpenTicketCountForUser(guildId, userId); - } catch (error) { - const typedError = ensureTypedServiceError(error, { - service: 'ticketService', - operation: 'getUserTicketCount', - message: 'Ticket operation failed: getUserTicketCount', - userMessage: 'Failed to count open tickets.', - context: { guildId, userId } - }); - logger.error('Error counting user tickets:', { - guildId, - userId, - error: typedError.message, - errorCode: typedError.context?.errorCode - }); - return 0; - } -} +export const getUserTicketCount = wrapServiceBoundary(async function getUserTicketCount(guildId, userId) { + return await getOpenTicketCountForUser(guildId, userId); +}, { + service: TICKET_SERVICE, + operation: 'getUserTicketCount', + userMessage: 'Failed to count open tickets.', + context: {}, +}); export async function createTicket(guild, member, categoryId, reason = 'No reason provided', priority = 'none') { try { @@ -70,10 +88,12 @@ export async function createTicket(guild, member, categoryId, reason = 'No reaso const currentTicketCount = await getUserTicketCount(guild.id, member.id); if (currentTicketCount >= maxTicketsPerUser) { - return { - success: false, - error: `You have reached the maximum number of open tickets (${maxTicketsPerUser}). Please close your existing tickets before creating a new one.` - }; + ticketUserError( + `Max open tickets reached for ${member.id}`, + `You have reached the maximum number of open tickets (${maxTicketsPerUser}). Please close your existing tickets before creating a new one.`, + ErrorTypes.VALIDATION, + { guildId: guild.id, userId: member.id, operation: 'createTicket' } + ); } let category = categoryId ? @@ -209,36 +229,16 @@ export async function createTicket(guild, member, categoryId, reason = 'No reaso } }); - return { success: true, channel, ticketData }; + return { channel, ticketData }; } catch (error) { - const typedError = ensureTypedServiceError(error, { - service: 'ticketService', - operation: 'createTicket', - message: 'Ticket operation failed: createTicket', - userMessage: 'Failed to create ticket. Please try again in a moment.', - context: { guildId: guild?.id, userId: member?.id } - }); - logger.error('Error creating ticket:', { - guildId: guild?.id, - userId: member?.id, - error: typedError.message, - errorCode: typedError.context?.errorCode - }); - return { - success: false, - error: typedError.userMessage || typedError.message, - errorCode: typedError.context?.errorCode - }; + rethrowTicketError(error, 'createTicket', 'Failed to create ticket. Please try again in a moment.', { guildId: guild?.id, userId: member?.id }); } } export async function closeTicket(channel, closer, reason = 'No reason provided') { try { - const ticketData = await getTicketData(channel.guild.id, channel.id); - if (!ticketData) { - return { success: false, error: 'This is not a ticket channel' }; - } + const ticketData = requireTicket(await getTicketData(channel.guild.id, channel.id), channel); const config = await getGuildConfig(channel.client, channel.guild.id); const dmOnClose = config.dmOnClose !== false; @@ -411,43 +411,24 @@ components: [] } }); - return { success: true, ticketData }; + return ticketData; } catch (error) { - const typedError = ensureTypedServiceError(error, { - service: 'ticketService', - operation: 'closeTicket', - message: 'Ticket operation failed: closeTicket', - userMessage: 'Failed to close ticket. Please try again in a moment.', - context: { guildId: channel?.guild?.id, channelId: channel?.id, closerId: closer?.id } - }); - logger.error('Error closing ticket:', { - guildId: channel?.guild?.id, - channelId: channel?.id, - userId: closer?.id, - error: typedError.message, - errorCode: typedError.context?.errorCode - }); - return { - success: false, - error: typedError.userMessage || typedError.message, - errorCode: typedError.context?.errorCode - }; + rethrowTicketError(error, 'closeTicket', 'Failed to close ticket. Please try again in a moment.', { guildId: channel?.guild?.id, channelId: channel?.id, closerId: closer?.id }); } } export async function claimTicket(channel, claimer) { try { - const ticketData = await getTicketData(channel.guild.id, channel.id); - if (!ticketData) { - return { success: false, error: 'This is not a ticket channel' }; - } + const ticketData = requireTicket(await getTicketData(channel.guild.id, channel.id), channel); if (ticketData.claimedBy) { - return { - success: false, - error: `This ticket is already claimed by <@${ticketData.claimedBy}>` - }; + ticketUserError( + 'Ticket already claimed', + `This ticket is already claimed by <@${ticketData.claimedBy}>`, + ErrorTypes.VALIDATION, + { channelId: channel.id, claimedBy: ticketData.claimedBy, operation: 'claimTicket' } + ); } ticketData.claimedBy = claimer.id; @@ -517,43 +498,24 @@ export async function claimTicket(channel, claimer) { } }); - return { success: true, ticketData }; + return ticketData; } catch (error) { - const typedError = ensureTypedServiceError(error, { - service: 'ticketService', - operation: 'claimTicket', - message: 'Ticket operation failed: claimTicket', - userMessage: 'Failed to claim ticket. Please try again in a moment.', - context: { guildId: channel?.guild?.id, channelId: channel?.id, claimerId: claimer?.id } - }); - logger.error('Error claiming ticket:', { - guildId: channel?.guild?.id, - channelId: channel?.id, - userId: claimer?.id, - error: typedError.message, - errorCode: typedError.context?.errorCode - }); - return { - success: false, - error: typedError.userMessage || typedError.message, - errorCode: typedError.context?.errorCode - }; + rethrowTicketError(error, 'claimTicket', 'Failed to claim ticket. Please try again in a moment.', { guildId: channel?.guild?.id, channelId: channel?.id, claimerId: claimer?.id }); } } export async function reopenTicket(channel, reopener) { try { - const ticketData = await getTicketData(channel.guild.id, channel.id); - if (!ticketData) { - return { success: false, error: 'This is not a ticket channel' }; - } + const ticketData = requireTicket(await getTicketData(channel.guild.id, channel.id), channel); if (ticketData.status !== 'closed') { - return { - success: false, - error: 'This ticket is not currently closed' - }; + ticketUserError( + 'Ticket not closed', + 'This ticket is not currently closed.', + ErrorTypes.VALIDATION, + { channelId: channel.id, operation: 'reopenTicket' } + ); } const config = await getGuildConfig(channel.client, channel.guild.id); @@ -641,33 +603,10 @@ export async function reopenTicket(channel, reopener) { await channel.send({ embeds: [reopenEmbed] }); } - return { - success: true, - ticketData, - movedToOpenCategory, - openCategoryMoveFailed - }; + return { ticketData, movedToOpenCategory, openCategoryMoveFailed }; } catch (error) { - const typedError = ensureTypedServiceError(error, { - service: 'ticketService', - operation: 'reopenTicket', - message: 'Ticket operation failed: reopenTicket', - userMessage: 'Failed to reopen ticket. Please try again in a moment.', - context: { guildId: channel?.guild?.id, channelId: channel?.id, reopenerId: reopener?.id } - }); - logger.error('Error reopening ticket:', { - guildId: channel?.guild?.id, - channelId: channel?.id, - userId: reopener?.id, - error: typedError.message, - errorCode: typedError.context?.errorCode - }); - return { - success: false, - error: typedError.userMessage || typedError.message, - errorCode: typedError.context?.errorCode - }; + rethrowTicketError(error, 'reopenTicket', 'Failed to reopen ticket. Please try again in a moment.', { guildId: channel?.guild?.id, channelId: channel?.id, reopenerId: reopener?.id }); } } @@ -768,10 +707,7 @@ ${rows} export async function deleteTicket(channel, deleter) { try { - const ticketData = await getTicketData(channel.guild.id, channel.id); - if (!ticketData) { - return { success: false, error: 'This is not a ticket channel' }; - } + const ticketData = requireTicket(await getTicketData(channel.guild.id, channel.id), channel); const deleteEmbed = createEmbed({ title: 'Ticket Deleted', @@ -913,50 +849,33 @@ export async function deleteTicket(channel, deleter) { } }, TICKET_DELETE_DELAY_MS); - return { success: true, ticketData }; + return ticketData; } catch (error) { - const typedError = ensureTypedServiceError(error, { - service: 'ticketService', - operation: 'deleteTicket', - message: 'Ticket operation failed: deleteTicket', - userMessage: 'Failed to delete ticket. Please try again in a moment.', - context: { guildId: channel?.guild?.id, channelId: channel?.id, deleterId: deleter?.id } - }); - logger.error('Error deleting ticket:', { - guildId: channel?.guild?.id, - channelId: channel?.id, - userId: deleter?.id, - error: typedError.message, - errorCode: typedError.context?.errorCode - }); - return { - success: false, - error: typedError.userMessage || typedError.message, - errorCode: typedError.context?.errorCode - }; + rethrowTicketError(error, 'deleteTicket', 'Failed to delete ticket. Please try again in a moment.', { guildId: channel?.guild?.id, channelId: channel?.id, deleterId: deleter?.id }); } } export async function unclaimTicket(channel, unclaimer) { try { - const ticketData = await getTicketData(channel.guild.id, channel.id); - if (!ticketData) { - return { success: false, error: 'This is not a ticket channel' }; - } + const ticketData = requireTicket(await getTicketData(channel.guild.id, channel.id), channel); if (!ticketData.claimedBy) { - return { - success: false, - error: 'This ticket is not currently claimed' - }; + ticketUserError( + 'Ticket not claimed', + 'This ticket is not currently claimed.', + ErrorTypes.VALIDATION, + { channelId: channel.id, operation: 'unclaimTicket' } + ); } if (ticketData.claimedBy !== unclaimer.id && !unclaimer.permissions.has(PermissionFlagsBits.ManageChannels)) { - return { - success: false, - error: 'You can only unclaim your own tickets or need Manage Channels permission.' - }; + ticketUserError( + 'Cannot unclaim ticket', + 'You can only unclaim your own tickets or need Manage Channels permission.', + ErrorTypes.PERMISSION, + { channelId: channel.id, operation: 'unclaimTicket' } + ); } const previousClaimer = ticketData.claimedBy; @@ -1028,28 +947,10 @@ export async function unclaimTicket(channel, unclaimer) { } }); - return { success: true, ticketData }; + return ticketData; } catch (error) { - const typedError = ensureTypedServiceError(error, { - service: 'ticketService', - operation: 'unclaimTicket', - message: 'Ticket operation failed: unclaimTicket', - userMessage: 'Failed to unclaim ticket. Please try again in a moment.', - context: { guildId: channel?.guild?.id, channelId: channel?.id, unclaimerId: unclaimer?.id } - }); - logger.error('Error unclaiming ticket:', { - guildId: channel?.guild?.id, - channelId: channel?.id, - userId: unclaimer?.id, - error: typedError.message, - errorCode: typedError.context?.errorCode - }); - return { - success: false, - error: typedError.userMessage || typedError.message, - errorCode: typedError.context?.errorCode - }; + rethrowTicketError(error, 'unclaimTicket', 'Failed to unclaim ticket. Please try again in a moment.', { guildId: channel?.guild?.id, channelId: channel?.id, unclaimerId: unclaimer?.id }); } } @@ -1059,14 +960,16 @@ async function getNextTicketNumber(guildId) { export async function updateTicketPriority(channel, priority, updater) { try { - const ticketData = await getTicketData(channel.guild.id, channel.id); - if (!ticketData) { - return { success: false, error: 'This is not a ticket channel' }; - } + const ticketData = requireTicket(await getTicketData(channel.guild.id, channel.id), channel); const priorityInfo = PRIORITY_MAP[priority]; if (!priorityInfo) { - return { success: false, error: 'Invalid priority level' }; + ticketUserError( + 'Invalid priority level', + 'Invalid priority level.', + ErrorTypes.VALIDATION, + { channelId: channel.id, priority, operation: 'updateTicketPriority' } + ); } ticketData.priority = priority; @@ -1136,27 +1039,9 @@ export async function updateTicketPriority(channel, priority, updater) { } }); - return { success: true, ticketData }; + return ticketData; } catch (error) { - const typedError = ensureTypedServiceError(error, { - service: 'ticketService', - operation: 'updateTicketPriority', - message: 'Ticket operation failed: updateTicketPriority', - userMessage: 'Failed to update ticket priority. Please try again in a moment.', - context: { guildId: channel?.guild?.id, channelId: channel?.id, updaterId: updater?.id, priority } - }); - logger.error('Error updating ticket priority:', { - guildId: channel?.guild?.id, - channelId: channel?.id, - userId: updater?.id, - error: typedError.message, - errorCode: typedError.context?.errorCode - }); - return { - success: false, - error: typedError.userMessage || typedError.message, - errorCode: typedError.context?.errorCode - }; + rethrowTicketError(error, 'updateTicketPriority', 'Failed to update ticket priority. Please try again in a moment.', { guildId: channel?.guild?.id, channelId: channel?.id, updaterId: updater?.id, priority }); } } \ No newline at end of file diff --git a/src/services/verificationService.js b/src/services/verificationService.js index 4206501c03..83e5676c89 100644 --- a/src/services/verificationService.js +++ b/src/services/verificationService.js @@ -81,11 +81,10 @@ export async function verifyUser(client, guildId, userId, options = {}) { if (member.roles.cache.has(verifiedRole.id)) { return { - success: false, - alreadyVerified: true, - message: "User already verified", + status: 'already_verified', userId, - roleId: verifiedRole.id + roleId: verifiedRole.id, + roleName: verifiedRole.name, }; } @@ -110,11 +109,10 @@ export async function verifyUser(client, guildId, userId, options = {}) { }); return { - success: true, + status: 'verified', userId, roleId: verifiedRole.id, roleName: verifiedRole.name, - message: "User verified successfully" }; } catch (error) { @@ -350,10 +348,8 @@ export async function removeVerification(client, guildId, userId, options = {}) if (!member.roles.cache.has(verifiedRole.id)) { return { - success: false, - notVerified: true, - message: "User doesn't have the verified role", - userId + status: 'not_verified', + userId, }; } @@ -377,10 +373,9 @@ export async function removeVerification(client, guildId, userId, options = {}) }); return { - success: true, + status: 'removed', userId, roleId: verifiedRole.id, - message: "Verification removed successfully" }; } catch (error) { diff --git a/src/services/warningService.js b/src/services/warningService.js deleted file mode 100644 index 460ff29487..0000000000 --- a/src/services/warningService.js +++ /dev/null @@ -1,123 +0,0 @@ -// warningService.js - -import { getFromDb, setInDb } from '../utils/database.js'; -import { logger } from '../utils/logger.js'; - -export class WarningService { - - static async addWarning({ - guildId, - userId, - moderatorId, - reason, - timestamp = Date.now() - }) { - try { - const key = `moderation:warnings:${guildId}:${userId}`; - - const warnings = await getFromDb(key, []); - - if (!Array.isArray(warnings)) { - logger.warn(`Warnings for ${userId} in ${guildId} corrupted, resetting`); - await setInDb(key, []); - return { success: false, error: 'Corrupted data' }; - } - - const warning = { - id: Date.now(), - guildId, - userId, - moderatorId, - reason, - timestamp, - status: 'active' - }; - - warnings.push(warning); - - await setInDb(key, warnings); - - logger.info(`Warning added: ${userId} in ${guildId} by ${moderatorId}`); - - return { - success: true, - id: warning.id, - totalCount: warnings.length - }; - } catch (error) { - logger.error('Error adding warning:', error); - return { success: false, error: error.message }; - } - } - - static async getWarnings(guildId, userId) { - try { - const key = `moderation:warnings:${guildId}:${userId}`; - const warnings = await getFromDb(key, []); - - return Array.isArray(warnings) - ? warnings.filter(w => w && w.status !== 'deleted') - : []; - } catch (error) { - logger.error('Error fetching warnings:', error); - return []; - } - } - - static async getWarningCount(guildId, userId) { - const warnings = await this.getWarnings(guildId, userId); - return warnings.length; - } - - static async removeWarning(guildId, userId, warningId) { - try { - const key = `moderation:warnings:${guildId}:${userId}`; - const warnings = await getFromDb(key, []); - - const index = warnings.findIndex(w => w.id === warningId); - if (index === -1) { - return { success: false, error: 'Warning not found' }; - } - - warnings[index].status = 'deleted'; - await setInDb(key, warnings); - - logger.info(`Warning removed: ${warningId} for ${userId} in ${guildId}`); - return { success: true }; - } catch (error) { - logger.error('Error removing warning:', error); - return { success: false, error: error.message }; - } - } - - static async clearWarnings(guildId, userId) { - try { - const key = `moderation:warnings:${guildId}:${userId}`; - const warnings = await getFromDb(key, []); - const count = warnings.length; - - await setInDb(key, []); - - logger.info(`Warnings cleared for ${userId} in ${guildId} (${count} removed)`); - return { success: true, count }; - } catch (error) { - logger.error('Error clearing warnings:', error); - return { success: false, error: error.message }; - } - } - - static async getGuildWarnings(guildId, filters = {}) { - try { - const { moderatorId, limit = 100 } = filters; - const prefix = `moderation:warnings:${guildId}:`; - - const allWarnings = []; - - logger.debug(`Fetched guild warnings for ${guildId} with ${allWarnings.length} total`); - return allWarnings.slice(0, limit); - } catch (error) { - logger.error('Error fetching guild warnings:', error); - return []; - } - } -} \ No newline at end of file diff --git a/src/services/xpSystem.js b/src/services/xpSystem.js deleted file mode 100644 index afb3eec503..0000000000 --- a/src/services/xpSystem.js +++ /dev/null @@ -1,140 +0,0 @@ -// xpSystem.js - -import { logger } from '../utils/logger.js'; -import { getLevelingConfig, getXpForLevel, getUserLevelData, saveUserLevelData } from './leveling.js'; -import { logEvent, EVENT_TYPES } from './loggingService.js'; -import { formatLogLine } from '../utils/logEmbeds.js'; -import { Mutex } from '../utils/mutex.js'; - -export async function addXp(client, guild, member, xpToAdd) { - const lockKey = `leveling:${guild.id}:${member.user.id}`; - return await Mutex.runExclusive(lockKey, async () => { - try { - - if (!xpToAdd || xpToAdd <= 0) { - return { success: false, reason: 'Invalid XP amount' }; - } - - const config = await getLevelingConfig(client, guild.id); - - if (!config.enabled) { - return { success: false, reason: 'Leveling is disabled in this server' }; - } - - const levelData = await getUserLevelData(client, guild.id, member.user.id); - - levelData.xp += xpToAdd; - levelData.totalXp += xpToAdd; - levelData.lastMessage = Date.now(); - - let xpNeededForNextLevel = getXpForLevel(levelData.level); - let didLevelUp = false; - const initialLevel = levelData.level; - - while (levelData.xp >= xpNeededForNextLevel && levelData.level < 1000) { - levelData.xp -= xpNeededForNextLevel; - levelData.level += 1; - didLevelUp = true; - xpNeededForNextLevel = getXpForLevel(levelData.level); - - logger.info(`🎉 ${member.user.tag} leveled up to level ${levelData.level} in ${guild.name}`); - - if (config.roleRewards && config.roleRewards[levelData.level]) { - await awardRoleReward(guild, member, config.roleRewards[levelData.level], levelData.level); - } - } - - if (didLevelUp) { - - if (config.announceLevelUp) { - await sendLevelUpAnnouncement(guild, member, levelData, config); - } - - try { - await logEvent({ - client, - guildId: guild.id, - eventType: EVENT_TYPES.LEVELING_LEVELUP, - data: { - title: 'Level Up', - lines: [ - formatLogLine('Member', `${member.user.tag} (\`${member.user.id}\`)`), - formatLogLine('New Level', levelData.level.toString()), - formatLogLine('Levels Gained', (levelData.level - initialLevel).toString()), - formatLogLine('Total XP', levelData.totalXp.toString()), - ], - userId: member.user.id, - }, - }); - } catch (logError) { - logger.debug('Failed to log leveling event:', logError.message); - } - } - - await saveUserLevelData(client, guild.id, member.user.id, levelData); - - return { - success: true, - level: levelData.level, - xp: levelData.xp, - totalXp: levelData.totalXp, - xpNeeded: getXpForLevel(levelData.level + 1), - leveledUp: didLevelUp - }; - - } catch (error) { - logger.error('Error adding XP:', error); - return { success: false, error: error.message }; - } - }); -} - -async function awardRoleReward(guild, member, roleId, level) { - try { - const role = guild.roles.cache.get(roleId); - - if (!role) { - logger.warn(`Role ${roleId} not found for level ${level} reward in guild ${guild.id}`); - return; - } - - if (member.roles.cache.has(roleId)) { - return; - } - - await member.roles.add(role, `Level ${level} reward`); - logger.info(`✅ Awarded role ${role.name} to ${member.user.tag} for reaching level ${level}`); - } catch (error) { - logger.error(`Failed to award role reward to ${member.user.id}:`, error); - } -} - -async function sendLevelUpAnnouncement(guild, member, levelData, config) { - try { - const levelUpChannel = config.levelUpChannel - ? guild.channels.cache.get(config.levelUpChannel) - : guild.systemChannel; - - if (!levelUpChannel || !levelUpChannel.isTextBased()) { - return; - } - - const permissions = levelUpChannel.permissionsFor(guild.members.me); - if (!permissions || !permissions.has(['SendMessages', 'EmbedLinks'])) { - logger.warn(`Missing permissions to send levelup message in ${levelUpChannel.id}`); - return; - } - - const message = config.levelUpMessage - .replace(/{user}/g, member.toString()) - .replace(/{level}/g, levelData.level) - .replace(/{xp}/g, levelData.xp) - .replace(/{xpNeeded}/g, getXpForLevel(levelData.level + 1)); - - await levelUpChannel.send(message).catch(error => { - logger.error(`Failed to send level up message in channel ${levelUpChannel.id}:`, error); - }); - } catch (error) { - logger.error('Error sending level up announcement:', error); - } -} \ No newline at end of file diff --git a/src/utils/commandPipeline.js b/src/utils/commandPipeline.js new file mode 100644 index 0000000000..5d518d0650 --- /dev/null +++ b/src/utils/commandPipeline.js @@ -0,0 +1,21 @@ +/** + * Standard slash-command export shape. + * + * Usage: + * export default defineSlashCommand({ + * data: new SlashCommandBuilder()..., + * category: 'economy', + * async execute(interaction, config, client) { + * // throw TitanBotError / createError on failure + * // use replyUserError for early validation returns + * // do NOT wrap in try/catch — interactionCreate handles errors + * }, + * }); + */ + +export function defineSlashCommand(command) { + if (!command?.data || typeof command.execute !== 'function') { + throw new Error('defineSlashCommand requires { data, execute }'); + } + return command; +} diff --git a/src/utils/database.js b/src/utils/database.js index 071b89a67c..65a6f2696f 100644 --- a/src/utils/database.js +++ b/src/utils/database.js @@ -2,9 +2,7 @@ import { pgDb } from './postgresDatabase.js'; import { logger } from './logger.js'; -import { BotConfig } from '../config/bot.js'; -import { normalizeGuildConfig, validateGuildConfigOrThrow } from './schemas.js'; -import { DEFAULT_GUILD_CONFIG } from './constants.js'; +import { BotConfig, getDefaultApplicationQuestions } from '../config/bot.js'; export { db, @@ -17,6 +15,8 @@ export { export { getGuildConfigKey, getGuildBirthdaysKey, + getBirthdayLeftBackupKey, + getBirthdayTrackingKey, getTicketKey, getTicketCounterKey, getInviteTrackingKey, @@ -24,16 +24,30 @@ export { getInviteUsesKey, getFakeAccountKey, getEconomyKey, + getEconomyPrefix, getAFKKey, getWelcomeConfigKey, getLevelingKey, getUserLevelKey, + getUserLevelPrefix, getApplicationRolesKey, getApplicationSettingsKey, getUserApplicationsKey, getApplicationKey, + getApplicationsPrefix, getJoinToCreateConfigKey, getJoinToCreateChannelsKey, + getWarningsKey, + getWarningsPrefix, + getUserNotesKey, + getUserNotesListKey, + getReactionRoleKey, + getReactionRolesPrefix, + getServerCountersKey, + getGiveawayEntryKey, + getGiveawayLockKey, + canonicalizeKey, + getLegacyVariantsForCanonical, } from './database/keys.js'; export { @@ -61,6 +75,7 @@ import { getWelcomeConfigKey, getEconomyKey, getAFKKey, + getUserLevelPrefix, } from './database/keys.js'; export async function insertVerificationAudit(record) { @@ -107,51 +122,8 @@ export function unwrapReplitData(data) { return data; } -export async function getGuildConfig(client, guildId, context = {}) { - try { - if (!client.db || typeof client.db.get !== "function") { - return {}; - } - - const configKey = getGuildConfigKey(guildId); - const rawConfig = await client.db.get(configKey, {}); - const cleanedConfig = unwrapReplitData(rawConfig); - - return normalizeGuildConfig(cleanedConfig, DEFAULT_GUILD_CONFIG); - } catch (error) { - logger.error(`Error fetching config for guild ${guildId}`, { - error, - traceId: context.traceId, - guildId, - userId: context.userId, - command: context.command - }); - return {}; - } -} - -export async function setGuildConfig(client, guildId, config, context = {}) { - try { - if (!client.db || typeof client.db.set !== "function") { - logger.error("Database client is not available for setGuildConfig"); - return false; - } - - const key = getGuildConfigKey(guildId); - const validated = validateGuildConfigOrThrow(config, { guildId, ...context }); - await client.db.set(key, validated); - return true; - } catch (error) { - logger.error(`Error saving config for guild ${guildId}`, { - error, - traceId: context.traceId, - guildId, - userId: context.userId, - command: context.command - }); - return false; - } -} +// Guild config access: import from services/config/guildConfig.js only. +// Low-level storage lives in ./database/guildConfigStorage.js export { pgDb }; @@ -304,7 +276,7 @@ export async function getEndedGiveaways(client) { } if (isPostgresSqlReady(wrapper)) { - const { pgConfig } = await import('../config/postgres.js'); + const { pgConfig } = await import('../config/database/postgres.js'); const result = await wrapper.db.pool.query( `SELECT id, guild_id, message_id, data, ends_at @@ -340,7 +312,7 @@ export async function markGiveawayEnded(client, giveawayId, endedData) { } if (isPostgresSqlReady(wrapper)) { - const { pgConfig } = await import('../config/postgres.js'); + const { pgConfig } = await import('../config/database/postgres.js'); await wrapper.db.pool.query( `UPDATE ${pgConfig.tables.giveaways} @@ -433,6 +405,11 @@ export async function getWelcomeConfig(client, guildId) { export async function saveWelcomeConfig(client, guildId, config) { const key = getWelcomeConfigKey(guildId); try { + if (!client.db || typeof client.db.set !== 'function') { + logger.error('Database client is not available for saveWelcomeConfig.'); + return false; + } + const existingConfig = await getWelcomeConfig(client, guildId); const mergedConfig = { ...existingConfig, ...config }; @@ -570,7 +547,7 @@ export async function getLeaderboard(client, guildId, limit = 10) { return []; } - const prefix = `guild:${guildId}:leveling:users:`; + const prefix = getUserLevelPrefix(guildId); let keys = await client.db.list(prefix); if (!Array.isArray(keys)) { @@ -654,19 +631,35 @@ export async function saveApplicationRoles(client, guildId, roles) { } } +function buildApplicationSettingsDefaults() { + return { + enabled: false, + applicationChannelId: null, + logChannelId: null, + questions: getDefaultApplicationQuestions(), + roles: { + admin: null, + reviewer: null, + accepted: null, + denied: null + }, + requiredRoles: [], + deniedRoles: [], + minAccountAge: 0, + maxApplications: 1, + cooldown: BotConfig.applications?.applicationCooldown ?? 7, + allowMultipleApplications: false, + requireVerification: false, + customWelcomeMessage: "", + pendingApplicationRetentionDays: 30, + reviewedApplicationRetentionDays: BotConfig.applications?.deleteApprovedAfter ?? 14, + }; +} + export async function getApplicationSettings(client, guildId) { if (!client.db) { logger.warn('Database not available for getApplicationSettings'); - return { - enabled: false, - applicationChannelId: null, - logChannelId: null, - questions: [ - "Why do you want to join our staff team?", - "What experience do you have that would make you a good fit?", - "How much time can you dedicate to this role?" - ] - }; + return buildApplicationSettingsDefaults(); } const key = getApplicationSettingsKey(guildId); @@ -674,62 +667,12 @@ export async function getApplicationSettings(client, guildId) { const settings = await client.db.get(key, {}); const unwrapped = unwrapReplitData(settings); - const defaultSettings = { - enabled: false, - applicationChannelId: null, - logChannelId: null, - questions: [ - "Why do you want to join our staff team?", - "What experience do you have that would make you a good fit?", - "How much time can you dedicate to this role?" - ], - roles: { - admin: null, - reviewer: null, - accepted: null, - denied: null - }, - requiredRoles: [], - deniedRoles: [], -minAccountAge: 0, - maxApplications: 1, -cooldown: 7, - allowMultipleApplications: false, - requireVerification: false, - customWelcomeMessage: "", - pendingApplicationRetentionDays: 30, - reviewedApplicationRetentionDays: 14 - }; + const defaultSettings = buildApplicationSettingsDefaults(); return { ...defaultSettings, ...unwrapped }; } catch (error) { logger.error(`Error getting application settings for guild ${guildId}:`, error); - return { - enabled: false, - applicationChannelId: null, - logChannelId: null, - questions: [ - "Why do you want to join our staff team?", - "What experience do you have that would make you a good fit?", - "How much time can you dedicate to this role?" - ], - roles: { - admin: null, - reviewer: null, - accepted: null, - denied: null - }, - requiredRoles: [], - deniedRoles: [], - minAccountAge: 0, - maxApplications: 1, - cooldown: 7, - allowMultipleApplications: false, - requireVerification: false, - customWelcomeMessage: "", - pendingApplicationRetentionDays: 30, - reviewedApplicationRetentionDays: 14 - }; + return buildApplicationSettingsDefaults(); } } diff --git a/src/utils/database/guildConfigStorage.js b/src/utils/database/guildConfigStorage.js new file mode 100644 index 0000000000..f867a4d2da --- /dev/null +++ b/src/utils/database/guildConfigStorage.js @@ -0,0 +1,84 @@ +import { logger } from '../logger.js'; +import { GUILD_CONFIG_DEFAULTS } from '../../config/guild/guildConfigDefaults.js'; +import { normalizeGuildConfig, validateGuildConfigOrThrow } from '../schemas.js'; +import { createError, ErrorTypes } from '../errorHandler.js'; +import { getGuildConfigKey } from './keys.js'; + +export function unwrapReplitData(data) { + if ( + typeof data === 'object' && + data !== null && + data.ok !== undefined && + data.value !== undefined + ) { + return unwrapReplitData(data.value); + } + return data; +} + +/** + * Low-level guild config read. Returns normalized config with defaults; + * never throws — callers use the guild config service for typed errors. + */ +export async function readGuildConfig(client, guildId, context = {}) { + try { + if (!client?.db || typeof client.db.get !== 'function') { + logger.warn(`Database unavailable for readGuildConfig in guild ${guildId}`); + return normalizeGuildConfig({}, GUILD_CONFIG_DEFAULTS); + } + + if (typeof client.db.isAvailable === 'function' && !client.db.isAvailable()) { + logger.warn(`PostgreSQL unavailable for readGuildConfig in guild ${guildId}`, { + traceId: context.traceId, + guildId, + }); + return normalizeGuildConfig({}, GUILD_CONFIG_DEFAULTS); + } + + const rawConfig = await client.db.get(getGuildConfigKey(guildId), null); + + if (rawConfig === null) { + return normalizeGuildConfig({}, GUILD_CONFIG_DEFAULTS); + } + + const cleanedConfig = unwrapReplitData(rawConfig); + return normalizeGuildConfig(cleanedConfig, GUILD_CONFIG_DEFAULTS); + } catch (error) { + logger.error(`Error fetching config for guild ${guildId}`, { + error, + traceId: context.traceId, + guildId, + userId: context.userId, + command: context.command, + }); + return normalizeGuildConfig({}, GUILD_CONFIG_DEFAULTS); + } +} + +/** + * Low-level guild config write. Validates payload and throws on failure. + */ +export async function writeGuildConfig(client, guildId, config, context = {}) { + if (!client?.db || typeof client.db.set !== 'function') { + throw createError( + 'Database client unavailable for guild config write', + ErrorTypes.DATABASE, + 'Failed to save server configuration. The database is unavailable.', + { guildId, ...context }, + ); + } + + const validated = validateGuildConfigOrThrow(config, { guildId, ...context }); + const saved = await client.db.set(getGuildConfigKey(guildId), validated); + + if (!saved) { + throw createError( + 'Guild config write rejected by database layer', + ErrorTypes.DATABASE, + 'Failed to save server configuration. Please try again.', + { guildId, ...context }, + ); + } + + return validated; +} diff --git a/src/utils/database/keyMigration.js b/src/utils/database/keyMigration.js new file mode 100644 index 0000000000..168e055864 --- /dev/null +++ b/src/utils/database/keyMigration.js @@ -0,0 +1,245 @@ +import { pgConfig } from '../../config/database/postgres.js'; +import { canonicalizeKey } from './keys.js'; +import { parseKey } from './keyParser.js'; + +/** + * Marker version for the key-canonicalization migration. Bump this only if a new + * legacy → canonical mapping is added that requires re-scanning existing data. + */ +export const KEY_MIGRATION_VERSION = 1; +const KEY_MIGRATION_MARKER = `__meta:key_migration:v${KEY_MIGRATION_VERSION}`; + +async function keyExists(client, canonicalKey) { + const parsed = parseKey(canonicalKey); + + if (parsed.type === 'economy') { + const result = await client.query( + `SELECT 1 FROM ${pgConfig.tables.economy} WHERE guild_id = $1 AND user_id = $2 LIMIT 1`, + [parsed.guildId, parsed.userId], + ); + return result.rows.length > 0; + } + + if (parsed.type === 'user_level') { + const result = await client.query( + `SELECT 1 FROM ${pgConfig.tables.user_levels} WHERE guild_id = $1 AND user_id = $2 LIMIT 1`, + [parsed.guildId, parsed.userId], + ); + return result.rows.length > 0; + } + + if (parsed.type === 'counters') { + const result = await client.query( + `SELECT counters FROM ${pgConfig.tables.guilds} WHERE id = $1 AND counters IS NOT NULL AND counters <> '[]'::jsonb LIMIT 1`, + [parsed.guildId], + ); + return result.rows.length > 0; + } + + const tempResult = await client.query( + `SELECT 1 FROM ${pgConfig.tables.temp_data} WHERE key = $1 LIMIT 1`, + [canonicalKey], + ); + return tempResult.rows.length > 0; +} + +async function ensureParentRows(client, guildId, userId) { + if (guildId) { + await client.query( + `INSERT INTO ${pgConfig.tables.guilds} (id, created_at) + VALUES ($1, CURRENT_TIMESTAMP) ON CONFLICT (id) DO NOTHING`, + [guildId], + ); + } + if (userId) { + await client.query( + `INSERT INTO ${pgConfig.tables.users} (id, created_at) + VALUES ($1, CURRENT_TIMESTAMP) ON CONFLICT (id) DO NOTHING`, + [userId], + ); + } +} + +async function migrateEconomyFromTemp(client, legacyKey, value) { + const parsed = parseKey(canonicalizeKey(legacyKey)); + const payload = typeof value === 'string' ? JSON.parse(value) : value; + const wallet = payload?.wallet ?? payload?.balance ?? 0; + const bank = payload?.bank ?? 0; + + await ensureParentRows(client, parsed.guildId, parsed.userId); + await client.query( + `INSERT INTO ${pgConfig.tables.economy} (guild_id, user_id, balance, bank, data, updated_at) + VALUES ($1, $2, $3, $4, $5::jsonb, CURRENT_TIMESTAMP) + ON CONFLICT (guild_id, user_id) DO UPDATE SET + balance = EXCLUDED.balance, + bank = EXCLUDED.bank, + data = EXCLUDED.data, + updated_at = CURRENT_TIMESTAMP`, + [parsed.guildId, parsed.userId, wallet, bank, JSON.stringify(payload ?? {})], + ); +} + +async function migrateUserLevelFromTemp(client, legacyKey, value) { + const parsed = parseKey(canonicalizeKey(legacyKey)); + const payload = typeof value === 'string' ? JSON.parse(value) : value; + + await ensureParentRows(client, parsed.guildId, parsed.userId); + await client.query( + `INSERT INTO ${pgConfig.tables.user_levels} + (guild_id, user_id, xp, level, total_xp, last_message, rank, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, CURRENT_TIMESTAMP) + ON CONFLICT (guild_id, user_id) DO UPDATE SET + xp = EXCLUDED.xp, + level = EXCLUDED.level, + total_xp = EXCLUDED.total_xp, + last_message = EXCLUDED.last_message, + rank = EXCLUDED.rank, + updated_at = CURRENT_TIMESTAMP`, + [ + parsed.guildId, + parsed.userId, + Number(payload?.xp) || 0, + Number(payload?.level) || 0, + Number(payload?.totalXp ?? payload?.total_xp) || 0, + Number(payload?.lastMessage ?? payload?.last_message) || 0, + Number(payload?.rank) || 0, + ], + ); +} + +async function migrateCountersFromTemp(client, legacyKey, value) { + const parsed = parseKey(canonicalizeKey(legacyKey)); + const payload = typeof value === 'string' ? JSON.parse(value) : value; + const counters = Array.isArray(payload) ? payload : []; + + await ensureParentRows(client, parsed.guildId, null); + await client.query( + `INSERT INTO ${pgConfig.tables.guilds} (id, counters, updated_at) + VALUES ($1, $2::jsonb, CURRENT_TIMESTAMP) + ON CONFLICT (id) DO UPDATE SET counters = EXCLUDED.counters, updated_at = CURRENT_TIMESTAMP`, + [parsed.guildId, JSON.stringify(counters)], + ); +} + +async function migrateTempKeyRename(client, legacyKey, canonicalKey) { + await client.query( + `INSERT INTO ${pgConfig.tables.temp_data} (key, value, expires_at, created_at) + SELECT $1, value, expires_at, created_at + FROM ${pgConfig.tables.temp_data} + WHERE key = $2 + ON CONFLICT (key) DO NOTHING`, + [canonicalKey, legacyKey], + ); + await client.query(`DELETE FROM ${pgConfig.tables.temp_data} WHERE key = $1`, [legacyKey]); +} + +async function hasCompletedMarker(client) { + const result = await client.query( + `SELECT 1 FROM ${pgConfig.tables.temp_data} WHERE key = $1 LIMIT 1`, + [KEY_MIGRATION_MARKER], + ); + return result.rows.length > 0; +} + +async function writeCompletedMarker(client, summary) { + await client.query( + `INSERT INTO ${pgConfig.tables.temp_data} (key, value, expires_at, created_at) + VALUES ($1, $2::jsonb, NULL, CURRENT_TIMESTAMP) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + [KEY_MIGRATION_MARKER, JSON.stringify({ completedAt: new Date().toISOString(), ...summary })], + ); +} + +/** + * Migrate legacy database keys to their canonical form. + * + * @param {object} options + * @param {import('pg').Pool} options.pool - Connected pg pool. + * @param {boolean} [options.dryRun=false] - Log actions without writing. + * @param {boolean} [options.force=false] - Ignore the completion marker. + * @param {object} [options.logger=console] - Logger with info/warn/error. + * @returns {Promise<{migrated:number, skipped:number, errors:number, alreadyDone?:boolean}>} + */ +export async function runKeyMigration({ pool, dryRun = false, force = false, logger = console } = {}) { + if (!pool) { + throw new Error('runKeyMigration requires a connected pg pool'); + } + + const client = await pool.connect(); + const summary = { migrated: 0, skipped: 0, errors: 0 }; + + try { + if (!force && !dryRun && (await hasCompletedMarker(client))) { + return { ...summary, alreadyDone: true }; + } + + logger.info(`Starting key migration${dryRun ? ' (dry run)' : ''}...`); + + const rows = await client.query( + `SELECT key, value FROM ${pgConfig.tables.temp_data} ORDER BY key`, + ); + + for (const row of rows.rows) { + const legacyKey = row.key; + if (legacyKey === KEY_MIGRATION_MARKER) continue; + + const canonicalKey = canonicalizeKey(legacyKey); + if (legacyKey === canonicalKey) continue; + + try { + if (await keyExists(client, canonicalKey)) { + logger.info(`Skip ${legacyKey} -> ${canonicalKey} (canonical already exists)`); + summary.skipped += 1; + if (!dryRun) { + await client.query( + `DELETE FROM ${pgConfig.tables.temp_data} WHERE key = $1`, + [legacyKey], + ); + } + continue; + } + + const parsed = parseKey(canonicalKey); + logger.info(`Migrate ${legacyKey} -> ${canonicalKey} [${parsed.type}]`); + + if (!dryRun) { + if (parsed.type === 'economy') { + await migrateEconomyFromTemp(client, legacyKey, row.value); + await client.query( + `DELETE FROM ${pgConfig.tables.temp_data} WHERE key = $1`, + [legacyKey], + ); + } else if (parsed.type === 'user_level') { + await migrateUserLevelFromTemp(client, legacyKey, row.value); + await client.query( + `DELETE FROM ${pgConfig.tables.temp_data} WHERE key = $1`, + [legacyKey], + ); + } else if (parsed.type === 'counters') { + await migrateCountersFromTemp(client, legacyKey, row.value); + await client.query( + `DELETE FROM ${pgConfig.tables.temp_data} WHERE key = $1`, + [legacyKey], + ); + } else { + await migrateTempKeyRename(client, legacyKey, canonicalKey); + } + } + + summary.migrated += 1; + } catch (error) { + summary.errors += 1; + logger.error(`Failed to migrate ${legacyKey}:`, error); + } + } + + if (!dryRun) { + await writeCompletedMarker(client, summary); + } + + logger.info('Key migration complete', summary); + return summary; + } finally { + client.release(); + } +} diff --git a/src/utils/database/keyParser.js b/src/utils/database/keyParser.js new file mode 100644 index 0000000000..1d2a424cbe --- /dev/null +++ b/src/utils/database/keyParser.js @@ -0,0 +1,294 @@ +import { + canonicalizeKey, + getEconomyPrefix, + getUserLevelPrefix, + getWarningsPrefix, + getReactionRolesPrefix, + getApplicationsPrefix, + getTicketCounterKey, + getServerCountersKey, + getGuildConfigKey, + getGuildBirthdaysKey, + getWelcomeConfigKey, + getLevelingKey, +} from './keys.js'; + +const TEMP_BACKED_TYPES = new Set([ + 'warnings', + 'usernotes', + 'usernotes_list', + 'reaction_role', + 'application', + 'application_roles', + 'application_settings', + 'application_users', + 'jointocreate_config', + 'jointocreate_channels', + 'birthday_left', + 'birthday_tracking', + 'invite_data', + 'giveaway_entry', + 'giveaway_lock', + 'ticket_counter', +]); + +export function isTempBackedType(type) { + return type === 'temp' || TEMP_BACKED_TYPES.has(type); +} + +/** + * Parse a storage key into routing metadata for the database layer. + */ +export function parseKey(key) { + const fullKey = canonicalizeKey(key); + + if (fullKey.startsWith('temp:')) { + return { type: 'temp', fullKey }; + } + if (fullKey.startsWith('cache:')) { + return { type: 'cache', fullKey }; + } + + const parts = fullKey.split(':'); + + if (parts[0] === 'guild') { + const guildId = parts[1]; + + if (parts[2] === 'config') { + return { type: 'guild_config', guildId, fullKey }; + } + if (parts[2] === 'birthdays') { + if (parts[3] === 'left') { + return { type: 'birthday_left', guildId, fullKey }; + } + if (parts[3] === 'tracking') { + return { type: 'birthday_tracking', guildId, fullKey }; + } + return { type: 'guild_birthdays', guildId, fullKey }; + } + if (parts[2] === 'giveaways') { + return { type: 'guild_giveaways', guildId, fullKey }; + } + if (parts[2] === 'welcome') { + return { type: 'welcome_config', guildId, fullKey }; + } + if (parts[2] === 'leveling') { + if (parts[3] === 'config') { + return { type: 'leveling_config', guildId, fullKey }; + } + if (parts[3] === 'users' && parts[4]) { + return { type: 'user_level', guildId, userId: parts[4], fullKey }; + } + return { type: 'leveling_data', guildId, fullKey }; + } + if (parts[2] === 'economy' && parts[3]) { + return { type: 'economy', guildId, userId: parts[3], fullKey }; + } + if (parts[2] === 'afk' && parts[3]) { + return { type: 'afk_status', guildId, userId: parts[3], fullKey }; + } + if (parts[2] === 'ticket') { + if (parts[3] === 'counter') { + return { type: 'ticket_counter', guildId, fullKey }; + } + if (parts[3]) { + return { type: 'ticket', guildId, channelId: parts[3], fullKey }; + } + } + if (parts[2] === 'warnings' && parts[3]) { + return { type: 'warnings', guildId, userId: parts[3], fullKey }; + } + if (parts[2] === 'usernotes') { + if (parts[3] === 'list') { + return { type: 'usernotes_list', guildId, fullKey }; + } + if (parts[3]) { + return { type: 'usernotes', guildId, userId: parts[3], fullKey }; + } + } + if (parts[2] === 'reaction_roles' && parts[3]) { + return { type: 'reaction_role', guildId, messageId: parts[3], fullKey }; + } + if (parts[2] === 'counters' && !parts[3]) { + return { type: 'counters', guildId, fullKey }; + } + if (parts[2] === 'applications') { + if (parts[3] === 'roles') { + return { type: 'application_roles', guildId, fullKey }; + } + if (parts[3] === 'settings') { + return { type: 'application_settings', guildId, fullKey }; + } + if (parts[3] === 'users' && parts[4]) { + return { type: 'application_users', guildId, userId: parts[4], fullKey }; + } + if (parts[3] && parts[3] !== 'role') { + return { type: 'application', guildId, applicationId: parts[3], fullKey }; + } + } + if (parts[2] === 'jointocreate') { + if (parts[3] === 'channels') { + return { type: 'jointocreate_channels', guildId, fullKey }; + } + return { type: 'jointocreate_config', guildId, fullKey }; + } + if (parts[2] === 'invite_uses' && parts[3]) { + return { type: 'invite_uses', guildId, inviteCode: parts[3], fullKey }; + } + if (parts[2] === 'invites') { + if (parts[3]) { + return { type: 'invite_member', guildId, userId: parts[3], fullKey }; + } + return { type: 'invite_tracking', guildId, fullKey }; + } + if (parts[2] === 'fake_account' && parts[3]) { + return { type: 'fake_account', guildId, userId: parts[3], fullKey }; + } + } + + if (parts[0] === 'giveaway') { + if (parts[1] === 'lock' && parts[2]) { + return { type: 'giveaway_lock', messageId: parts[2], fullKey }; + } + if (parts[1] && parts[2]) { + return { type: 'giveaway_entry', userId: parts[1], giveawayId: parts[2], fullKey }; + } + } + + return { type: 'temp', fullKey }; +} + +/** + * Build PostgreSQL list queries for structured tables based on a key prefix. + * Returns { queries: [{ sql, params, mapKey }], staticKeys: string[] } + */ +export function getStructuredListPlan(prefix, tables) { + const plan = { queries: [], staticKeys: [] }; + const canonicalPrefix = canonicalizeKey(prefix.endsWith(':') ? prefix.slice(0, -1) : prefix); + const normalizedPrefix = prefix.endsWith(':') ? prefix : `${prefix}:`; + + const addEconomy = (guildId) => { + plan.queries.push({ + sql: `SELECT user_id FROM ${tables.economy} WHERE guild_id = $1`, + params: [guildId], + mapKey: (row) => `guild:${guildId}:economy:${row.user_id}`, + }); + }; + + const addUserLevels = (guildId) => { + plan.queries.push({ + sql: `SELECT user_id FROM ${tables.user_levels} WHERE guild_id = $1`, + params: [guildId], + mapKey: (row) => `guild:${guildId}:leveling:users:${row.user_id}`, + }); + }; + + const addTickets = (guildId) => { + plan.queries.push({ + sql: `SELECT channel_id FROM ${tables.tickets} WHERE guild_id = $1`, + params: [guildId], + mapKey: (row) => `guild:${guildId}:ticket:${row.channel_id}`, + }); + plan.staticKeys.push(getTicketCounterKey(guildId)); + }; + + const addGuildScopedSingletons = (guildId) => { + plan.staticKeys.push( + getGuildConfigKey(guildId), + getGuildBirthdaysKey(guildId), + getWelcomeConfigKey(guildId), + getLevelingKey(guildId), + getServerCountersKey(guildId), + `guild:${guildId}:applications:roles`, + `guild:${guildId}:applications:settings`, + `guild:${guildId}:jointocreate`, + `guild:${guildId}:jointocreate:channels`, + `guild:${guildId}:invites`, + `guild:${guildId}:usernotes:list`, + `guild:${guildId}:birthdays:left`, + `guild:${guildId}:birthdays:tracking`, + ); + addEconomy(guildId); + addUserLevels(guildId); + addTickets(guildId); + }; + + let match = normalizedPrefix.match(/^economy:([^:]+):$/); + if (match) { + addEconomy(match[1]); + return plan; + } + + match = normalizedPrefix.match(/^guild:([^:]+):economy:$/); + if (match) { + addEconomy(match[1]); + return plan; + } + + match = normalizedPrefix.match(/^([^:]+):leveling:users:$/); + if (match && match[1] !== 'guild') { + addUserLevels(match[1]); + return plan; + } + + match = normalizedPrefix.match(/^guild:([^:]+):leveling:users:$/); + if (match) { + addUserLevels(match[1]); + return plan; + } + + match = normalizedPrefix.match(/^guild:([^:]+):ticket:$/); + if (match) { + addTickets(match[1]); + return plan; + } + + match = normalizedPrefix.match(/^moderation:warnings:([^:]+):$/); + if (match) { + plan.tempPrefixes = [normalizedPrefix, getWarningsPrefix(match[1])]; + return plan; + } + + match = normalizedPrefix.match(/^guild:([^:]+):warnings:$/); + if (match) { + plan.tempPrefixes = [getWarningsPrefix(match[1]), `moderation:warnings:${match[1]}:`]; + return plan; + } + + match = normalizedPrefix.match(/^reaction_roles:([^:]+):$/); + if (match) { + plan.tempPrefixes = [normalizedPrefix, getReactionRolesPrefix(match[1])]; + return plan; + } + + match = normalizedPrefix.match(/^guild:([^:]+):reaction_roles:$/); + if (match) { + plan.tempPrefixes = [getReactionRolesPrefix(match[1]), `reaction_roles:${match[1]}:`]; + return plan; + } + + match = normalizedPrefix.match(/^guild:([^:]+):applications:$/); + if (match) { + plan.tempPrefixes = [getApplicationsPrefix(match[1])]; + return plan; + } + + match = normalizedPrefix.match(/^guild:([^:]+):$/); + if (match) { + addGuildScopedSingletons(match[1]); + plan.tempPrefixes = [ + getApplicationsPrefix(match[1]), + getWarningsPrefix(match[1]), + `moderation:warnings:${match[1]}:`, + getReactionRolesPrefix(match[1]), + `reaction_roles:${match[1]}:`, + getEconomyPrefix(match[1]), + `economy:${match[1]}:`, + getUserLevelPrefix(match[1]), + `${match[1]}:leveling:users:`, + ]; + return plan; + } + + return plan; +} diff --git a/src/utils/database/keys.js b/src/utils/database/keys.js index 1bb078c1cc..139922dee8 100644 --- a/src/utils/database/keys.js +++ b/src/utils/database/keys.js @@ -1,5 +1,12 @@ +/** + * Canonical database key registry. + * All storage keys should be built through these helpers. + */ + export const getGuildConfigKey = (guildId) => `guild:${guildId}:config`; export const getGuildBirthdaysKey = (guildId) => `guild:${guildId}:birthdays`; +export const getBirthdayLeftBackupKey = (guildId) => `guild:${guildId}:birthdays:left`; +export const getBirthdayTrackingKey = (guildId) => `guild:${guildId}:birthdays:tracking`; export function getTicketKey(guildId, channelId) { return `guild:${guildId}:ticket:${channelId}`; @@ -29,6 +36,10 @@ export function getEconomyKey(guildId, userId) { return `guild:${guildId}:economy:${userId}`; } +export function getEconomyPrefix(guildId) { + return `guild:${guildId}:economy:`; +} + export function getAFKKey(guildId, userId) { return `guild:${guildId}:afk:${userId}`; } @@ -45,6 +56,10 @@ export function getUserLevelKey(guildId, userId) { return `guild:${guildId}:leveling:users:${userId}`; } +export function getUserLevelPrefix(guildId) { + return `guild:${guildId}:leveling:users:`; +} + export function getApplicationRolesKey(guildId) { return `guild:${guildId}:applications:roles`; } @@ -61,6 +76,10 @@ export function getApplicationKey(guildId, applicationId) { return `guild:${guildId}:applications:${applicationId}`; } +export function getApplicationsPrefix(guildId) { + return `guild:${guildId}:applications:`; +} + export function getJoinToCreateConfigKey(guildId) { return `guild:${guildId}:jointocreate`; } @@ -68,3 +87,166 @@ export function getJoinToCreateConfigKey(guildId) { export function getJoinToCreateChannelsKey(guildId) { return `guild:${guildId}:jointocreate:channels`; } + +export function getWarningsKey(guildId, userId) { + return `guild:${guildId}:warnings:${userId}`; +} + +export function getWarningsPrefix(guildId) { + return `guild:${guildId}:warnings:`; +} + +export function getUserNotesKey(guildId, userId) { + return `guild:${guildId}:usernotes:${userId}`; +} + +export function getUserNotesListKey(guildId) { + return `guild:${guildId}:usernotes:list`; +} + +export function getReactionRoleKey(guildId, messageId) { + return `guild:${guildId}:reaction_roles:${messageId}`; +} + +export function getReactionRolesPrefix(guildId) { + return `guild:${guildId}:reaction_roles:`; +} + +export function getServerCountersKey(guildId) { + return `guild:${guildId}:counters`; +} + +export function getGiveawayEntryKey(userId, giveawayId) { + return `giveaway:${userId}:${giveawayId}`; +} + +export function getGiveawayLockKey(messageId) { + return `giveaway:lock:${messageId}`; +} + +/** + * Legacy key patterns mapped to canonical builders. + * Used by migration script and read-time fallback. + */ +export const LEGACY_KEY_RESOLVERS = [ + { + pattern: /^economy:([^:]+):([^:]+)$/, + toCanonical: ([, guildId, userId]) => getEconomyKey(guildId, userId), + }, + { + pattern: /^birthdays:([^:]+)$/, + toCanonical: ([, guildId]) => getGuildBirthdaysKey(guildId), + }, + { + pattern: /^([^:]+):leveling:users:([^:]+)$/, + toCanonical: ([, guildId, userId]) => getUserLevelKey(guildId, userId), + skipIf: (guildId) => guildId === 'guild', + }, + { + pattern: /^moderation:warnings:([^:]+):([^:]+)$/, + toCanonical: ([, guildId, userId]) => getWarningsKey(guildId, userId), + }, + { + pattern: /^moderation_user_notes_([^_]+)_([^_]+)$/, + toCanonical: ([, guildId, userId]) => getUserNotesKey(guildId, userId), + }, + { + pattern: /^moderation_user_notes_list_([^_]+)$/, + toCanonical: ([, guildId]) => getUserNotesListKey(guildId), + }, + { + pattern: /^reaction_roles:([^:]+):([^:]+)$/, + toCanonical: ([, guildId, messageId]) => getReactionRoleKey(guildId, messageId), + }, + { + pattern: /^counters:([^:]+)$/, + toCanonical: ([, guildId]) => getServerCountersKey(guildId), + }, + { + pattern: /^bday-role-tracking-([^:]+)$/, + toCanonical: ([, guildId]) => getBirthdayTrackingKey(guildId), + }, +]; + +/** + * Returns the canonical key for a legacy or already-canonical key. + */ +export function canonicalizeKey(key) { + if (typeof key !== 'string' || !key) { + return key; + } + + for (const { pattern, toCanonical, skipIf } of LEGACY_KEY_RESOLVERS) { + const match = key.match(pattern); + if (!match) continue; + if (skipIf?.(match[1])) continue; + return toCanonical(match); + } + + return key; +} + +/** + * Returns legacy key variants that may still hold data for a canonical key. + */ +export function getLegacyVariantsForCanonical(canonicalKey) { + const variants = []; + + for (const { pattern, toCanonical } of LEGACY_KEY_RESOLVERS) { + const sample = canonicalKey; + const match = sample.match(/^guild:([^:]+):economy:([^:]+)$/); + if (match && toCanonical(['', match[1], match[2]]) === canonicalKey) { + variants.push(`economy:${match[1]}:${match[2]}`); + continue; + } + + const birthdaysMatch = sample.match(/^guild:([^:]+):birthdays$/); + if (birthdaysMatch && toCanonical(['', birthdaysMatch[1]]) === canonicalKey) { + variants.push(`birthdays:${birthdaysMatch[1]}`); + continue; + } + + const levelMatch = sample.match(/^guild:([^:]+):leveling:users:([^:]+)$/); + if (levelMatch && toCanonical(['', levelMatch[1], levelMatch[2]]) === canonicalKey) { + variants.push(`${levelMatch[1]}:leveling:users:${levelMatch[2]}`); + continue; + } + + const warningsMatch = sample.match(/^guild:([^:]+):warnings:([^:]+)$/); + if (warningsMatch && toCanonical(['', warningsMatch[1], warningsMatch[2]]) === canonicalKey) { + variants.push(`moderation:warnings:${warningsMatch[1]}:${warningsMatch[2]}`); + continue; + } + + const notesMatch = sample.match(/^guild:([^:]+):usernotes:([^:]+)$/); + if (notesMatch && toCanonical(['', notesMatch[1], notesMatch[2]]) === canonicalKey) { + variants.push(`moderation_user_notes_${notesMatch[1]}_${notesMatch[2]}`); + continue; + } + + const notesListMatch = sample.match(/^guild:([^:]+):usernotes:list$/); + if (notesListMatch && toCanonical(['', notesListMatch[1]]) === canonicalKey) { + variants.push(`moderation_user_notes_list_${notesListMatch[1]}`); + continue; + } + + const reactionMatch = sample.match(/^guild:([^:]+):reaction_roles:([^:]+)$/); + if (reactionMatch && toCanonical(['', reactionMatch[1], reactionMatch[2]]) === canonicalKey) { + variants.push(`reaction_roles:${reactionMatch[1]}:${reactionMatch[2]}`); + continue; + } + + const countersMatch = sample.match(/^guild:([^:]+):counters$/); + if (countersMatch && toCanonical(['', countersMatch[1]]) === canonicalKey) { + variants.push(`counters:${countersMatch[1]}`); + continue; + } + + const trackingMatch = sample.match(/^guild:([^:]+):birthdays:tracking$/); + if (trackingMatch && toCanonical(['', trackingMatch[1]]) === canonicalKey) { + variants.push(`bday-role-tracking-${trackingMatch[1]}`); + } + } + + return variants; +} diff --git a/src/utils/database/schema.js b/src/utils/database/schema.js new file mode 100644 index 0000000000..4c7d2e6594 --- /dev/null +++ b/src/utils/database/schema.js @@ -0,0 +1,227 @@ +/** + * Single source of truth for the PostgreSQL schema. + * + * Both the runtime auto-create path (src/utils/postgresDatabase.js) and the + * standalone migration script (scripts/migrate.js) build the database from these + * definitions, so the schema can never diverge between them. + */ + +import { pgConfig } from '../../config/database/postgres.js'; + +const t = pgConfig.tables; + +export const tableStatements = [ + `CREATE TABLE IF NOT EXISTS ${t.guilds} ( + id VARCHAR(20) PRIMARY KEY, + config JSONB DEFAULT '{}', + counters JSONB DEFAULT '[]', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )`, + + `CREATE TABLE IF NOT EXISTS ${t.users} ( + id VARCHAR(20) PRIMARY KEY, + username VARCHAR(100), + discriminator VARCHAR(10), + avatar VARCHAR(100), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )`, + + `CREATE TABLE IF NOT EXISTS ${t.guild_users} ( + guild_id VARCHAR(20), + user_id VARCHAR(20), + joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (guild_id, user_id), + FOREIGN KEY (guild_id) REFERENCES ${t.guilds}(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES ${t.users}(id) ON DELETE CASCADE + )`, + + `CREATE TABLE IF NOT EXISTS ${t.birthdays} ( + guild_id VARCHAR(20), + user_id VARCHAR(20), + month INTEGER NOT NULL, + day INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (guild_id, user_id), + FOREIGN KEY (guild_id) REFERENCES ${t.guilds}(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES ${t.users}(id) ON DELETE CASCADE + )`, + + `CREATE TABLE IF NOT EXISTS ${t.giveaways} ( + id SERIAL PRIMARY KEY, + guild_id VARCHAR(20), + message_id VARCHAR(20) NOT NULL, + data JSONB NOT NULL, + ends_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (guild_id) REFERENCES ${t.guilds}(id) ON DELETE CASCADE, + UNIQUE(guild_id, message_id) + )`, + + `CREATE TABLE IF NOT EXISTS ${t.tickets} ( + guild_id VARCHAR(20), + channel_id VARCHAR(20) PRIMARY KEY, + data JSONB NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP, + FOREIGN KEY (guild_id) REFERENCES ${t.guilds}(id) ON DELETE CASCADE + )`, + + `CREATE TABLE IF NOT EXISTS ${t.afk_status} ( + guild_id VARCHAR(20), + user_id VARCHAR(20), + reason TEXT, + status_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP, + PRIMARY KEY (guild_id, user_id), + FOREIGN KEY (guild_id) REFERENCES ${t.guilds}(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES ${t.users}(id) ON DELETE CASCADE + )`, + + `CREATE TABLE IF NOT EXISTS ${t.welcome_configs} ( + guild_id VARCHAR(20) PRIMARY KEY, + config JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (guild_id) REFERENCES ${t.guilds}(id) ON DELETE CASCADE + )`, + + `CREATE TABLE IF NOT EXISTS ${t.leveling_configs} ( + guild_id VARCHAR(20) PRIMARY KEY, + config JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (guild_id) REFERENCES ${t.guilds}(id) ON DELETE CASCADE + )`, + + `CREATE TABLE IF NOT EXISTS ${t.user_levels} ( + guild_id VARCHAR(20), + user_id VARCHAR(20), + xp BIGINT DEFAULT 0, + level INTEGER DEFAULT 0, + total_xp BIGINT DEFAULT 0, + last_message TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + rank INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (guild_id, user_id), + FOREIGN KEY (guild_id) REFERENCES ${t.guilds}(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES ${t.users}(id) ON DELETE CASCADE + )`, + + `CREATE TABLE IF NOT EXISTS ${t.economy} ( + guild_id VARCHAR(20), + user_id VARCHAR(20), + balance BIGINT DEFAULT 0, + bank BIGINT DEFAULT 0, + data JSONB DEFAULT '{}', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (guild_id, user_id), + FOREIGN KEY (guild_id) REFERENCES ${t.guilds}(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES ${t.users}(id) ON DELETE CASCADE + )`, + + `CREATE TABLE IF NOT EXISTS ${t.verification_audit} ( + id SERIAL PRIMARY KEY, + guild_id VARCHAR(20) NOT NULL, + user_id VARCHAR(20) NOT NULL, + action VARCHAR(50) NOT NULL, + source VARCHAR(50), + moderator_id VARCHAR(20), + metadata JSONB DEFAULT '{}', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )`, + + `CREATE TABLE IF NOT EXISTS ${t.invite_tracking} ( + guild_id VARCHAR(20), + inviter_id VARCHAR(20), + invite_code VARCHAR(20), + uses INTEGER DEFAULT 0, + data JSONB DEFAULT '{}', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (guild_id, invite_code), + FOREIGN KEY (guild_id) REFERENCES ${t.guilds}(id) ON DELETE CASCADE + )`, + + `CREATE TABLE IF NOT EXISTS ${t.application_roles} ( + guild_id VARCHAR(20), + role_id VARCHAR(20), + data JSONB DEFAULT '{}', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (guild_id, role_id), + FOREIGN KEY (guild_id) REFERENCES ${t.guilds}(id) ON DELETE CASCADE + )`, + + `CREATE TABLE IF NOT EXISTS ${t.temp_data} ( + key VARCHAR(255) PRIMARY KEY, + value JSONB NOT NULL, + expires_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )`, + + `CREATE TABLE IF NOT EXISTS ${t.cache_data} ( + key VARCHAR(255) PRIMARY KEY, + value JSONB NOT NULL, + expires_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )`, +]; + +export const indexStatements = [ + `CREATE INDEX IF NOT EXISTS idx_guild_users_guild_id ON ${t.guild_users}(guild_id)`, + `CREATE INDEX IF NOT EXISTS idx_guild_users_user_id ON ${t.guild_users}(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_birthdays_guild_id ON ${t.birthdays}(guild_id)`, + `CREATE INDEX IF NOT EXISTS idx_birthdays_month_day ON ${t.birthdays}(month, day)`, + `CREATE INDEX IF NOT EXISTS idx_giveaways_guild_id ON ${t.giveaways}(guild_id)`, + `CREATE INDEX IF NOT EXISTS idx_giveaways_ends_at ON ${t.giveaways}(ends_at)`, + `CREATE INDEX IF NOT EXISTS idx_tickets_guild_id ON ${t.tickets}(guild_id)`, + `CREATE INDEX IF NOT EXISTS idx_tickets_expires_at ON ${t.tickets}(expires_at)`, + `CREATE INDEX IF NOT EXISTS idx_afk_status_guild_id ON ${t.afk_status}(guild_id)`, + `CREATE INDEX IF NOT EXISTS idx_afk_status_expires_at ON ${t.afk_status}(expires_at)`, + `CREATE INDEX IF NOT EXISTS idx_user_levels_guild_id ON ${t.user_levels}(guild_id)`, + `CREATE INDEX IF NOT EXISTS idx_user_levels_xp ON ${t.user_levels}(xp)`, + `CREATE INDEX IF NOT EXISTS idx_economy_guild_id ON ${t.economy}(guild_id)`, + `CREATE INDEX IF NOT EXISTS idx_verification_audit_guild_id ON ${t.verification_audit}(guild_id)`, + `CREATE INDEX IF NOT EXISTS idx_verification_audit_user_id ON ${t.verification_audit}(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_verification_audit_created_at ON ${t.verification_audit}(created_at)`, + `CREATE INDEX IF NOT EXISTS idx_temp_data_expires_at ON ${t.temp_data}(expires_at)`, + `CREATE INDEX IF NOT EXISTS idx_cache_data_expires_at ON ${t.cache_data}(expires_at)`, +]; + +export const UPDATE_TIMESTAMP_FUNCTION = ` + CREATE OR REPLACE FUNCTION update_updated_at_column() + RETURNS TRIGGER AS $$ + BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; + END; + $$ language 'plpgsql'; +`; + +/** + * Tables that carry an updated_at column maintained by the shared trigger. + * `name` is the trigger identifier, `table` is the concrete table name. + */ +export const triggerDefinitions = [ + { name: 'update_guilds_updated_at', table: t.guilds }, + { name: 'update_users_updated_at', table: t.users }, + { name: 'update_welcome_configs_updated_at', table: t.welcome_configs }, + { name: 'update_leveling_configs_updated_at', table: t.leveling_configs }, + { name: 'update_user_levels_updated_at', table: t.user_levels }, + { name: 'update_economy_updated_at', table: t.economy }, + { name: 'update_application_roles_updated_at', table: t.application_roles }, + { name: 'update_invite_tracking_updated_at', table: t.invite_tracking }, + { name: 'update_guild_users_updated_at', table: t.guild_users }, + { name: 'update_birthdays_updated_at', table: t.birthdays }, + { name: 'update_giveaways_updated_at', table: t.giveaways }, + { name: 'update_tickets_updated_at', table: t.tickets }, + { name: 'update_afk_status_updated_at', table: t.afk_status }, +]; diff --git a/src/utils/database/tickets.js b/src/utils/database/tickets.js index d2255c80da..58384f57be 100644 --- a/src/utils/database/tickets.js +++ b/src/utils/database/tickets.js @@ -20,7 +20,7 @@ export async function getOpenTicketCountForUser(guildId, userId) { } if (db.db?.pool && typeof db.db.isAvailable === 'function' && db.db.isAvailable()) { - const { pgConfig } = await import('../../config/postgres.js'); + const { pgConfig } = await import('../../config/database/postgres.js'); const result = await db.db.pool.query( `SELECT COUNT(*)::int AS count FROM ${pgConfig.tables.tickets} WHERE guild_id = $1 @@ -102,7 +102,7 @@ async function listGuildTickets(guildId) { } if (db.db?.pool && typeof db.db.isAvailable === 'function' && db.db.isAvailable()) { - const { pgConfig } = await import('../../config/postgres.js'); + const { pgConfig } = await import('../../config/database/postgres.js'); const result = await db.db.pool.query( `SELECT data FROM ${pgConfig.tables.tickets} WHERE guild_id = $1`, [guildId], diff --git a/src/utils/economy.js b/src/utils/economy.js index be54340e1b..c50e9ab1f3 100644 --- a/src/utils/economy.js +++ b/src/utils/economy.js @@ -1,11 +1,12 @@ // economy.js -import { getColor } from './database.js'; +import { getColor, getEconomyKey as getEconomyStorageKey } from './database.js'; import { BotConfig } from '../config/bot.js'; import { normalizeEconomyData } from './schemas.js'; import { logger } from './logger.js'; import { validateDiscordId, validateNumber } from './validation.js'; import { DEFAULT_ECONOMY_DATA } from './constants.js'; +import { createError, ErrorTypes, wrapServiceBoundary } from './errorHandler.js'; const ECONOMY_CONFIG = BotConfig.economy || {}; const BASE_BANK_CAPACITY = ECONOMY_CONFIG.baseBankCapacity || 10000; @@ -28,7 +29,7 @@ export function getEconomyKey(guildId, userId) { throw new Error('Invalid guild ID or user ID'); } - return `economy:${validGuildId}:${validUserId}`; + return getEconomyStorageKey(validGuildId, validUserId); } export function getMaxBankCapacity(userData) { @@ -51,7 +52,8 @@ export function getMaxBankCapacity(userData) { } export function formatCurrency(amount) { - return `${amount.toLocaleString()} ${ECONOMY_CONFIG.currency || 'coins'}`; + const currencyName = ECONOMY_CONFIG.currency?.name || 'coins'; + return `${amount.toLocaleString()} ${currencyName}`; } export async function getEconomyData(client, guildId, userId) { @@ -62,8 +64,12 @@ export async function getEconomyData(client, guildId, userId) { const key = getEconomyKey(guildId, userId); const data = await client.db.get(key, {}); + const defaults = { + ...DEFAULT_ECONOMY_DATA, + wallet: ECONOMY_CONFIG.startingBalance ?? DEFAULT_ECONOMY_DATA.wallet, + }; - return normalizeEconomyData(data, DEFAULT_ECONOMY_DATA); + return normalizeEconomyData(data, defaults); } catch (error) { logger.error(`Error getting economy data for user ${userId}`, error); return normalizeEconomyData({}, DEFAULT_ECONOMY_DATA); @@ -239,97 +245,109 @@ export function formatShopItem(item, index) { return `**${index + 1}.** ${item.emoji} **${item.name}** - ${formatCurrency(item.price)}\n${item.description}\n`; } -export async function addMoney(client, guildId, userId, amount, type = 'wallet') { - try { - - const validAmount = validateNumber(amount, 'amount'); - if (validAmount === null || validAmount <= 0) { - return { success: false, error: 'Amount must be a positive number' }; - } +export const addMoney = wrapServiceBoundary(async function addMoney(client, guildId, userId, amount, type = 'wallet') { + const validAmount = validateNumber(amount, 'amount'); + if (validAmount === null || validAmount <= 0) { + throw createError( + 'Invalid amount', + ErrorTypes.VALIDATION, + 'Amount must be a positive number.', + { guildId, userId, amount, operation: 'addMoney' } + ); + } - if (type !== 'wallet' && type !== 'bank') { - logger.warn('[VALIDATION] Invalid money type:', { type }); - return { success: false, error: 'Type must be "wallet" or "bank"' }; - } + if (type !== 'wallet' && type !== 'bank') { + throw createError( + 'Invalid money type', + ErrorTypes.VALIDATION, + 'Type must be "wallet" or "bank".', + { guildId, userId, type, operation: 'addMoney' } + ); + } - const userData = await getEconomyData(client, guildId, userId); - - if (type === 'bank') { - const maxBank = getMaxBankCapacity(userData); - if ((userData.bank || 0) + validAmount > maxBank) { - return { - success: false, - error: 'Bank capacity exceeded', - current: userData.bank || 0, - max: maxBank - }; - } - userData.bank = (userData.bank || 0) + validAmount; - } else { - userData.wallet = (userData.wallet || 0) + validAmount; + const userData = await getEconomyData(client, guildId, userId); + + if (type === 'bank') { + const maxBank = getMaxBankCapacity(userData); + if ((userData.bank || 0) + validAmount > maxBank) { + throw createError( + 'Bank capacity exceeded', + ErrorTypes.VALIDATION, + `Bank capacity exceeded. Current: ${userData.bank || 0}, Max: ${maxBank}.`, + { guildId, userId, current: userData.bank || 0, max: maxBank, operation: 'addMoney' } + ); } + userData.bank = (userData.bank || 0) + validAmount; + } else { + userData.wallet = (userData.wallet || 0) + validAmount; + } - await setEconomyData(client, guildId, userId, userData); - - return { - success: true, - newBalance: type === 'bank' ? userData.bank : userData.wallet, - ...(type === 'bank' ? { maxBank: getMaxBankCapacity(userData) } : {}) - }; - } catch (error) { - logger.error(`Error adding money to ${type} for user ${userId} in guild ${guildId}`, error); - return { success: false, error: 'An error occurred while processing your request' }; + await setEconomyData(client, guildId, userId, userData); + + return { + newBalance: type === 'bank' ? userData.bank : userData.wallet, + ...(type === 'bank' ? { maxBank: getMaxBankCapacity(userData) } : {}), + }; +}, { + service: 'economy', + operation: 'addMoney', + userMessage: 'Failed to add money. Please try again.', +}); + +export const removeMoney = wrapServiceBoundary(async function removeMoney(client, guildId, userId, amount, type = 'wallet') { + const validAmount = validateNumber(amount, 'amount'); + if (validAmount === null || validAmount <= 0) { + throw createError( + 'Invalid amount', + ErrorTypes.VALIDATION, + 'Amount must be a positive number.', + { guildId, userId, amount, operation: 'removeMoney' } + ); } -} -export async function removeMoney(client, guildId, userId, amount, type = 'wallet') { - try { - - const validAmount = validateNumber(amount, 'amount'); - if (validAmount === null || validAmount <= 0) { - return { success: false, error: 'Amount must be a positive number' }; - } + if (type !== 'wallet' && type !== 'bank') { + throw createError( + 'Invalid money type', + ErrorTypes.VALIDATION, + 'Type must be "wallet" or "bank".', + { guildId, userId, type, operation: 'removeMoney' } + ); + } - if (type !== 'wallet' && type !== 'bank') { - logger.warn('[VALIDATION] Invalid money type:', { type }); - return { success: false, error: 'Type must be "wallet" or "bank"' }; - } + const userData = await getEconomyData(client, guildId, userId); - const userData = await getEconomyData(client, guildId, userId); - - if (type === 'bank') { - if ((userData.bank || 0) < validAmount) { - return { - success: false, - error: 'Insufficient funds in bank', - current: userData.bank || 0, - required: validAmount - }; - } - userData.bank = (userData.bank || 0) - validAmount; - } else { - if ((userData.wallet || 0) < validAmount) { - return { - success: false, - error: 'Insufficient funds in wallet', - current: userData.wallet || 0, - required: validAmount - }; - } - userData.wallet = (userData.wallet || 0) - validAmount; + if (type === 'bank') { + if ((userData.bank || 0) < validAmount) { + throw createError( + 'Insufficient bank funds', + ErrorTypes.VALIDATION, + `Insufficient funds in bank. You have ${userData.bank || 0}, need ${validAmount}.`, + { guildId, userId, current: userData.bank || 0, required: validAmount, operation: 'removeMoney' } + ); } - - await setEconomyData(client, guildId, userId, userData); - - return { - success: true, - newBalance: type === 'bank' ? userData.bank : userData.wallet - }; - } catch (error) { - logger.error(`Error removing money from ${type} for user ${userId} in guild ${guildId}`, error); - return { success: false, error: 'An error occurred while processing your request' }; + userData.bank = (userData.bank || 0) - validAmount; + } else { + if ((userData.wallet || 0) < validAmount) { + throw createError( + 'Insufficient wallet funds', + ErrorTypes.VALIDATION, + `Insufficient funds in wallet. You have ${userData.wallet || 0}, need ${validAmount}.`, + { guildId, userId, current: userData.wallet || 0, required: validAmount, operation: 'removeMoney' } + ); + } + userData.wallet = (userData.wallet || 0) - validAmount; } -} + + await setEconomyData(client, guildId, userId, userData); + + return { + newBalance: type === 'bank' ? userData.bank : userData.wallet, + }; +}, { + service: 'economy', + operation: 'removeMoney', + userMessage: 'Failed to remove money. Please try again.', +}); export function getShopInventory() { return [ diff --git a/src/utils/embeds.js b/src/utils/embeds.js index fa21f863f5..67b40ad2ab 100644 --- a/src/utils/embeds.js +++ b/src/utils/embeds.js @@ -1,7 +1,7 @@ // embeds.js import { EmbedBuilder } from 'discord.js'; -import { getColor } from '../config/bot.js'; +import { getColor, botConfig } from '../config/bot.js'; const EMOJI_REGEX = /[\p{Extended_Pictographic}\uFE0F]/gu; const EMBED_FOOTER_SYMBOL = Symbol('titanbotFooterText'); @@ -157,6 +157,12 @@ export function createEmbed({ } catch (error) { } + } else if (botConfig.embeds?.author?.name) { + embed.setAuthor({ + name: botConfig.embeds.author.name, + ...(botConfig.embeds.author.icon ? { iconURL: botConfig.embeds.author.icon } : {}), + ...(botConfig.embeds.author.url ? { url: botConfig.embeds.author.url } : {}), + }); } if (footer) { @@ -169,6 +175,12 @@ export function createEmbed({ } catch (error) { } + } else if (botConfig.embeds?.footer?.text) { + const defaultFooter = { + text: botConfig.embeds.footer.text, + ...(botConfig.embeds.footer.icon ? { iconURL: botConfig.embeds.footer.icon } : {}), + }; + embed.setFooter(defaultFooter); } if (thumbnail) { @@ -181,6 +193,8 @@ export function createEmbed({ } catch (error) { } + } else if (botConfig.embeds?.thumbnail) { + embed.setThumbnail(botConfig.embeds.thumbnail); } if (image) { diff --git a/src/utils/errorHandler.js b/src/utils/errorHandler.js index bd9b77b3e7..d7e7c5c1e8 100644 --- a/src/utils/errorHandler.js +++ b/src/utils/errorHandler.js @@ -1,11 +1,20 @@ -// errorHandler.js +// errorHandler.js — the single entry point for all error handling. // -// User-facing error rules: -// 1. Use replyUserError or handleInteractionError — not raw errorEmbed in commands/handlers -// 2. Set a specific userMessage when you know the cause -// 3. Use ErrorTypes — don't invent custom title strings -// 4. Put guidance in the description, not embed tip fields -// 5. Success/info/warning replies use successEmbed / infoEmbed / warningEmbed +// Rules: +// 1. Commands/handlers: throw TitanBotError (via createError) or let errors propagate; +// interactionCreate routes them through handleInteractionError. For expected user-facing +// failures (validation, cooldowns), use replyUserError. +// Do NOT wrap a command's execute() body in a try/catch whose only purpose is to call +// handleInteractionError — that is redundant because interactionCreate already catches +// command.execute errors and calls handleInteractionError with COMMAND_ERROR_SUBTYPES. +// Only keep a local try/catch when the catch does something more (custom recovery, +// typed re-throw, status-code branching) or when it lives in a standalone handler +// (collector callbacks, modal/component handlers) not reached via the command path. +// 2. Services: throw, never return { success: false }. Wrap exports with wrapServiceBoundary +// (re-exported here) so unknown errors get typed with service/operation context. +// 3. Background tasks (cron, timers): wrap with handleTaskError / runSafeTask. +// 4. Set a specific userMessage when you know the cause; use ErrorTypes, don't invent titles. +// 5. Success/info/warning replies use successEmbed / infoEmbed / warningEmbed. import { logger } from './logger.js'; import { buildUserErrorEmbed } from './embeds.js'; @@ -13,6 +22,10 @@ import { MessageFlags } from 'discord.js'; import { getErrorMetadata, getDefaultErrorCodeByType, resolveErrorCode, ErrorCodes } from './errorRegistry.js'; import { InteractionHelper } from './interactionHelper.js'; +// Re-export so consumers only ever need to import from errorHandler.js +export { ErrorCodes, getErrorMetadata, resolveErrorCode, getDefaultErrorCodeByType } from './errorRegistry.js'; +export { ensureTypedServiceError, wrapServiceBoundary, wrapServiceClassMethods } from './serviceErrorBoundary.js'; + export const ErrorTypes = { VALIDATION: 'validation', PERMISSION: 'permission', @@ -37,42 +50,68 @@ export class TitanBotError extends Error { } } +// Discord API error codes that indicate a permission problem rather than a bug. +const DISCORD_PERMISSION_CODES = new Set([ + 50001, // Missing Access + 50013, // Missing Permissions + 50007, // Cannot send messages to this user (DMs closed) + 160002, // Cannot reply without permission to read message history +]); + +// PostgreSQL / node-postgres error codes and errno values that indicate database trouble. +const DATABASE_ERROR_CODES = new Set([ + 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', + '57014', // query_canceled (statement timeout) + '53300', // too_many_connections + '08006', '08001', '08003', // connection failures + '40001', '40P01', // serialization failure / deadlock +]); + export function categorizeError(error) { if (error instanceof TitanBotError) { return error.type; } - const message = error.message?.toLowerCase() || ''; - const code = error.code; + const message = error?.message?.toLowerCase() || ''; + const code = error?.code; - if (code >= 10000 && code < 20000) { - return ErrorTypes.DISCORD_API; + if (typeof code === 'string' && DATABASE_ERROR_CODES.has(code)) { + return ErrorTypes.DATABASE; } - if (message.includes('rate limit') || code === 50001) { + if (message.includes('rate limit') || code === 429) { return ErrorTypes.RATE_LIMIT; } - if (message.includes('permission') || message.includes('missing') || code === 50013) { + if (DISCORD_PERMISSION_CODES.has(code)) { return ErrorTypes.PERMISSION; } - if (message.includes('database') || message.includes('connection') || message.includes('timeout')) { - return ErrorTypes.DATABASE; + // Remaining numeric codes in Discord's ranges (unknown entity 10xxx, request-level 5xxxx, etc.) + if (typeof code === 'number' && code >= 10000) { + return ErrorTypes.DISCORD_API; } - if (message.includes('network') || message.includes('fetch') || message.includes('enotconn')) { + if (error?.name === 'AbortError' || message.includes('network') || message.includes('fetch failed') || message.includes('enotconn')) { return ErrorTypes.NETWORK; } - if (message.includes('config') || message.includes('not found') || message.includes('invalid')) { - return ErrorTypes.CONFIGURATION; + if (message.includes('permission') || message.includes('missing access') || message.includes('missing permissions')) { + return ErrorTypes.PERMISSION; + } + + if (message.includes('database') || message.includes('postgres') || message.includes('sql') || message.includes('connection') || message.includes('timeout')) { + return ErrorTypes.DATABASE; } if (message.includes('validation') || message.includes('invalid') || message.includes('required')) { return ErrorTypes.VALIDATION; } + if (message.includes('config') || message.includes('not found')) { + return ErrorTypes.CONFIGURATION; + } + return ErrorTypes.UNKNOWN; } @@ -183,12 +222,7 @@ function buildErrorLogData(interaction, error, errorType, context = {}) { } function logInteractionError(error, errorType, logData) { - const isUserError = [ - ErrorTypes.VALIDATION, - ErrorTypes.RATE_LIMIT, - ErrorTypes.USER_INPUT, - ErrorTypes.PERMISSION - ].includes(errorType); + const isUserError = USER_ERROR_TYPES.has(errorType); const isExpectedError = Boolean(error?.context?.expected === true || error?.context?.suppressErrorLog === true); if (isUserError || isExpectedError) { @@ -245,13 +279,16 @@ async function sendErrorResponse(interaction, embed, context = {}) { } const useEphemeral = context.ephemeral !== false; - if (useEphemeral && !interaction.deferred && !interaction.replied) { - errorMessage.flags = MessageFlags.Ephemeral; - } - if (interaction.deferred || interaction.replied) { + if (interaction.replied) { + // A visible reply already exists; don't overwrite it — follow up ephemerally. + await interaction.followUp({ ...errorMessage, flags: MessageFlags.Ephemeral }); + } else if (interaction.deferred) { await interaction.editReply(errorMessage); } else { + if (useEphemeral) { + errorMessage.flags = MessageFlags.Ephemeral; + } await interaction.reply(errorMessage); } @@ -312,17 +349,73 @@ export async function replyUserError(interaction, { return sendErrorResponse(interaction, embed, { ...context, traceId, ephemeral, subtype }); } +const USER_ERROR_TYPES = new Set([ + ErrorTypes.VALIDATION, + ErrorTypes.RATE_LIMIT, + ErrorTypes.USER_INPUT, + ErrorTypes.PERMISSION +]); + +function buildErrorReference(resolvedErrorCode, traceId) { + const shortTrace = traceId ? String(traceId).slice(0, 8) : null; + return shortTrace ? `${resolvedErrorCode} · ${shortTrace}` : resolvedErrorCode; +} + export async function handleInteractionError(interaction, error, context = {}) { const errorType = categorizeError(error); const userMessage = getUserMessage(error, context); - const { logData, traceId } = buildErrorLogData(interaction, error, errorType, context); + const { logData, traceId, resolvedErrorCode } = buildErrorLogData(interaction, error, errorType, context); logInteractionError(error, errorType, logData); - const embed = buildUserErrorEmbed(errorType, userMessage); + // System errors get a reference code so users can report them and we can grep logs. + const isUserError = USER_ERROR_TYPES.has(errorType) || error?.context?.expected === true; + const description = isUserError + ? userMessage + : `${userMessage}\n\n-# Ref: \`${buildErrorReference(resolvedErrorCode, traceId)}\``; + + const embed = buildUserErrorEmbed(errorType, description); await sendErrorResponse(interaction, embed, { ...context, traceId }); } +/** + * Central error handler for non-interaction contexts (cron jobs, timers, event + * side-effects). Logs with the same structured fields as interaction errors. + */ +export function handleTaskError(taskName, error, context = {}) { + const errorType = categorizeError(error); + const resolvedErrorCode = resolveErrorCode({ error, errorType, context }); + const errorMetadata = getErrorMetadata(resolvedErrorCode); + + logger.error(`Task Error [${taskName}] [${errorType.toUpperCase()}]`, { + event: 'task.error', + task: taskName, + errorCode: resolvedErrorCode || ErrorCodes.TASK_ERROR, + remediationHint: errorMetadata.remediation, + severity: errorMetadata.severity, + retryable: errorMetadata.retryable, + type: errorType, + error: error?.message || String(error), + stack: error?.stack, + context + }); +} + +/** + * Wrap a background task so it can never produce an unhandled rejection. + * Usage: cron.schedule('* * * * *', runSafeTask('giveaways', () => checkGiveaways(client))) + */ +export function runSafeTask(taskName, fn, context = {}) { + return async (...args) => { + try { + return await fn(...args); + } catch (error) { + handleTaskError(taskName, error, context); + return null; + } + }; +} + export function withErrorHandling(fn, context = {}) { return async (...args) => { try { @@ -333,6 +426,12 @@ export function withErrorHandling(fn, context = {}) { (arg.isCommand || arg.isButton || arg.isModalSubmit || arg.isStringSelectMenu || arg.isChatInputCommand || arg._isPrefixCommand) ); + // Slash commands are handled by interactionCreate — re-throw so the + // central handler can attach trace context and command subtypes. + if (interaction?.isChatInputCommand?.()) { + throw error; + } + if (interaction) { await handleInteractionError(interaction, error, context); } else { @@ -360,6 +459,8 @@ export default { getUserMessage, replyUserError, handleInteractionError, + handleTaskError, + runSafeTask, withErrorHandling, createError }; diff --git a/src/utils/errorRegistry.js b/src/utils/errorRegistry.js index f4204068ea..18880bb41b 100644 --- a/src/utils/errorRegistry.js +++ b/src/utils/errorRegistry.js @@ -12,6 +12,9 @@ const ErrorCodes = Object.freeze({ INTERACTION_INVALID: 'INTERACTION_INVALID', INTERACTION_EXPIRED: 'INTERACTION_EXPIRED', INTERACTION_RESPONSE_FAILED: 'INTERACTION_RESPONSE_FAILED', + INTERACTION_UNHANDLED: 'INTERACTION_UNHANDLED', + TASK_ERROR: 'TASK_ERROR', + UNHANDLED_REJECTION: 'UNHANDLED_REJECTION', UNKNOWN_ERROR: 'UNKNOWN_ERROR' }); @@ -71,6 +74,21 @@ const ErrorCodeRegistry = Object.freeze({ retryable: false, remediation: 'Check interaction acknowledgement state and Discord response error codes.' }, + [ErrorCodes.INTERACTION_UNHANDLED]: { + severity: 'high', + retryable: false, + remediation: 'Add a handler for this interaction type or register the missing button/modal/select handler.' + }, + [ErrorCodes.TASK_ERROR]: { + severity: 'high', + retryable: true, + remediation: 'Inspect the named background task for thrown errors or unawaited promises.' + }, + [ErrorCodes.UNHANDLED_REJECTION]: { + severity: 'high', + retryable: false, + remediation: 'Find the promise that rejected without a catch handler and route it through runSafeTask or an explicit catch.' + }, [ErrorCodes.UNKNOWN_ERROR]: { severity: 'high', retryable: false, diff --git a/src/utils/logEmbeds.js b/src/utils/logging/logEmbeds.js similarity index 100% rename from src/utils/logEmbeds.js rename to src/utils/logging/logEmbeds.js diff --git a/src/utils/loggingUi.js b/src/utils/logging/loggingUi.js similarity index 98% rename from src/utils/loggingUi.js rename to src/utils/logging/loggingUi.js index 6c3bc6f4d6..76cbc8877d 100644 --- a/src/utils/loggingUi.js +++ b/src/utils/logging/loggingUi.js @@ -7,7 +7,7 @@ import { StringSelectMenuBuilder, StringSelectMenuOptionBuilder, } from 'discord.js'; -import { EVENT_TYPES } from '../services/loggingService.js'; +import { EVENT_TYPES } from '../../services/loggingService.js'; const EVENT_TYPES_BY_CATEGORY = Object.values(EVENT_TYPES).reduce((accumulator, eventType) => { const [category] = eventType.split('.'); diff --git a/src/utils/memoryStorage.js b/src/utils/memoryStorage.js index 570e2dab68..26d3465b01 100644 --- a/src/utils/memoryStorage.js +++ b/src/utils/memoryStorage.js @@ -41,7 +41,7 @@ class MemoryStorage { async list(prefix) { const keys = []; - for (const [key] of this.data.keys()) { + for (const key of this.data.keys()) { if (key.startsWith(prefix)) { if (this.expirationTimes.has(key)) { const expirationTime = this.expirationTimes.get(key); diff --git a/src/utils/messageAdapter.js b/src/utils/messageAdapter.js index a20acd98c8..2d69d689b4 100644 --- a/src/utils/messageAdapter.js +++ b/src/utils/messageAdapter.js @@ -2,9 +2,11 @@ import { mapArgumentsToOptions } from './prefixParser.js'; import { createEmbed } from './embeds.js'; +import { handleInteractionError } from './errorHandler.js'; import { logger } from './logger.js'; import { InteractionHelper } from './interactionHelper.js'; -import { SLASH_ONLY_COMMANDS } from '../config/prefixRestrictions.js'; +import { SLASH_ONLY_COMMANDS } from '../config/commands/prefixRestrictions.js'; +import { getCommandPrefix } from '../config/bot.js'; import { ResponseCoordinator, buildPrefixUsage } from './responseCoordinator.js'; import { enforceDefaultCommandPermissions } from './permissionGuard.js'; @@ -189,7 +191,7 @@ export function supportsPrefixExecution(command) { export async function executePrefixCommand(command, message, args, client, prefixOverride = null, guildConfig = null) { const mockInteraction = createMockInteraction(message, command.data, args); const coordinator = mockInteraction._responseCoordinator; - const prefix = prefixOverride || message.client?.config?.bot?.prefix || '!'; + const prefix = prefixOverride || getCommandPrefix(); try { const permissionAllowed = await enforceDefaultCommandPermissions(mockInteraction, command, { @@ -212,22 +214,10 @@ export async function executePrefixCommand(command, message, args, client, prefi await command.execute(mockInteraction, guildConfig, client); } } catch (error) { - logger.error('Prefix command execution error:', { - command: command.data.name, - args, - error: error.message, - stack: error.stack, + await handleInteractionError(mockInteraction, error, { + type: 'prefix_command', + command: command.data?.name, + source: 'messageAdapter.executePrefixCommand', }); - - if (!coordinator.hasResponded()) { - const embed = createEmbed({ - title: 'Command Execution Failed', - description: `An error occurred while executing this command.\n\n${error.message}`, - color: 'error', - }); - await coordinator.respond({ embeds: [embed] }); - } - - throw error; } } diff --git a/src/utils/moderation.js b/src/utils/moderation.js index 2a86b32c0b..1077863262 100644 --- a/src/utils/moderation.js +++ b/src/utils/moderation.js @@ -17,6 +17,7 @@ const ACTION_TO_EVENT_TYPE = { 'Channel Locked': EVENT_TYPES.MODERATION_LOCK, 'Channel Unlocked': EVENT_TYPES.MODERATION_UNLOCK, 'DM Sent': EVENT_TYPES.MODERATION_DM, + 'Bot Message Sent': EVENT_TYPES.MODERATION_CONFIG, 'Log Channel Activated': EVENT_TYPES.MODERATION_CONFIG, 'Log Filter Updated': EVENT_TYPES.MODERATION_CONFIG, 'Case Created': EVENT_TYPES.MODERATION_CONFIG, diff --git a/src/utils/permissionGuard.js b/src/utils/permissionGuard.js index 4a27af10d6..d6cb4a84d5 100644 --- a/src/utils/permissionGuard.js +++ b/src/utils/permissionGuard.js @@ -3,6 +3,7 @@ import { PermissionFlagsBits } from 'discord.js'; import { logger } from './logger.js'; import { replyUserError, ErrorTypes } from './errorHandler.js'; +import { isBotOwner, getBotMessage } from '../config/bot.js'; /** * Read default_member_permissions from a SlashCommandBuilder (or its JSON). @@ -148,6 +149,10 @@ export async function checkModerationPermissions( * @returns {Promise} true when the member may proceed */ export async function enforceDefaultCommandPermissions(interaction, command, context = {}) { + if (isBotOwner(interaction.user?.id)) { + return true; + } + const requiredPermissions = getCommandDefaultPermissions(command?.data); if (requiredPermissions == null) { return true; @@ -164,7 +169,7 @@ export async function enforceDefaultCommandPermissions(interaction, command, con const commandName = command?.data?.name ?? interaction.commandName ?? 'command'; await replyUserError(interaction, { type: ErrorTypes.PERMISSION, - message: 'You do not have permission to use this command.', + message: getBotMessage('noPermission'), context: { source: context.source ?? 'permissionGuard.enforceDefaultCommandPermissions', commandName, diff --git a/src/utils/postgresDatabase.js b/src/utils/postgresDatabase.js index f884395e80..84e9678f0c 100644 --- a/src/utils/postgresDatabase.js +++ b/src/utils/postgresDatabase.js @@ -1,9 +1,25 @@ // postgresDatabase.js import pg from 'pg'; -import { pgConfig, resolvePostgresPoolConfig } from '../config/postgres.js'; +import { pgConfig, resolvePostgresPoolConfig } from '../config/database/postgres.js'; import { logger } from './logger.js'; import { assertAllowlistedIdentifier, quoteIdentifier } from './sqlIdentifiers.js'; +import { + canonicalizeKey, + getLegacyVariantsForCanonical, +} from './database/keys.js'; +import { + parseKey, + isTempBackedType, + getStructuredListPlan, +} from './database/keyParser.js'; +import { runKeyMigration } from './database/keyMigration.js'; +import { + tableStatements, + indexStatements, + UPDATE_TIMESTAMP_FUNCTION, + triggerDefinitions, +} from './database/schema.js'; class PostgreSQLDatabase { constructor() { @@ -88,6 +104,7 @@ class PostgreSQLDatabase { logger.warn( `No schema version found. Bootstrapped schema ledger to version ${pgConfig.migration.expectedVersion} (${pgConfig.migration.expectedLabel}).` ); + await this.runStartupKeyMigration(); return true; } @@ -99,6 +116,7 @@ class PostgreSQLDatabase { } } + await this.runStartupKeyMigration(); return true; } catch (error) { this.lastFailureReason = error.code || 'POSTGRES_CONNECTION_FAILED'; @@ -137,6 +155,24 @@ class PostgreSQLDatabase { return false; } + async runStartupKeyMigration() { + if (pgConfig.features.autoMigrate === false) { + return; + } + + try { + const result = await runKeyMigration({ pool: this.pool, logger }); + if (result?.alreadyDone) { + logger.debug('Key migration already applied, skipping.'); + } else if (result && (result.migrated > 0 || result.errors > 0)) { + logger.info('Startup key migration finished', result); + } + } catch (error) { + // Never block startup on key migration; legacy reads still work via fallback. + logger.error('Startup key migration failed (continuing with legacy fallback):', error); + } + } + isAvailable() { return this.isConnected && this.pool; } @@ -218,172 +254,7 @@ class PostgreSQLDatabase { } async createTables() { - const tables = [ - `CREATE TABLE IF NOT EXISTS ${pgConfig.tables.guilds} ( - id VARCHAR(20) PRIMARY KEY, - config JSONB DEFAULT '{}', - counters JSONB DEFAULT '[]', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - )`, - - `CREATE TABLE IF NOT EXISTS ${pgConfig.tables.users} ( - id VARCHAR(20) PRIMARY KEY, - username VARCHAR(100), - discriminator VARCHAR(10), - avatar VARCHAR(100), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - )`, - - `CREATE TABLE IF NOT EXISTS ${pgConfig.tables.guild_users} ( - guild_id VARCHAR(20), - user_id VARCHAR(20), - joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (guild_id, user_id), - FOREIGN KEY (guild_id) REFERENCES ${pgConfig.tables.guilds}(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES ${pgConfig.tables.users}(id) ON DELETE CASCADE - )`, - - `CREATE TABLE IF NOT EXISTS ${pgConfig.tables.birthdays} ( - guild_id VARCHAR(20), - user_id VARCHAR(20), - month INTEGER NOT NULL, - day INTEGER NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (guild_id, user_id), - FOREIGN KEY (guild_id) REFERENCES ${pgConfig.tables.guilds}(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES ${pgConfig.tables.users}(id) ON DELETE CASCADE - )`, - - `CREATE TABLE IF NOT EXISTS ${pgConfig.tables.giveaways} ( - id SERIAL PRIMARY KEY, - guild_id VARCHAR(20), - message_id VARCHAR(20) NOT NULL, - data JSONB NOT NULL, - ends_at TIMESTAMP, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (guild_id) REFERENCES ${pgConfig.tables.guilds}(id) ON DELETE CASCADE, - UNIQUE(guild_id, message_id) - )`, - - `CREATE TABLE IF NOT EXISTS ${pgConfig.tables.tickets} ( - guild_id VARCHAR(20), - channel_id VARCHAR(20) PRIMARY KEY, - data JSONB NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - expires_at TIMESTAMP, - FOREIGN KEY (guild_id) REFERENCES ${pgConfig.tables.guilds}(id) ON DELETE CASCADE - )`, - - `CREATE TABLE IF NOT EXISTS ${pgConfig.tables.afk_status} ( - guild_id VARCHAR(20), - user_id VARCHAR(20), - reason TEXT, - status_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - expires_at TIMESTAMP, - PRIMARY KEY (guild_id, user_id), - FOREIGN KEY (guild_id) REFERENCES ${pgConfig.tables.guilds}(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES ${pgConfig.tables.users}(id) ON DELETE CASCADE - )`, - - `CREATE TABLE IF NOT EXISTS ${pgConfig.tables.welcome_configs} ( - guild_id VARCHAR(20) PRIMARY KEY, - config JSONB NOT NULL DEFAULT '{}', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (guild_id) REFERENCES ${pgConfig.tables.guilds}(id) ON DELETE CASCADE - )`, - - `CREATE TABLE IF NOT EXISTS ${pgConfig.tables.leveling_configs} ( - guild_id VARCHAR(20) PRIMARY KEY, - config JSONB NOT NULL DEFAULT '{}', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (guild_id) REFERENCES ${pgConfig.tables.guilds}(id) ON DELETE CASCADE - )`, - - `CREATE TABLE IF NOT EXISTS ${pgConfig.tables.user_levels} ( - guild_id VARCHAR(20), - user_id VARCHAR(20), - xp BIGINT DEFAULT 0, - level INTEGER DEFAULT 0, - total_xp BIGINT DEFAULT 0, - last_message TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - rank INTEGER DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (guild_id, user_id), - FOREIGN KEY (guild_id) REFERENCES ${pgConfig.tables.guilds}(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES ${pgConfig.tables.users}(id) ON DELETE CASCADE - )`, - - `CREATE TABLE IF NOT EXISTS ${pgConfig.tables.economy} ( - guild_id VARCHAR(20), - user_id VARCHAR(20), - balance BIGINT DEFAULT 0, - bank BIGINT DEFAULT 0, - data JSONB DEFAULT '{}', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (guild_id, user_id), - FOREIGN KEY (guild_id) REFERENCES ${pgConfig.tables.guilds}(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES ${pgConfig.tables.users}(id) ON DELETE CASCADE - )`, - - `CREATE TABLE IF NOT EXISTS ${pgConfig.tables.verification_audit} ( - id SERIAL PRIMARY KEY, - guild_id VARCHAR(20) NOT NULL, - user_id VARCHAR(20) NOT NULL, - action VARCHAR(50) NOT NULL, - source VARCHAR(50), - moderator_id VARCHAR(20), - metadata JSONB DEFAULT '{}', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - )`, - - `CREATE TABLE IF NOT EXISTS ${pgConfig.tables.invite_tracking} ( - guild_id VARCHAR(20), - inviter_id VARCHAR(20), - invite_code VARCHAR(20), - uses INTEGER DEFAULT 0, - data JSONB DEFAULT '{}', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (guild_id, invite_code), - FOREIGN KEY (guild_id) REFERENCES ${pgConfig.tables.guilds}(id) ON DELETE CASCADE - )`, - - `CREATE TABLE IF NOT EXISTS ${pgConfig.tables.application_roles} ( - guild_id VARCHAR(20), - role_id VARCHAR(20), - data JSONB DEFAULT '{}', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (guild_id, role_id), - FOREIGN KEY (guild_id) REFERENCES ${pgConfig.tables.guilds}(id) ON DELETE CASCADE - )`, - - `CREATE TABLE IF NOT EXISTS ${pgConfig.tables.temp_data} ( - key VARCHAR(255) PRIMARY KEY, - value JSONB NOT NULL, - expires_at TIMESTAMP, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - )`, - - `CREATE TABLE IF NOT EXISTS ${pgConfig.tables.cache_data} ( - key VARCHAR(255) PRIMARY KEY, - value JSONB NOT NULL, - expires_at TIMESTAMP, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - )` - ]; - - for (const table of tables) { + for (const table of tableStatements) { try { await this.pool.query(table); } catch (error) { @@ -398,28 +269,7 @@ class PostgreSQLDatabase { } async createIndexes() { - const indexes = [ - `CREATE INDEX IF NOT EXISTS idx_guild_users_guild_id ON ${pgConfig.tables.guild_users}(guild_id)`, - `CREATE INDEX IF NOT EXISTS idx_guild_users_user_id ON ${pgConfig.tables.guild_users}(user_id)`, - `CREATE INDEX IF NOT EXISTS idx_birthdays_guild_id ON ${pgConfig.tables.birthdays}(guild_id)`, - `CREATE INDEX IF NOT EXISTS idx_birthdays_month_day ON ${pgConfig.tables.birthdays}(month, day)`, - `CREATE INDEX IF NOT EXISTS idx_giveaways_guild_id ON ${pgConfig.tables.giveaways}(guild_id)`, - `CREATE INDEX IF NOT EXISTS idx_giveaways_ends_at ON ${pgConfig.tables.giveaways}(ends_at)`, - `CREATE INDEX IF NOT EXISTS idx_tickets_guild_id ON ${pgConfig.tables.tickets}(guild_id)`, - `CREATE INDEX IF NOT EXISTS idx_tickets_expires_at ON ${pgConfig.tables.tickets}(expires_at)`, - `CREATE INDEX IF NOT EXISTS idx_afk_status_guild_id ON ${pgConfig.tables.afk_status}(guild_id)`, - `CREATE INDEX IF NOT EXISTS idx_afk_status_expires_at ON ${pgConfig.tables.afk_status}(expires_at)`, - `CREATE INDEX IF NOT EXISTS idx_user_levels_guild_id ON ${pgConfig.tables.user_levels}(guild_id)`, - `CREATE INDEX IF NOT EXISTS idx_user_levels_xp ON ${pgConfig.tables.user_levels}(xp)`, - `CREATE INDEX IF NOT EXISTS idx_economy_guild_id ON ${pgConfig.tables.economy}(guild_id)`, - `CREATE INDEX IF NOT EXISTS idx_verification_audit_guild_id ON ${pgConfig.tables.verification_audit}(guild_id)`, - `CREATE INDEX IF NOT EXISTS idx_verification_audit_user_id ON ${pgConfig.tables.verification_audit}(user_id)`, - `CREATE INDEX IF NOT EXISTS idx_verification_audit_created_at ON ${pgConfig.tables.verification_audit}(created_at)`, - `CREATE INDEX IF NOT EXISTS idx_temp_data_expires_at ON ${pgConfig.tables.temp_data}(expires_at)`, - `CREATE INDEX IF NOT EXISTS idx_cache_data_expires_at ON ${pgConfig.tables.cache_data}(expires_at)` - ]; - - for (const index of indexes) { + for (const index of indexStatements) { try { await this.pool.query(index); } catch (error) { @@ -432,33 +282,9 @@ class PostgreSQLDatabase { async createAuditTriggers() { try { - const functionQuery = ` - CREATE OR REPLACE FUNCTION update_updated_at_column() - RETURNS TRIGGER AS $$ - BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; - END; - $$ language 'plpgsql'; - `; - - await this.pool.query(functionQuery); - - const triggers = [ - { name: 'update_guilds_updated_at', table: pgConfig.tables.guilds }, - { name: 'update_users_updated_at', table: pgConfig.tables.users }, - { name: 'update_welcome_configs_updated_at', table: pgConfig.tables.welcome_configs }, - { name: 'update_leveling_configs_updated_at', table: pgConfig.tables.leveling_configs }, - { name: 'update_user_levels_updated_at', table: pgConfig.tables.user_levels }, - { name: 'update_economy_updated_at', table: pgConfig.tables.economy }, - { name: 'update_application_roles_updated_at', table: pgConfig.tables.application_roles }, - { name: 'update_invite_tracking_updated_at', table: pgConfig.tables.invite_tracking }, - { name: 'update_guild_users_updated_at', table: pgConfig.tables.guild_users }, - { name: 'update_birthdays_updated_at', table: pgConfig.tables.birthdays }, - { name: 'update_giveaways_updated_at', table: pgConfig.tables.giveaways }, - { name: 'update_tickets_updated_at', table: pgConfig.tables.tickets }, - { name: 'update_afk_status_updated_at', table: pgConfig.tables.afk_status }, - ]; + await this.pool.query(UPDATE_TIMESTAMP_FUNCTION); + + const triggers = triggerDefinitions; const allowedTriggerIdentifiers = new Set(triggers.map(trigger => trigger.name)); @@ -494,6 +320,35 @@ class PostgreSQLDatabase { } } + async _getTempValue(key, defaultValue = null) { + const result = await this.pool.query( + `SELECT value FROM ${pgConfig.tables.temp_data} WHERE key = $1 AND (expires_at IS NULL OR expires_at > NOW())`, + [key], + ); + return result.rows.length > 0 ? result.rows[0].value : defaultValue; + } + + async _getWithLegacyFallback(canonicalKey, originalKey, defaultValue) { + let value = await this._getTempValue(canonicalKey, defaultValue); + if (value !== defaultValue) { + return value; + } + + const legacyKeys = new Set([ + ...(originalKey !== canonicalKey ? [originalKey] : []), + ...getLegacyVariantsForCanonical(canonicalKey), + ]); + + for (const legacyKey of legacyKeys) { + value = await this._getTempValue(legacyKey, defaultValue); + if (value !== defaultValue) { + return value; + } + } + + return defaultValue; + } + async get(key, defaultValue = null) { try { if (!this.isAvailable()) { @@ -501,25 +356,34 @@ class PostgreSQLDatabase { return defaultValue; } - const parsedKey = this.parseKey(key); - - if (parsedKey.type === 'temp') { - const result = await this.pool.query( - `SELECT value FROM ${pgConfig.tables.temp_data} WHERE key = $1 AND (expires_at IS NULL OR expires_at > NOW())`, - [parsedKey.fullKey] - ); - return result.rows.length > 0 ? result.rows[0].value : defaultValue; + const canonicalKey = canonicalizeKey(key); + const parsedKey = parseKey(canonicalKey); + + if (parsedKey.type === 'temp' || isTempBackedType(parsedKey.type)) { + return await this._getWithLegacyFallback(parsedKey.fullKey, key, defaultValue); } - + if (parsedKey.type === 'cache') { const result = await this.pool.query( `SELECT value FROM ${pgConfig.tables.cache_data} WHERE key = $1 AND (expires_at IS NULL OR expires_at > NOW())`, - [parsedKey.fullKey] + [parsedKey.fullKey], ); return result.rows.length > 0 ? result.rows[0].value : defaultValue; } - return await this.getStructuredData(parsedKey, defaultValue); + const structuredValue = await this.getStructuredData(parsedKey, defaultValue); + if (structuredValue !== defaultValue) { + return structuredValue; + } + + if (canonicalKey !== key) { + const legacyParsed = parseKey(key); + if (legacyParsed.fullKey !== parsedKey.fullKey) { + return await this.getStructuredData(legacyParsed, defaultValue); + } + } + + return structuredValue; } catch (error) { logger.error(`Error getting value for key ${key}:`, error); return defaultValue; @@ -533,26 +397,27 @@ class PostgreSQLDatabase { return false; } - const parsedKey = this.parseKey(key); + const canonicalKey = canonicalizeKey(key); + const parsedKey = parseKey(canonicalKey); const expiresAt = ttl ? new Date(Date.now() + ttl * 1000) : null; const jsonValue = JSON.stringify(value ?? null); - - if (parsedKey.type === 'temp') { + + if (parsedKey.type === 'temp' || isTempBackedType(parsedKey.type)) { await this.pool.query( - `INSERT INTO ${pgConfig.tables.temp_data} (key, value, expires_at) - VALUES ($1, $2, $3) + `INSERT INTO ${pgConfig.tables.temp_data} (key, value, expires_at) + VALUES ($1, $2, $3) ON CONFLICT (key) DO UPDATE SET value = $2, expires_at = $3`, - [parsedKey.fullKey, jsonValue, expiresAt] + [parsedKey.fullKey, jsonValue, expiresAt], ); return true; } - + if (parsedKey.type === 'cache') { await this.pool.query( - `INSERT INTO ${pgConfig.tables.cache_data} (key, value, expires_at) - VALUES ($1, $2, $3) + `INSERT INTO ${pgConfig.tables.cache_data} (key, value, expires_at) + VALUES ($1, $2, $3) ON CONFLICT (key) DO UPDATE SET value = $2, expires_at = $3`, - [parsedKey.fullKey, jsonValue, expiresAt] + [parsedKey.fullKey, jsonValue, expiresAt], ); return true; } @@ -571,19 +436,29 @@ class PostgreSQLDatabase { return false; } - const parsedKey = this.parseKey(key); - - if (parsedKey.type === 'temp') { + const canonicalKey = canonicalizeKey(key); + const parsedKey = parseKey(canonicalKey); + let deleted = false; + + if (parsedKey.type === 'temp' || isTempBackedType(parsedKey.type)) { await this.pool.query(`DELETE FROM ${pgConfig.tables.temp_data} WHERE key = $1`, [parsedKey.fullKey]); - return true; - } - - if (parsedKey.type === 'cache') { + deleted = true; + } else if (parsedKey.type === 'cache') { await this.pool.query(`DELETE FROM ${pgConfig.tables.cache_data} WHERE key = $1`, [parsedKey.fullKey]); - return true; + deleted = true; + } else { + deleted = await this.deleteStructuredData(parsedKey); + } + + for (const legacyKey of getLegacyVariantsForCanonical(canonicalKey)) { + await this.pool.query(`DELETE FROM ${pgConfig.tables.temp_data} WHERE key = $1`, [legacyKey]); + } + + if (key !== canonicalKey) { + await this.pool.query(`DELETE FROM ${pgConfig.tables.temp_data} WHERE key = $1`, [key]); } - return await this.deleteStructuredData(parsedKey); + return deleted; } catch (error) { logger.error(`Error deleting key ${key}:`, error); return false; @@ -597,21 +472,43 @@ class PostgreSQLDatabase { return []; } - const keys = []; - - const tempResult = await this.pool.query( - `SELECT key FROM ${pgConfig.tables.temp_data} WHERE key LIKE $1 AND (expires_at IS NULL OR expires_at > NOW())`, - [`${prefix}%`] - ); - keys.push(...tempResult.rows.map(row => row.key)); - + const keys = new Set(); + const plan = getStructuredListPlan(prefix, pgConfig.tables); + const tempPrefixes = plan.tempPrefixes ?? [prefix]; + + for (const tempPrefix of tempPrefixes) { + const tempResult = await this.pool.query( + `SELECT key FROM ${pgConfig.tables.temp_data} WHERE key LIKE $1 AND (expires_at IS NULL OR expires_at > NOW())`, + [`${tempPrefix}%`], + ); + for (const row of tempResult.rows) { + keys.add(canonicalizeKey(row.key)); + } + } + const cacheResult = await this.pool.query( `SELECT key FROM ${pgConfig.tables.cache_data} WHERE key LIKE $1 AND (expires_at IS NULL OR expires_at > NOW())`, - [`${prefix}%`] + [`${prefix}%`], ); - keys.push(...cacheResult.rows.map(row => row.key)); + for (const row of cacheResult.rows) { + keys.add(row.key); + } + + for (const query of plan.queries) { + const result = await this.pool.query(query.sql, query.params); + for (const row of result.rows) { + keys.add(query.mapKey(row)); + } + } + + for (const staticKey of plan.staticKeys ?? []) { + if (!staticKey.startsWith(prefix)) continue; + if (await this.exists(staticKey)) { + keys.add(staticKey); + } + } - return keys; + return [...keys]; } catch (error) { logger.error(`Error listing keys with prefix ${prefix}:`, error); return []; @@ -695,58 +592,6 @@ class PostgreSQLDatabase { } } - parseKey(key) { - - if (key.startsWith('temp:')) { - return { type: 'temp', fullKey: key }; - } - if (key.startsWith('cache:')) { - return { type: 'cache', fullKey: key }; - } - - const parts = key.split(':'); - - if (parts[0] === 'guild') { - if (parts[2] === 'config') { - return { type: 'guild_config', guildId: parts[1], fullKey: key }; - } - if (parts[2] === 'birthdays') { - return { type: 'guild_birthdays', guildId: parts[1], fullKey: key }; - } - if (parts[2] === 'giveaways') { - return { type: 'guild_giveaways', guildId: parts[1], fullKey: key }; - } - if (parts[2] === 'welcome') { - return { type: 'welcome_config', guildId: parts[1], fullKey: key }; - } - if (parts[2] === 'leveling') { - - if (parts[3] === 'config') { - return { type: 'leveling_config', guildId: parts[1], fullKey: key }; - } - if (parts[3] === 'users') { - return { type: 'user_level', guildId: parts[1], userId: parts[4], fullKey: key }; - } - return { type: 'leveling_data', guildId: parts[1], fullKey: key }; - } - if (parts[2] === 'economy' && parts[3]) { - return { type: 'economy', guildId: parts[1], userId: parts[3], fullKey: key }; - } - if (parts[2] === 'afk' && parts[3]) { - return { type: 'afk_status', guildId: parts[1], userId: parts[3], fullKey: key }; - } - if (parts[2] === 'ticket' && parts[3]) { - return { type: 'ticket', guildId: parts[1], channelId: parts[3], fullKey: key }; - } - } - - if (parts[0] === 'counters' && parts[1]) { - return { type: 'counters', guildId: parts[1], fullKey: key }; - } - - return { type: 'temp', fullKey: key }; - } - async getStructuredData(parsedKey, defaultValue) { try { switch (parsedKey.type) { @@ -789,12 +634,22 @@ class PostgreSQLDatabase { ); return levelingConfigResult.rows.length > 0 ? levelingConfigResult.rows[0].config : defaultValue; - case 'user_level': + case 'user_level': { const userLevelResult = await this.pool.query( `SELECT xp, level, total_xp, last_message, rank FROM ${pgConfig.tables.user_levels} WHERE guild_id = $1 AND user_id = $2`, [parsedKey.guildId, parsedKey.userId] ); - return userLevelResult.rows.length > 0 ? userLevelResult.rows[0] : defaultValue; + if (userLevelResult.rows.length === 0) return defaultValue; + // Map snake_case columns to the camelCase shape consumers expect + const levelRow = userLevelResult.rows[0]; + return { + xp: Number(levelRow.xp) || 0, + level: Number(levelRow.level) || 0, + totalXp: Number(levelRow.total_xp) || 0, + lastMessage: Number(levelRow.last_message) || 0, + rank: Number(levelRow.rank) || 0, + }; + } case 'economy': { const economyResult = await this.pool.query( @@ -810,12 +665,19 @@ class PostgreSQLDatabase { return { wallet: row.balance ?? 0, bank: row.bank ?? 0 }; } - case 'afk_status': + case 'afk_status': { const afkResult = await this.pool.query( `SELECT reason, status_at, expires_at FROM ${pgConfig.tables.afk_status} WHERE guild_id = $1 AND user_id = $2`, - [parsedKey.guildId, parsedKey.userId] + [parsedKey.guildId, parsedKey.userId], ); - return afkResult.rows.length > 0 ? afkResult.rows[0] : defaultValue; + if (afkResult.rows.length === 0) return defaultValue; + const row = afkResult.rows[0]; + return { + reason: row.reason, + setAt: row.status_at, + expiresAt: row.expires_at, + }; + } case 'ticket': const ticketResult = await this.pool.query( @@ -1004,7 +866,7 @@ class PostgreSQLDatabase { VALUES ($1, $2, $3, $4) ON CONFLICT (guild_id, user_id) DO UPDATE SET reason = $3, expires_at = $4, status_at = CURRENT_TIMESTAMP`, - [parsedKey.guildId, parsedKey.userId, value.reason, value.expiresAt ? new Date(value.expiresAt) : null] + [parsedKey.guildId, parsedKey.userId, value.reason, (value.expiresAt ?? value.expires_at) ? new Date(value.expiresAt ?? value.expires_at) : null] ); return true; @@ -1118,6 +980,13 @@ class PostgreSQLDatabase { case 'ticket': await this.pool.query(`DELETE FROM ${pgConfig.tables.tickets} WHERE guild_id = $1 AND channel_id = $2`, [parsedKey.guildId, parsedKey.channelId]); return true; + + case 'counters': + await this.pool.query( + `UPDATE ${pgConfig.tables.guilds} SET counters = '[]'::jsonb, updated_at = CURRENT_TIMESTAMP WHERE id = $1`, + [parsedKey.guildId], + ); + return true; default: return false; diff --git a/src/utils/prefixParser.js b/src/utils/prefixParser.js index 6fe9d153e7..53a1e37d15 100644 --- a/src/utils/prefixParser.js +++ b/src/utils/prefixParser.js @@ -1,6 +1,6 @@ // prefixParser.js -import { resolveSubcommandAlias } from '../config/commandAliases.js'; +import { resolveSubcommandAlias } from '../config/commands/commandAliases.js'; import { logger } from './logger.js'; export function parsePrefixCommand(content, prefix) { diff --git a/src/utils/safeMathParser.js b/src/utils/safeMathParser.js index 72fc82213c..bb2f8d6c29 100644 --- a/src/utils/safeMathParser.js +++ b/src/utils/safeMathParser.js @@ -61,7 +61,7 @@ function tokenize(expression) { while (index < expression.length) { const character = expression[index]; - if (character === '' || character === '\t' || character === '\n') { + if (character === ' ' || character === '\t' || character === '\n' || character === '\r') { index += 1; continue; } diff --git a/src/utils/serviceErrorBoundary.js b/src/utils/serviceErrorBoundary.js index 23577ba79a..75d845b105 100644 --- a/src/utils/serviceErrorBoundary.js +++ b/src/utils/serviceErrorBoundary.js @@ -1,6 +1,6 @@ // serviceErrorBoundary.js -import { createError, ErrorTypes, TitanBotError } from './errorHandler.js'; +import { createError, ErrorTypes, TitanBotError, categorizeError } from './errorHandler.js'; import { resolveErrorCode, getErrorMetadata } from './errorRegistry.js'; function normalizeBoundaryContext(context = {}) { @@ -11,39 +11,6 @@ function normalizeBoundaryContext(context = {}) { return context; } -function inferErrorType(error, fallbackType = ErrorTypes.UNKNOWN) { - const message = error?.message?.toLowerCase?.() || ''; - const code = error?.code; - - if (typeof code === 'string') { - if (code.includes('PERMISSION') || code.includes('FORBIDDEN')) { - return ErrorTypes.PERMISSION; - } - - if (code.includes('VALIDATION') || code.includes('INVALID')) { - return ErrorTypes.VALIDATION; - } - - if (code.includes('DB') || code.includes('SQL') || code.includes('POSTGRES')) { - return ErrorTypes.DATABASE; - } - } - - if (message.includes('permission') || message.includes('forbidden')) { - return ErrorTypes.PERMISSION; - } - - if (message.includes('database') || message.includes('sql') || message.includes('connection') || message.includes('timeout')) { - return ErrorTypes.DATABASE; - } - - if (message.includes('validation') || message.includes('invalid') || message.includes('required')) { - return ErrorTypes.VALIDATION; - } - - return fallbackType; -} - export function ensureTypedServiceError(error, options = {}) { if (error instanceof TitanBotError) { return error; @@ -51,7 +18,8 @@ export function ensureTypedServiceError(error, options = {}) { const context = normalizeBoundaryContext(options.context); const fallbackType = options.type || ErrorTypes.UNKNOWN; - const type = inferErrorType(error, fallbackType); + const categorized = categorizeError(error); + const type = categorized === ErrorTypes.UNKNOWN ? fallbackType : categorized; const service = options.service || 'unknown_service'; const operation = options.operation || 'unknown_operation'; const errorCode = resolveErrorCode({ @@ -120,4 +88,4 @@ export function wrapServiceClassMethods(ServiceClass, optionsFactory) { } return ServiceClass; -} \ No newline at end of file +} diff --git a/src/utils/ticketLogging.js b/src/utils/ticket/ticketLogging.js similarity index 98% rename from src/utils/ticketLogging.js rename to src/utils/ticket/ticketLogging.js index db297ddaad..0439783e40 100644 --- a/src/utils/ticketLogging.js +++ b/src/utils/ticket/ticketLogging.js @@ -1,13 +1,13 @@ // ticketLogging.js import { ChannelType } from 'discord.js'; -import { getGuildConfig } from '../services/guildConfig.js'; -import { logger } from './logger.js'; +import { getGuildConfig } from '../../services/config/guildConfig.js'; +import { logger } from '../logger.js'; import { buildStandardLogEmbed, formatRatingStars, resolveUserAuthor, -} from './logEmbeds.js'; +} from '../logging/logEmbeds.js'; export async function logTicketEvent({ client, guildId, event }) { try { diff --git a/src/utils/ticketPermissions.js b/src/utils/ticket/ticketPermissions.js similarity index 89% rename from src/utils/ticketPermissions.js rename to src/utils/ticket/ticketPermissions.js index a056f4a1f8..a4411e8efc 100644 --- a/src/utils/ticketPermissions.js +++ b/src/utils/ticket/ticketPermissions.js @@ -1,8 +1,8 @@ // ticketPermissions.js import { PermissionFlagsBits } from 'discord.js'; -import { getGuildConfig } from '../services/guildConfig.js'; -import { getTicketData } from './database.js'; +import { getGuildConfig } from '../../services/config/guildConfig.js'; +import { getTicketData } from '../database.js'; export async function getTicketPermissionContext({ client, interaction }) { const guildId = interaction.guildId; diff --git a/tests/commandAccessService.test.js b/tests/commandAccessService.test.js deleted file mode 100644 index 65508ded45..0000000000 --- a/tests/commandAccessService.test.js +++ /dev/null @@ -1,81 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; - -import { - isCommandEnabledInConfig, - getCommandAccessSnapshot, - buildCommandRegistry, - resolveCategoryChoice, -} from '../src/services/commandAccessService.js'; - -const mockClient = { - commands: new Map([ - ['balance', { data: { name: 'balance', description: 'Check balance' }, category: 'Economy' }], - ['daily', { data: { name: 'daily', description: 'Daily reward' }, category: 'Economy' }], - ['ping', { data: { name: 'ping', description: 'Ping' }, category: 'Core' }], - ['commands', { data: { name: 'commands', description: 'Manage commands' }, category: 'Core' }], - ['configwizard', { data: { name: 'configwizard', description: 'Config wizard' }, category: 'Core' }], - ]), -}; - -test('isCommandEnabledInConfig respects category and command disables', () => { - const config = { - disabledCategories: { economy: true }, - disabledCommands: {}, - }; - - assert.equal(isCommandEnabledInConfig(config, 'balance', 'Economy'), false); - assert.equal(isCommandEnabledInConfig(config, 'ping', 'Core'), true); -}); - -test('isCommandEnabledInConfig keeps protected commands available', () => { - const config = { - disabledCategories: { core: true }, - disabledCommands: { commands: true, configwizard: true }, - }; - - assert.equal(isCommandEnabledInConfig(config, 'commands', 'Core'), true); - assert.equal(isCommandEnabledInConfig(config, 'configwizard', 'Core'), true); - assert.equal(isCommandEnabledInConfig(config, 'ping', 'Core'), false); -}); - -test('isCommandEnabledInConfig supports legacy array disabledCommands', () => { - const config = { - disabledCategories: {}, - disabledCommands: ['urban', 'flip'], - }; - - assert.equal(isCommandEnabledInConfig(config, 'urban', 'Search'), false); - assert.equal(isCommandEnabledInConfig(config, 'flip', 'Fun'), false); -}); - -test('getCommandAccessSnapshot summarizes category state', () => { - const config = { - disabledCategories: { economy: true }, - disabledCommands: { ping: true }, - }; - - const snapshot = getCommandAccessSnapshot(mockClient, config); - const economy = snapshot.categories.find((category) => category.key === 'economy'); - const core = snapshot.categories.find((category) => category.key === 'core'); - - assert.equal(economy.categoryDisabled, true); - assert.equal(core.disabledCount, 1); - assert.equal(snapshot.enabledTotal, 2); -}); - -test('buildCommandRegistry groups commands by category folder', () => { - const registry = buildCommandRegistry(mockClient); - assert.equal(registry.get('economy')?.commands.length, 2); - assert.equal(registry.get('core')?.commands.length, 3); -}); - -test('isCommandEnabledInConfig supports subcommand-level disables', () => { - const config = { - disabledCategories: {}, - disabledCommands: { 'birthday list': true }, - }; - - assert.equal(isCommandEnabledInConfig(config, 'birthday list', 'Birthday'), false); - assert.equal(isCommandEnabledInConfig(config, 'birthday info', 'Birthday'), true); -}); diff --git a/tests/embedUtils.test.js b/tests/embedUtils.test.js deleted file mode 100644 index 1fff605bab..0000000000 --- a/tests/embedUtils.test.js +++ /dev/null @@ -1,73 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { createEmbed } from '../src/utils/embeds.js'; - -test('createEmbed should ignore unimportant footer text and should not add a timestamp by default', () => { - const embed = createEmbed({ - title: 'Test', - description: 'Hello world', - footer: 'Footer text should move into description' - }); - - const data = embed.toJSON(); - assert.equal(data.footer, undefined); - assert.equal(data.timestamp, undefined); - assert.equal(data.description, 'Hello world'); -}); - -test('createEmbed should set important footer text on the embed footer', () => { - const embed = createEmbed({ - title: 'Test', - description: 'Hello world', - footer: 'Dashboard closes after 10 minutes of inactivity' - }); - - const data = embed.toJSON(); - assert.equal(data.footer?.text, 'Dashboard closes after 10 minutes of inactivity'); - assert.equal(data.timestamp, undefined); - assert.equal(data.description, 'Hello world'); -}); - -test('setFooter should place important footer text on the embed footer', () => { - const embed = createEmbed({ - title: 'Footer Test', - description: 'Base description.' - }); - - embed.setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }); - const data = embed.toJSON(); - - assert.equal(data.footer?.text, 'Dashboard closes after 10 minutes of inactivity'); - assert.equal(data.description, 'Base description.'); -}); - -test('setFooter should keep footer text at the bottom when fields are added after footer', () => { - const embed = createEmbed({ - title: 'Dashboard', - description: 'Manage settings for **Test Server**.', - }); - - embed.setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }); - embed.addFields( - { name: 'Status', value: '`Enabled`', inline: true }, - { name: 'Channel', value: '`Not set`', inline: true }, - ); - - const data = embed.toJSON(); - assert.equal(data.footer?.text, 'Dashboard closes after 10 minutes of inactivity'); - assert.equal(data.description, 'Manage settings for **Test Server**.'); - assert.equal(data.fields?.length, 2); -}); - -test('setFooter should ignore unimportant footer text', () => { - const embed = createEmbed({ - title: 'Footer Test', - description: 'Base description.' - }); - - embed.setFooter({ text: 'Requested by mrpinkify' }); - const data = embed.toJSON(); - - assert.equal(data.footer, undefined); - assert.equal(data.description, 'Base description.'); -}); diff --git a/tests/failure-paths/abuseProtection.test.js b/tests/failure-paths/abuseProtection.test.js deleted file mode 100644 index e09419a23a..0000000000 --- a/tests/failure-paths/abuseProtection.test.js +++ /dev/null @@ -1,100 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; - -import { - enforceAbuseProtection, - formatCooldownDuration, - isRiskyCommand, - resetAbuseProtectionState -} from '../../src/utils/abuseProtection.js'; -import { logger } from '../../src/utils/logger.js'; - -function createInteraction(overrides = {}) { - return { - guildId: 'guild-1', - user: { id: 'user-1' }, - ...overrides - }; -} - -test('detects risky commands by category and explicit name', () => { - assert.equal(isRiskyCommand({ category: 'moderation' }, 'ban'), true); - assert.equal(isRiskyCommand({ category: 'fun' }, 'wipedata'), true); - assert.equal(isRiskyCommand({ category: 'fun' }, 'ping'), false); -}); - -test('blocks risky command after cooldown limit is exceeded', async () => { - resetAbuseProtectionState(); - const interaction = createInteraction(); - const riskyCommand = { category: 'moderation' }; - - const first = await enforceAbuseProtection(interaction, riskyCommand, 'ban'); - const second = await enforceAbuseProtection(interaction, riskyCommand, 'ban'); - const third = await enforceAbuseProtection(interaction, riskyCommand, 'ban'); - - assert.equal(first.allowed, true); - assert.equal(second.allowed, true); - assert.equal(third.allowed, false); - assert.ok(third.remainingMs > 0); -}); - -test('supports command-level abuse policy overrides', async () => { - resetAbuseProtectionState(); - const interaction = createInteraction(); - const command = { - category: 'fun', - abuseProtection: { - enabled: true, - maxAttempts: 1, - windowMs: 5_000 - } - }; - - const first = await enforceAbuseProtection(interaction, command, 'custom-risky'); - const second = await enforceAbuseProtection(interaction, command, 'custom-risky'); - - assert.equal(first.allowed, true); - assert.equal(second.allowed, false); - assert.equal(second.policy.maxAttempts, 1); - assert.equal(second.policy.windowMs, 5_000); -}); - -test('logs anomaly warning after repeated blocked attempts', async () => { - resetAbuseProtectionState(); - const interaction = createInteraction({ guildId: 'guild-2', user: { id: 'user-2' } }); - const command = { - category: 'moderation', - abuseProtection: { - maxAttempts: 1, - windowMs: 60_000, - anomaly: { - threshold: 3, - windowMs: 60_000 - } - } - }; - - const warnLogs = []; - const originalWarn = logger.warn; - logger.warn = (message, meta) => { - warnLogs.push({ message, meta }); - }; - - try { - await enforceAbuseProtection(interaction, command, 'ban'); - await enforceAbuseProtection(interaction, command, 'ban'); - await enforceAbuseProtection(interaction, command, 'ban'); - await enforceAbuseProtection(interaction, command, 'ban'); - - const anomalyLog = warnLogs.find((entry) => entry.meta?.event === 'interaction.command.abuse_anomaly'); - assert.ok(anomalyLog, 'expected anomaly log event after repeated blocked attempts'); - assert.equal(anomalyLog.meta.command, 'ban'); - } finally { - logger.warn = originalWarn; - } -}); - -test('formats cooldown duration for user-facing messaging', () => { - assert.equal(formatCooldownDuration(500), '1s'); - assert.equal(formatCooldownDuration(61_000), '1m 1s'); -}); \ No newline at end of file diff --git a/tests/failure-paths/database.failure.test.js b/tests/failure-paths/database.failure.test.js deleted file mode 100644 index 84e1ff67d3..0000000000 --- a/tests/failure-paths/database.failure.test.js +++ /dev/null @@ -1,45 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; - -import { db, initializeDatabase } from '../../src/utils/database.js'; -import { pgDb } from '../../src/utils/postgresDatabase.js'; - -function resetDbSingleton() { - db.initialized = false; - db.db = null; - db.useFallback = false; - db.connectionType = 'none'; - db.degradedModeWarningShown = false; - db.degradedReason = null; -} - -test('DB down path falls back to memory with degraded status', async () => { - const originalConnect = pgDb.connect; - const originalGetLastFailure = pgDb.getLastFailure; - - try { - resetDbSingleton(); - - pgDb.connect = async () => false; - pgDb.getLastFailure = () => ({ - reason: 'POSTGRES_CONNECTION_FAILED', - message: 'connect ECONNREFUSED' - }); - - await initializeDatabase(); - - const status = db.getStatus(); - assert.equal(status.initialized, true); - assert.equal(status.isDegraded, true); - assert.equal(status.connectionType, 'memory'); - assert.equal(status.degradedReason, 'POSTGRES_UNAVAILABLE'); - - await db.set('health:key', { ok: true }); - const value = await db.get('health:key'); - assert.deepEqual(value, { ok: true }); - } finally { - pgDb.connect = originalConnect; - pgDb.getLastFailure = originalGetLastFailure; - resetDbSingleton(); - } -}); \ No newline at end of file diff --git a/tests/failure-paths/errorHandler.failure.test.js b/tests/failure-paths/errorHandler.failure.test.js deleted file mode 100644 index 080dc50232..0000000000 --- a/tests/failure-paths/errorHandler.failure.test.js +++ /dev/null @@ -1,169 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { MessageFlags } from 'discord.js'; - -import { - handleInteractionError, - replyUserError, - ErrorTypes, - createError -} from '../../src/utils/errorHandler.js'; -import { buildUserErrorEmbed } from '../../src/utils/embeds.js'; -import { logger } from '../../src/utils/logger.js'; - -function createLoggerCapture() { - const entries = []; - const originals = { - warn: logger.warn, - error: logger.error, - debug: logger.debug - }; - - logger.warn = (message, meta) => entries.push({ level: 'warn', message, meta }); - logger.error = (message, meta) => entries.push({ level: 'error', message, meta }); - logger.debug = (message, meta) => entries.push({ level: 'debug', message, meta }); - - return { - entries, - restore() { - logger.warn = originals.warn; - logger.error = originals.error; - logger.debug = originals.debug; - } - }; -} - -function createInteraction(overrides = {}) { - return { - id: 'interaction-1', - createdTimestamp: Date.now(), - deferred: false, - replied: false, - guildId: 'guild-1', - channelId: 'channel-1', - commandName: 'ping', - type: 2, - customId: null, - user: { id: 'user-1' }, - async reply() {}, - async editReply() {}, - ...overrides - }; -} - -test('expired interaction path logs INTERACTION_EXPIRED and skips reply', async () => { - const capture = createLoggerCapture(); - let replyCalled = false; - - try { - const interaction = createInteraction({ - createdTimestamp: Date.now() - (15 * 60 * 1000), - reply: async () => { - replyCalled = true; - } - }); - - const error = new Error('database timeout during operation'); - await handleInteractionError(interaction, error, {}); - - assert.equal(replyCalled, false, 'expired interactions should not attempt reply'); - - const expiredLog = capture.entries.find( - (entry) => entry.level === 'warn' && entry.meta?.event === 'interaction.error.expired' - ); - - assert.ok(expiredLog, 'should log interaction expiry event'); - assert.equal(expiredLog.meta?.errorCode, 'INTERACTION_EXPIRED'); - } finally { - capture.restore(); - } -}); - -test('Discord API response failure path logs response_unavailable when API says expired', async () => { - const capture = createLoggerCapture(); - - try { - const interaction = createInteraction({ - reply: async () => { - const err = new Error('Unknown interaction'); - err.code = 10062; - throw err; - } - }); - - const apiError = new Error('Discord API request failed'); - apiError.code = 10062; - - await handleInteractionError(interaction, apiError, {}); - - const unavailableLog = capture.entries.find( - (entry) => entry.level === 'warn' && entry.meta?.event === 'interaction.error.response_unavailable' - ); - - assert.ok(unavailableLog, 'should log unavailable response event'); - assert.equal(unavailableLog.meta?.errorCode, '10062'); - } finally { - capture.restore(); - } -}); - -test('buildUserErrorEmbed uses plain titles and typed colors', () => { - const validationEmbed = buildUserErrorEmbed('validation', 'Check your input.'); - assert.equal(validationEmbed.data.title, 'Invalid Input'); - assert.equal(validationEmbed.data.description, 'Check your input.'); - - const rateLimitEmbed = buildUserErrorEmbed('rate_limit', 'Wait a moment.'); - assert.equal(rateLimitEmbed.data.title, 'Too Fast'); -}); - -test('handleInteractionError does not add tip fields', async () => { - let replyPayload = null; - const interaction = createInteraction({ - reply: async (payload) => { - replyPayload = payload; - } - }); - - await handleInteractionError(interaction, createError('rate limited', ErrorTypes.RATE_LIMIT), {}); - - const embed = replyPayload?.embeds?.[0]; - assert.ok(embed); - assert.equal(embed.data.fields?.length ?? 0, 0, 'error embeds should not include tip fields'); - assert.equal(embed.data.title, 'Too Fast'); -}); - -test('replyUserError sends ephemeral replies for fresh interactions', async () => { - let replyPayload = null; - const interaction = createInteraction({ - reply: async (payload) => { - replyPayload = payload; - } - }); - - await replyUserError(interaction, { - type: ErrorTypes.PERMISSION, - message: 'You need Manage Server.' - }); - - assert.equal(replyPayload?.flags, MessageFlags.Ephemeral); - assert.equal(replyPayload?.embeds?.[0]?.data?.title, 'Permission Denied'); - assert.equal(replyPayload?.embeds?.[0]?.data?.description, 'You need Manage Server.'); -}); - -test('replyUserError uses editReply when interaction is deferred', async () => { - let editPayload = null; - const interaction = createInteraction({ - deferred: true, - editReply: async (payload) => { - editPayload = payload; - } - }); - - await replyUserError(interaction, { - type: ErrorTypes.VALIDATION, - message: 'Missing required option.' - }); - - assert.ok(editPayload); - assert.equal(editPayload.embeds?.[0]?.data?.title, 'Invalid Input'); -}); \ No newline at end of file diff --git a/tests/failure-paths/safeMathParser.test.js b/tests/failure-paths/safeMathParser.test.js deleted file mode 100644 index 7298dc09a6..0000000000 --- a/tests/failure-paths/safeMathParser.test.js +++ /dev/null @@ -1,33 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; - -import { evaluateMathExpression } from '../../src/utils/safeMathParser.js'; - -test('safe parser evaluates arithmetic precedence correctly', () => { - const result = evaluateMathExpression('2 + 2 * 3'); - assert.equal(result, 8); -}); - -test('safe parser evaluates trig with degree conversion', () => { - const result = evaluateMathExpression('sin(45 deg)'); - assert.ok(Math.abs(result - 0.7071067811865476) < 1e-10); -}); - -test('safe parser supports constants and exponent operator', () => { - const result = evaluateMathExpression('pi ^ 2'); - assert.ok(Math.abs(result - (Math.PI ** 2)) < 1e-10); -}); - -test('safe parser rejects code-like tokens', () => { - assert.throws( - () => evaluateMathExpression('process.exit()'), - /Unsupported token|Unsupported character/ - ); -}); - -test('safe parser rejects malformed expressions', () => { - assert.throws( - () => evaluateMathExpression('2 + (3 * 4'), - /Mismatched parentheses/ - ); -}); \ No newline at end of file diff --git a/tests/failure-paths/zodValidationCoverage.test.js b/tests/failure-paths/zodValidationCoverage.test.js deleted file mode 100644 index ce2d9ab211..0000000000 --- a/tests/failure-paths/zodValidationCoverage.test.js +++ /dev/null @@ -1,72 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; - -import { validateChatInputPayloadOrThrow } from '../../src/utils/commandInputValidation.js'; -import { validateGuildConfigOrThrow } from '../../src/utils/schemas.js'; - -test('zod command input validation accepts valid command payload', () => { - const interaction = { - commandName: 'ping', - options: { - data: [ - { name: 'target', type: 3, value: 'user123' }, - { name: 'count', type: 4, value: 3 } - ] - } - }; - - const result = validateChatInputPayloadOrThrow(interaction); - assert.equal(result.commandName, 'ping'); - assert.equal(result.options.length, 2); -}); - -test('zod command input validation rejects invalid option payload shape', () => { - const interaction = { - commandName: 'ping', - options: { - data: [ - { name: 'bad', type: 3, value: { nested: 'not-allowed' } } - ] - } - }; - - assert.throws( - () => validateChatInputPayloadOrThrow(interaction), - /Invalid command input payload/ - ); -}); - -test('zod guild config validation accepts valid config write payload', () => { - const config = { - prefix: '!', - logging: { - enabled: true, - enabledEvents: { - 'message.delete': true - } - }, - logIgnore: { - users: ['123'], - channels: ['456'] - } - }; - - const validated = validateGuildConfigOrThrow(config); - assert.equal(validated.prefix, '!'); - assert.equal(validated.logging.enabled, true); -}); - -test('zod guild config validation rejects invalid config write payload', () => { - const invalidConfig = { - prefix: '!', - logging: { - enabled: 'yes', - enabledEvents: {} - } - }; - - assert.throws( - () => validateGuildConfigOrThrow(invalidConfig), - /Invalid guild configuration payload/ - ); -}); \ No newline at end of file diff --git a/tests/panelStatus.reaction.test.js b/tests/panelStatus.reaction.test.js deleted file mode 100644 index c56d2d522a..0000000000 --- a/tests/panelStatus.reaction.test.js +++ /dev/null @@ -1,55 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; - -import { - messageHasSelectMenuCustomId, - formatPanelStatusField, - getReactionRolePanelStatus, -} from '../src/utils/panelStatus.js'; - -test('messageHasSelectMenuCustomId finds select menu custom_id', () => { - const message = { - components: [ - { - type: 1, - components: [{ type: 3, custom_id: 'reaction_roles', options: [] }], - }, - ], - }; - - assert.equal(messageHasSelectMenuCustomId(message, 'reaction_roles'), true); - assert.equal(messageHasSelectMenuCustomId(message, 'other'), false); -}); - -test('formatPanelStatusField describes deleted panels with repost hint', () => { - const value = formatPanelStatusField({ exists: false, reason: 'panel_deleted' }); - assert.match(value, /Repost Panel/); -}); - -test('getReactionRolePanelStatus scans for reaction_roles select menu', async () => { - const panelMessage = { - id: 'msg-rr', - author: { id: 'bot' }, - components: [{ type: 1, components: [{ type: 3, custom_id: 'reaction_roles' }] }], - }; - - const client = { user: { id: 'bot' } }; - const guild = { - id: 'g1', - channels: { - fetch: async () => ({ - messages: { - fetch: async () => new Map([['msg-rr', panelMessage]]), - }, - }), - }, - }; - - const status = await getReactionRolePanelStatus(client, guild, { - channelId: 'ch1', - messageId: 'stale', - }); - - assert.equal(status.exists, true); - assert.equal(status.recoveredId, 'msg-rr'); -}); diff --git a/tests/panelStatus.test.js b/tests/panelStatus.test.js deleted file mode 100644 index 486bb9820c..0000000000 --- a/tests/panelStatus.test.js +++ /dev/null @@ -1,144 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; - -import { - messageHasButtonCustomId, - getBotPanelStatus, - getTicketPanelStatus, -} from '../src/utils/panelStatus.js'; - -test('messageHasButtonCustomId finds nested button custom_id', () => { - const message = { - components: [ - { - type: 1, - components: [{ type: 2, custom_id: 'create_ticket', style: 1, label: 'Create' }], - }, - ], - }; - - assert.equal(messageHasButtonCustomId(message, 'create_ticket'), true); - assert.equal(messageHasButtonCustomId(message, 'other'), false); -}); - -test('messageHasButtonCustomId returns false for empty input', () => { - assert.equal(messageHasButtonCustomId(null, 'create_ticket'), false); - assert.equal(messageHasButtonCustomId({ components: [] }, ''), false); -}); - -test('getBotPanelStatus returns no_channel when channelId missing', async () => { - const client = { user: { id: 'bot' } }; - const guild = { id: 'g1' }; - - const status = await getBotPanelStatus(client, guild, { buttonCustomId: 'create_ticket' }); - assert.deepEqual(status, { exists: false, reason: 'no_channel' }); -}); - -test('getBotPanelStatus returns channel_missing when fetch fails', async () => { - const client = { user: { id: 'bot' } }; - const guild = { - id: 'g1', - channels: { - fetch: async () => null, - }, - }; - - const status = await getBotPanelStatus(client, guild, { - channelId: 'missing', - buttonCustomId: 'create_ticket', - }); - assert.equal(status.exists, false); - assert.equal(status.reason, 'channel_missing'); -}); - -test('getBotPanelStatus returns active when stored message has button', async () => { - const panelMessage = { - id: 'msg1', - url: 'https://discord.com/channels/1/2/3', - components: [{ type: 1, components: [{ type: 2, custom_id: 'create_ticket' }] }], - }; - - const client = { user: { id: 'bot' } }; - const guild = { - id: 'g1', - channels: { - fetch: async () => ({ - messages: { - fetch: async (idOrOpts) => { - if (idOrOpts === 'msg1') return panelMessage; - return null; - }, - }, - }), - }, - }; - - const status = await getBotPanelStatus(client, guild, { - channelId: 'ch1', - messageId: 'msg1', - buttonCustomId: 'create_ticket', - }); - - assert.equal(status.exists, true); - assert.equal(status.message.id, 'msg1'); -}); - -test('getBotPanelStatus scans channel when messageId missing or stale', async () => { - const recovered = { - id: 'recovered', - author: { id: 'bot' }, - components: [{ type: 1, components: [{ type: 2, custom_id: 'create_ticket' }] }], - }; - - const client = { user: { id: 'bot' } }; - const guild = { - id: 'g1', - channels: { - fetch: async () => ({ - messages: { - fetch: async (idOrOpts) => { - if (typeof idOrOpts === 'string') return null; - return new Map([['recovered', recovered]]); - }, - }, - }), - }, - }; - - const status = await getBotPanelStatus(client, guild, { - channelId: 'ch1', - messageId: 'stale', - buttonCustomId: 'create_ticket', - }); - - assert.equal(status.exists, true); - assert.equal(status.recoveredId, 'recovered'); -}); - -test('getTicketPanelStatus delegates to ticket config fields', async () => { - let captured = null; - const client = { - user: { id: 'bot' }, - }; - const guild = { - id: 'g1', - channels: { - fetch: async (channelId) => { - captured = channelId; - return { - messages: { - fetch: async () => new Map(), - }, - }; - }, - }, - }; - - const status = await getTicketPanelStatus(client, guild, { - ticketPanelChannelId: 'panel-ch', - ticketPanelMessageId: null, - }); - - assert.equal(captured, 'panel-ch'); - assert.equal(status.reason, 'panel_deleted'); -}); diff --git a/tests/prefixParser.test.js b/tests/prefixParser.test.js deleted file mode 100644 index f54939788f..0000000000 --- a/tests/prefixParser.test.js +++ /dev/null @@ -1,103 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; - -import { parsePrefixCommand, mapArgumentsToOptions } from '../src/utils/prefixParser.js'; -import { resolveCommandAlias, resolveSubcommandAlias } from '../src/config/commandAliases.js'; -import { getPrefixRestriction } from '../src/config/prefixRestrictions.js'; -import balanceCommand from '../src/commands/Economy/balance.js'; -import countCommand from '../src/commands/Fun/count.js'; -import helpCommand from '../src/commands/Core/help.js'; -import ticketCommand from '../src/commands/Ticket/ticket.js'; -import birthdayCommand from '../src/commands/Birthday/birthday.js'; - -const balanceData = balanceCommand.default?.data || balanceCommand.data || balanceCommand; -const countData = countCommand.default?.data || countCommand.data || countCommand; -const helpData = helpCommand.default?.data || helpCommand.data || helpCommand; -const ticketData = ticketCommand.default?.data || ticketCommand.data || ticketCommand; -const birthdayData = birthdayCommand.default?.data || birthdayCommand.data || birthdayCommand; - -test('parsePrefixCommand should detect the balance command with default prefix', () => { - const parsed = parsePrefixCommand('!balance', '!'); - assert.deepEqual(parsed, { commandName: 'balance', args: [] }); -}); - -test('parsePrefixCommand should split space-separated arguments', () => { - const parsed = parsePrefixCommand('!birthday set 3 15', '!'); - assert.deepEqual(parsed, { - commandName: 'birthday', - args: ['set', '3', '15'], - }); -}); - -test('parsePrefixCommand should parse quoted arguments', () => { - const parsed = parsePrefixCommand('!warn @user "being rude in chat"', '!'); - assert.deepEqual(parsed, { - commandName: 'warn', - args: ['@user', 'being rude in chat'], - }); -}); - -test('resolveCommandAlias should not shadow level or stats commands', () => { - assert.equal(resolveCommandAlias('level'), 'level'); - assert.equal(resolveCommandAlias('stats'), 'stats'); - assert.equal(resolveCommandAlias('verify'), 'verify'); - assert.equal(resolveCommandAlias('lvl'), 'rank'); - assert.equal(resolveCommandAlias('ss'), 'serverstats'); - assert.equal(resolveCommandAlias('vadmin'), 'verification'); -}); - -test('resolveSubcommandAlias should keep count disable intact', () => { - assert.equal(resolveSubcommandAlias('disable'), 'disable'); -}); - -test('mapArgumentsToOptions should correctly map a single user argument', () => { - const args = ['<@1234567890>']; - const options = mapArgumentsToOptions(args, balanceData); - - assert.equal(options.getUser('user') instanceof Object, false); - assert.equal(options._positional[0], '<@1234567890>'); - assert.equal(typeof options.getString('user'), 'string'); - assert.equal(options.getString('user'), '<@1234567890>'); -}); - -test('mapArgumentsToOptions should validate optional user option as valid', () => { - const args = []; - const options = mapArgumentsToOptions(args, balanceData); - const validation = options.validateRequired(); - assert.equal(validation.valid, true); - assert.equal(validation.missing.length, 0); -}); - -test('mapArgumentsToOptions should reject unknown subcommands', () => { - const options = mapArgumentsToOptions(['notreal'], countData); - const validation = options.validateRequired(); - assert.equal(validation.valid, false); - assert.equal(validation.missing[0].name, 'subcommand'); -}); - -test('mapArgumentsToOptions should resolve subcommand aliases', () => { - const options = mapArgumentsToOptions(['s', '3', '15'], birthdayData); - assert.equal(options.getSubcommand(), 'set'); - assert.equal(options.getInteger('month'), 3); - assert.equal(options.getInteger('day'), 15); -}); - -test('getPrefixRestriction should block slash-only commands', () => { - const restriction = getPrefixRestriction(helpCommand, [], resolveSubcommandAlias); - assert.equal(restriction.blocked, true); -}); - -test('getPrefixRestriction should block ticket command entirely via prefix', () => { - const restriction = getPrefixRestriction(ticketCommand, ['setup'], resolveSubcommandAlias); - assert.equal(restriction.blocked, true); -}); - -test('getPrefixRestriction should allow simple subcommands on mixed commands', () => { - const restriction = getPrefixRestriction(countCommand, ['status'], resolveSubcommandAlias); - assert.equal(restriction.blocked, false); -}); - -test('getPrefixRestriction should allow count disable via prefix', () => { - const restriction = getPrefixRestriction(countCommand, ['disable'], resolveSubcommandAlias); - assert.equal(restriction.blocked, false); -}); diff --git a/tests/prefixPermissions.test.js b/tests/prefixPermissions.test.js deleted file mode 100644 index 3ac76111bc..0000000000 --- a/tests/prefixPermissions.test.js +++ /dev/null @@ -1,196 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { PermissionFlagsBits } from 'discord.js'; - -import banCommand from '../src/commands/Moderation/ban.js'; -import pingCommand from '../src/commands/Core/ping.js'; -import { - getCommandDefaultPermissions, - memberMeetsCommandPermissions, - memberHasConfiguredModeratorRole, -} from '../src/utils/permissionGuard.js'; -import { executePrefixCommand } from '../src/utils/messageAdapter.js'; - -const banData = banCommand.default?.data || banCommand.data || banCommand; -const pingData = pingCommand.default?.data || pingCommand.data || pingCommand; - -function createMockMessage({ hasPermission = true, content = '!ban @user reason', modRoleId = null } = {}) { - const sentMessages = []; - - const channel = { - send: async (payload) => { - sentMessages.push(payload); - return { - id: `msg-${sentMessages.length}`, - edit: async (editPayload) => { - sentMessages[sentMessages.length - 1] = editPayload; - return sentMessages[sentMessages.length - 1]; - }, - deletable: true, - delete: async () => {}, - }; - }, - }; - - const required = getCommandDefaultPermissions(banData); - const rolesCache = new Map(); - if (modRoleId) { - rolesCache.set(modRoleId, { id: modRoleId }); - } - - const message = { - id: 'trigger-msg', - content, - author: { id: 'user-1', tag: 'User#0001', bot: false, toString: () => '<@user-1>' }, - member: { - id: 'user-1', - permissions: { - has: (perm) => { - if (!hasPermission) return false; - if (required == null) return true; - return (BigInt(perm) & required) === required || perm === required; - }, - }, - roles: { - cache: { - has: (roleId) => rolesCache.has(roleId), - }, - }, - }, - guild: { - id: 'guild-1', - ownerId: 'owner-1', - members: { cache: new Map(), fetch: async () => null }, - }, - channel, - client: { config: { bot: { prefix: '!' } } }, - createdTimestamp: Date.now(), - createdAt: new Date(), - deletable: false, - }; - - return { message, sentMessages }; -} - -function getEmbedDescription(payload) { - const embed = payload.embeds?.[0]; - return embed?.data?.description ?? embed?.description ?? ''; -} - -test('getCommandDefaultPermissions reads BanMembers from ban command', () => { - const permissions = getCommandDefaultPermissions(banData); - assert.equal(permissions, PermissionFlagsBits.BanMembers); -}); - -test('getCommandDefaultPermissions returns null when command has no default permissions', () => { - const permissions = getCommandDefaultPermissions(pingData); - assert.equal(permissions, null); -}); - -test('memberMeetsCommandPermissions allows guild owner without explicit flags', () => { - const member = { - id: 'owner-1', - guild: { ownerId: 'owner-1' }, - permissions: { has: () => false }, - }; - - assert.equal( - memberMeetsCommandPermissions(member, PermissionFlagsBits.BanMembers), - true, - ); -}); - -test('executePrefixCommand blocks prefix ban when member lacks BanMembers', async () => { - const { message, sentMessages } = createMockMessage({ hasPermission: false }); - let executeCalled = false; - - const command = { - data: banData, - execute: async () => { - executeCalled = true; - }, - category: 'moderation', - }; - - await executePrefixCommand(command, message, ['@user', 'reason'], message.client, '!'); - - assert.equal(executeCalled, false); - assert.equal(sentMessages.length, 1); - assert.match(getEmbedDescription(sentMessages[0]), /do not have permission/i); -}); - -test('executePrefixCommand allows prefix ban when member has BanMembers', async () => { - const { message, sentMessages } = createMockMessage({ hasPermission: true }); - let executeCalled = false; - - const command = { - data: banData, - execute: async () => { - executeCalled = true; - }, - category: 'moderation', - }; - - await executePrefixCommand(command, message, ['@user', 'reason'], message.client, '!'); - - assert.equal(executeCalled, true); - assert.equal(sentMessages.length, 0); -}); - -test('executePrefixCommand allows prefix ban when member has configured modRole without BanMembers', async () => { - const modRoleId = 'mod-role-1'; - const { message, sentMessages } = createMockMessage({ - hasPermission: false, - modRoleId, - }); - let executeCalled = false; - - const command = { - data: banData, - category: 'moderation', - execute: async () => { - executeCalled = true; - }, - }; - - await executePrefixCommand(command, message, ['@user', 'reason'], message.client, '!', { - modRole: modRoleId, - }); - - assert.equal(executeCalled, true); - assert.equal(sentMessages.length, 0); -}); - -test('memberHasConfiguredModeratorRole detects modRole membership', () => { - const member = { - roles: { - cache: { - has: (id) => id === 'mod-role-1', - }, - }, - }; - - assert.equal(memberHasConfiguredModeratorRole(member, { modRole: 'mod-role-1' }), true); - assert.equal(memberHasConfiguredModeratorRole(member, { modRole: 'other-role' }), false); -}); - -test('memberMeetsCommandPermissions accepts modRole for moderation commands', () => { - const member = { - id: 'user-1', - guild: { ownerId: 'owner-1' }, - permissions: { has: () => false }, - roles: { - cache: { - has: (id) => id === 'mod-role-1', - }, - }, - }; - - assert.equal( - memberMeetsCommandPermissions(member, PermissionFlagsBits.BanMembers, { - guildConfig: { modRole: 'mod-role-1' }, - commandCategory: 'moderation', - }), - true, - ); -}); diff --git a/tests/prefixResponse.test.js b/tests/prefixResponse.test.js deleted file mode 100644 index d4e6741c33..0000000000 --- a/tests/prefixResponse.test.js +++ /dev/null @@ -1,133 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; - -import banCommand from '../src/commands/Moderation/ban.js'; -import warnCommand from '../src/commands/Moderation/warn.js'; -import { ResponseCoordinator, buildPrefixUsage } from '../src/utils/responseCoordinator.js'; -import { executePrefixCommand, createMockInteraction } from '../src/utils/messageAdapter.js'; - -const banData = banCommand.default?.data || banCommand.data || banCommand; -const warnData = warnCommand.default?.data || warnCommand.data || warnCommand; - -function createMockMessage(content = '!ban') { - const sentMessages = []; - - const channel = { - send: async (payload) => { - sentMessages.push(payload); - return { - id: `msg-${sentMessages.length}`, - edit: async (editPayload) => { - sentMessages[sentMessages.length - 1] = editPayload; - return sentMessages[sentMessages.length - 1]; - }, - deletable: true, - delete: async () => {}, - }; - }, - }; - - const message = { - id: 'trigger-msg', - content, - author: { id: 'user-1', tag: 'User#0001', bot: false, toString: () => '<@user-1>' }, - member: { permissions: { has: () => true } }, - guild: { id: 'guild-1', members: { cache: new Map(), fetch: async () => null } }, - channel, - client: { config: { bot: { prefix: '!' } } }, - createdTimestamp: Date.now(), - createdAt: new Date(), - deletable: false, - }; - - return { message, sentMessages, channel }; -} - -test('buildPrefixUsage formats empty ban command args', () => { - const options = { subcommandName: null, subcommandGroupName: null, optionDefs: banData.options.map((opt) => ({ name: opt.name, required: opt.required })) }; - const usage = buildPrefixUsage('!', banData, options); - assert.equal(usage, '!ban [target] [reason]'); -}); - -function getEmbedTitle(payload) { - const embed = payload.embeds?.[0]; - return embed?.data?.title ?? embed?.title ?? ''; -} - -function getEmbedDescription(payload) { - const embed = payload.embeds?.[0]; - return embed?.data?.description ?? embed?.description ?? ''; -} - -test('ResponseCoordinator respondUsage finalizes and blocks second respond', async () => { - const { message, sentMessages } = createMockMessage(); - const interaction = { replied: false, deferred: false, _isPrefixCommand: true, _replyMessage: null }; - const coordinator = ResponseCoordinator.attach(interaction, { message }); - - await coordinator.respondUsage('!ban [target] [reason]'); - assert.equal(sentMessages.length, 1); - assert.match(getEmbedTitle(sentMessages[0]), /Wrong usage/); - assert.equal(coordinator.isUsageFinalized(), true); - - await coordinator.respond({ content: 'should not send' }); - assert.equal(sentMessages.length, 1); -}); - -test('ResponseCoordinator second respond edits existing message', async () => { - const { message, sentMessages } = createMockMessage(); - const interaction = { replied: false, deferred: false, _isPrefixCommand: true, _replyMessage: null }; - const coordinator = ResponseCoordinator.attach(interaction, { message }); - - await coordinator.respond({ content: 'first' }); - await coordinator.respond({ content: 'second' }); - assert.equal(sentMessages.length, 1); - assert.equal(sentMessages[0].content, 'second'); -}); - -test('executePrefixCommand sends exactly one usage embed for !ban with no args', async () => { - const { message, sentMessages } = createMockMessage('!ban'); - const command = { data: banData, execute: banCommand.default?.execute || banCommand.execute, category: 'moderation' }; - - await executePrefixCommand(command, message, [], message.client, '!'); - - assert.equal(sentMessages.length, 1); - assert.match(getEmbedTitle(sentMessages[0]), /Wrong usage/); - assert.match(getEmbedDescription(sentMessages[0]), /!ban \[target\] \[reason\]/); -}); - -test('executePrefixCommand sends exactly one usage embed for !warn with no args', async () => { - const { message, sentMessages } = createMockMessage('!warn'); - const command = { data: warnData, execute: warnCommand.default?.execute || warnCommand.execute, category: 'moderation' }; - - await executePrefixCommand(command, message, [], message.client, '!'); - - assert.equal(sentMessages.length, 1); - assert.match(getEmbedTitle(sentMessages[0]), /Wrong usage/); - assert.match(getEmbedDescription(sentMessages[0]), /!warn \[target\] \[reason\]/); -}); - -test('ResponseCoordinator edit after defer sends without recursion for prefix commands', async () => { - const { message, sentMessages } = createMockMessage(); - const mockInteraction = createMockInteraction(message, banData, []); - - await mockInteraction.deferReply(); - await mockInteraction.editReply({ content: 'balance result' }); - - assert.equal(sentMessages.length, 1); - assert.equal(sentMessages[0].content, 'balance result'); -}); - -test('createMockInteraction routes command reply through coordinator without duplicate sends', async () => { - const { message, sentMessages } = createMockMessage('!ban'); - const mockInteraction = createMockInteraction(message, banData, []); - - await mockInteraction._responseCoordinator.respondUsage('!ban [target] [reason]'); - await mockInteraction.reply({ content: 'duplicate attempt' }); - - assert.equal(sentMessages.length, 1); - assert.equal(coordinatorUsageFinalized(mockInteraction), true); -}); - -function coordinatorUsageFinalized(interaction) { - return interaction._responseCoordinator?.isUsageFinalized() === true; -} From 8e87f5aa3fdb17c454f30026930b8926e6d61571 Mon Sep 17 00:00:00 2001 From: codebymitch Date: Mon, 13 Jul 2026 16:09:10 +1000 Subject: [PATCH 2/3] Fix guildConfig import paths after restructure --- package-lock.json | 16 ++++++---------- src/config/bot.js | 7 +++---- src/services/birthdayService.js | 2 +- src/services/commandAccessService.js | 2 +- src/services/loggingService.js | 2 +- src/services/panelHealthService.js | 2 +- src/services/ticket.js | 2 +- src/services/verificationService.js | 2 +- 8 files changed, 15 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index 819d26b9c9..b11b7cdec6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,9 +7,6 @@ "": { "name": "titanbot", "version": "2.1.0", - "engines": { - "node": ">=20.10.0" - }, "dependencies": { "@discordjs/rest": "^2.6.1", "axios": "^1.15.2", @@ -23,9 +20,8 @@ "winston-daily-rotate-file": "^5.0.0", "zod": "^3.25.76" }, - "overrides": { - "form-data": "^4.0.6", - "undici": "^6.27.0" + "engines": { + "node": ">=20.10.0" } }, "node_modules/@asamuzakjp/css-color": { @@ -2172,7 +2168,7 @@ "discord-api-types": "^0.38.40", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "6.27.0" + "undici": "^6.27.0" } }, "@discordjs/util": { @@ -2286,7 +2282,7 @@ "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", "requires": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } @@ -2451,7 +2447,7 @@ "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "6.27.0" + "undici": "^6.27.0" }, "dependencies": { "@discordjs/collection": { @@ -3116,7 +3112,7 @@ "integrity": "sha512-rFVIERWq0FeKINDOTKobUamrvX9m4xdP8NTJfA8thYbLrkOfK6vgZ6UlTjHeBippjFq6xlPv7OGVT2INl6Y0hQ==", "requires": { "jsdom": "^26.0.0", - "undici": ">=6.11.1", + "undici": "^6.27.0", "ws": "^8.16.0" } }, diff --git a/src/config/bot.js b/src/config/bot.js index bd42c98669..86dc861cc3 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -23,10 +23,9 @@ export const botConfig = { // 5 = Competing activities: [ { - // Text users will see (example: "Playing /help | Titan Bot"). - name: "Made with ❤️", - // Activity type number (0 = Playing). - type: 0, + name: "Custom Status", // required by Discord API, not shown in the client + state: "stalking", // this is what people actually see + type: 4, // Custom }, ], }, diff --git a/src/services/birthdayService.js b/src/services/birthdayService.js index 75e237ce2e..002665d4eb 100644 --- a/src/services/birthdayService.js +++ b/src/services/birthdayService.js @@ -1,6 +1,6 @@ // birthdayService.js -import { getGuildConfig } from './guildConfig.js'; +import { getGuildConfig } from './config/guildConfig.js'; import { getGuildBirthdays, setBirthday as dbSetBirthday, deleteBirthday as dbDeleteBirthday, getMonthName, getBirthdayTrackingKey } from '../utils/database.js'; import { logger } from '../utils/logger.js'; import { TitanBotError, ErrorTypes } from '../utils/errorHandler.js'; diff --git a/src/services/commandAccessService.js b/src/services/commandAccessService.js index 55854c6eed..122b08f416 100644 --- a/src/services/commandAccessService.js +++ b/src/services/commandAccessService.js @@ -1,6 +1,6 @@ // commandAccessService.js -import { getGuildConfig, updateGuildConfig } from './guildConfig.js'; +import { getGuildConfig, updateGuildConfig } from './config/guildConfig.js'; import { normalizeCategoryKey, formatCategoryName, diff --git a/src/services/loggingService.js b/src/services/loggingService.js index 0d2f42f22f..3eddcd307d 100644 --- a/src/services/loggingService.js +++ b/src/services/loggingService.js @@ -1,7 +1,7 @@ // loggingService.js import { ChannelType } from 'discord.js'; -import { getGuildConfig, updateGuildConfig } from './guildConfig.js'; +import { getGuildConfig, updateGuildConfig } from './config/guildConfig.js'; import { logger } from '../utils/logger.js'; import { appendContentSection, diff --git a/src/services/panelHealthService.js b/src/services/panelHealthService.js index 98cbd60170..1a5a32196e 100644 --- a/src/services/panelHealthService.js +++ b/src/services/panelHealthService.js @@ -1,6 +1,6 @@ import { logger } from '../utils/logger.js'; import { getReactionRoleKey } from '../utils/database/keys.js'; -import { getGuildConfig, setGuildConfig, patchGuildConfig } from './guildConfig.js'; +import { getGuildConfig, setGuildConfig, patchGuildConfig } from './config/guildConfig.js'; import { getTicketPanelStatus, getVerificationPanelStatus, diff --git a/src/services/ticket.js b/src/services/ticket.js index e3c9e3b69e..158a364d1c 100644 --- a/src/services/ticket.js +++ b/src/services/ticket.js @@ -9,7 +9,7 @@ import { AttachmentBuilder, } from 'discord.js'; import { buildStandardLogEmbed, formatLogLine } from '../utils/logging/logEmbeds.js'; -import { getGuildConfig } from './guildConfig.js'; +import { getGuildConfig } from './config/guildConfig.js'; import { getTicketData, saveTicketData, deleteTicketData, getOpenTicketCountForUser, incrementTicketCounter } from '../utils/database.js'; import { logger } from '../utils/logger.js'; import { createEmbed, errorEmbed } from '../utils/embeds.js'; diff --git a/src/services/verificationService.js b/src/services/verificationService.js index 83e5676c89..49a548c07d 100644 --- a/src/services/verificationService.js +++ b/src/services/verificationService.js @@ -3,7 +3,7 @@ import { PermissionFlagsBits } from 'discord.js'; import { botConfig } from '../config/bot.js'; import { logger } from '../utils/logger.js'; -import { getGuildConfig, setGuildConfig } from './guildConfig.js'; +import { getGuildConfig, setGuildConfig } from './config/guildConfig.js'; import { createError, ErrorTypes } from '../utils/errorHandler.js'; import { insertVerificationAudit } from '../utils/database.js'; import { ensureTypedServiceError } from '../utils/serviceErrorBoundary.js'; From 0d0b9c66b5c5491a4cfddbd36ecd27cb33a3bfe8 Mon Sep 17 00:00:00 2001 From: codebymitch Date: Mon, 13 Jul 2026 16:20:48 +1000 Subject: [PATCH 3/3] Consolidate search commands and fix import/log noise --- .../{define.js => modules/search_define.js} | 40 +++++------- .../{google.js => modules/search_google.js} | 17 ++--- .../{urban.js => modules/search_urban.js} | 64 ++++++++----------- src/commands/Search/search.js | 54 ++++++++++++++++ src/services/music/playerHandler.js | 42 ++++++++++-- src/utils/moderation.js | 2 +- 6 files changed, 138 insertions(+), 81 deletions(-) rename src/commands/Search/{define.js => modules/search_define.js} (81%) rename src/commands/Search/{google.js => modules/search_google.js} (54%) rename src/commands/Search/{urban.js => modules/search_urban.js} (77%) create mode 100644 src/commands/Search/search.js diff --git a/src/commands/Search/define.js b/src/commands/Search/modules/search_define.js similarity index 81% rename from src/commands/Search/define.js rename to src/commands/Search/modules/search_define.js index 1db7259dd0..149ec0f3aa 100644 --- a/src/commands/Search/define.js +++ b/src/commands/Search/modules/search_define.js @@ -1,29 +1,19 @@ -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, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getColor } from '../../config/bot.js'; +import { createEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { handleInteractionError, replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.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, @@ -32,23 +22,23 @@ export default { }); 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) @@ -60,7 +50,7 @@ export default { return text; }) .join('\n\n'); - + if (definitions) { embed.addFields({ name: `**${meaning.partOfSpeech || 'Definition'}**`, @@ -69,18 +59,18 @@ export default { }); } }); - + 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, @@ -101,4 +91,4 @@ export default { } } }, -}; \ No newline at end of file +}; diff --git a/src/commands/Search/google.js b/src/commands/Search/modules/search_google.js similarity index 54% rename from src/commands/Search/google.js rename to src/commands/Search/modules/search_google.js index 947f6c7906..c277046d49 100644 --- a/src/commands/Search/google.js +++ b/src/commands/Search/modules/search_google.js @@ -1,17 +1,8 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { getColor } from '../../config/bot.js'; +import { createEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.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) { const query = interaction.options.getString('query'); const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}`; @@ -32,4 +23,4 @@ export default { commandName: 'google' }); }, -}; \ No newline at end of file +}; diff --git a/src/commands/Search/urban.js b/src/commands/Search/modules/search_urban.js similarity index 77% rename from src/commands/Search/urban.js rename to src/commands/Search/modules/search_urban.js index cec17d323b..b382fe30c0 100644 --- a/src/commands/Search/urban.js +++ b/src/commands/Search/modules/search_urban.js @@ -1,24 +1,14 @@ -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, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getColor } from '../../config/bot.js'; +import { createEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { handleInteractionError, replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.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, @@ -45,29 +35,29 @@ export default { }); }); }, 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') + .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, @@ -75,36 +65,36 @@ export default { }) .setURL(definition.permalink) .addFields( - { - name: 'Example', + { + name: 'Example', value: formattedExample, - inline: false + inline: false }, - { - name: 'Stats', + { + name: 'Stats', value: `${definition.thumbs_up.toLocaleString()} • ${definition.thumbs_down.toLocaleString()}`, - inline: true + inline: true }, - { - name: 'Author', + { + name: 'Author', value: definition.author || 'Anonymous', - inline: true + inline: true } ) - .setFooter({ + .setFooter({ text: 'Urban Dictionary', - iconURL: 'https://i.imgur.com/8aQrX3a.png' + 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, @@ -128,4 +118,4 @@ export default { } } }, -}; \ No newline at end of file +}; diff --git a/src/commands/Search/search.js b/src/commands/Search/search.js new file mode 100644 index 0000000000..52c1871902 --- /dev/null +++ b/src/commands/Search/search.js @@ -0,0 +1,54 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; + +import searchDefine from './modules/search_define.js'; +import searchGoogle from './modules/search_google.js'; +import searchUrban from './modules/search_urban.js'; + +export default { + data: new SlashCommandBuilder() + .setName('search') + .setDescription('Search the web and dictionaries') + .addSubcommand(subcommand => + subcommand + .setName('define') + .setDescription('Look up a word definition') + .addStringOption(option => + option.setName('word') + .setDescription('The word to look up') + .setRequired(true)) + ) + .addSubcommand(subcommand => + subcommand + .setName('google') + .setDescription('Search Google') + .addStringOption(option => + option.setName('query') + .setDescription('What would you like to search for?') + .setRequired(true)) + ) + .addSubcommand(subcommand => + subcommand + .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, config, client) { + const subcommand = interaction.options.getSubcommand(); + + switch (subcommand) { + case 'define': + return await searchDefine.execute(interaction, config, client); + case 'google': + return await searchGoogle.execute(interaction, config, client); + case 'urban': + return await searchUrban.execute(interaction, config, client); + default: + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Unknown subcommand' }); + } + } +}; diff --git a/src/services/music/playerHandler.js b/src/services/music/playerHandler.js index 0d11611774..b16290fbc3 100644 --- a/src/services/music/playerHandler.js +++ b/src/services/music/playerHandler.js @@ -72,22 +72,54 @@ export function setupPlayerHandler(client) { return; } + // Lavalink nodes often flap (reconnect -> error -> reconnect). Throttle all + // per-node messages to one line per interval, log the first connect only, + // and skip reconnect noise entirely since it is meaningless during flapping. + const nodeLogState = new Map(); + const NODE_LOG_INTERVAL_MS = 5 * 60 * 1000; + + const shouldLogNodeEvent = (nodeName) => { + const prev = nodeLogState.get(nodeName) ?? { lastLogAt: 0, hasConnected: false }; + const now = Date.now(); + if (now - prev.lastLogAt < NODE_LOG_INTERVAL_MS) { + return false; + } + nodeLogState.set(nodeName, { ...prev, lastLogAt: now }); + return true; + }; + + const markNodeConnected = (nodeName) => { + const prev = nodeLogState.get(nodeName) ?? { lastLogAt: 0, hasConnected: false }; + nodeLogState.set(nodeName, { ...prev, hasConnected: true }); + }; + client.riffy.on('nodeConnect', (node) => { + const prev = nodeLogState.get(node.name) ?? { lastLogAt: 0, hasConnected: false }; + if (prev.hasConnected) { + return; + } + markNodeConnected(node.name); logger.info(`Lavalink node "${node.name}" connected.`); }); + client.riffy.on('nodeReconnect', () => { + // Intentionally silent — reconnect spam is not actionable during flapping. + }); + client.riffy.on('nodeError', (node, error) => { - logger.error(`Lavalink node "${node.name}" error:`, error?.message || error); + if (!shouldLogNodeEvent(node.name)) { + return; + } + logger.warn(`Lavalink node "${node.name}" error: ${error?.message || error}`); }); client.riffy.on('nodeDisconnect', (node) => { + if (!shouldLogNodeEvent(node.name)) { + return; + } logger.warn(`Lavalink node "${node.name}" disconnected.`); }); - client.riffy.on('nodeReconnect', (node) => { - logger.info(`Lavalink node "${node.name}" reconnected.`); - }); - client.riffy.on('trackStart', async (player, track) => { try { const guildData = getGuildMusicData(player.guildId); diff --git a/src/utils/moderation.js b/src/utils/moderation.js index 1077863262..13ac5e4e53 100644 --- a/src/utils/moderation.js +++ b/src/utils/moderation.js @@ -1,7 +1,7 @@ // moderation.js import { logEvent as logAuditEvent, EVENT_TYPES } from '../services/loggingService.js'; -import { formatLogLine } from './logEmbeds.js'; +import { formatLogLine } from './logging/logEmbeds.js'; import { logger } from './logger.js'; import { getFromDb, setInDb } from './database.js';