From 4a5cdd9fe1f559d845d4870927175dc5e6824878 Mon Sep 17 00:00:00 2001 From: robertwalter Date: Sat, 17 Oct 2026 15:59:39 +0200 Subject: [PATCH 01/20] feat: rebrand TitanBot to NexoBot Rename user-facing branding, colors, package metadata, docs and local defaults. Support server links now point to GitHub issues. LICENSE keeps the original copyright line. Co-Authored-By: Claude Opus 5.5 --- .env.example | 4 +- .github/CODEOWNERS | 2 +- .github/workflows/migration-version-check.yml | 16 +- .github/workflows/restore-drill.yml | 16 +- CONTRIBUTING.md | 8 +- LICENSE | 1 + README.md | 408 ++++++------------ SECURITY.md | 18 +- docker-compose.yml | 26 +- package.json | 16 +- roadmap.txt | 2 +- scripts/backup.js | 2 +- scripts/migrate.js | 2 +- scripts/restore-drill.js | 2 +- src/app.js | 4 +- src/commands/Core/help.js | 7 +- src/commands/Core/support.js | 10 +- src/commands/Tools/embedbuilder.js | 2 +- src/commands/Tools/shorten.js | 2 +- src/config/bot.js | 6 +- src/config/shop/index.js | 2 +- src/handlers/help/helpButtons.js | 4 +- src/utils/ticket/ticketLogging.js | 2 +- 23 files changed, 211 insertions(+), 351 deletions(-) diff --git a/.env.example b/.env.example index c2eae4ad28..ab7fdd4f1f 100644 --- a/.env.example +++ b/.env.example @@ -23,11 +23,11 @@ CORS_ORIGIN=* # Railway: use the private POSTGRES_URL / DATABASE_URL variable (includes SSL). # Public proxy logs showing "invalid length of startup packet" or "SSL without ALPN" # are usually internet scanners — not your bot. -POSTGRES_URL=postgresql://postgres:yourpassword@localhost:5432/titanbot +POSTGRES_URL=postgresql://postgres:yourpassword@localhost:5432/nexobot POSTGRES_SSL= POSTGRES_HOST=localhost POSTGRES_PORT=5432 -POSTGRES_DB=titanbot +POSTGRES_DB=nexobot POSTGRES_USER=postgres POSTGRES_PASSWORD=yourpassword diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 28f9bc8b37..8236343112 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,2 @@ # All files require review and approval from the repository owner -* @codebymitch +* @Robertwlt03 diff --git a/.github/workflows/migration-version-check.yml b/.github/workflows/migration-version-check.yml index 971b9750b7..dbb7dd20e3 100644 --- a/.github/workflows/migration-version-check.yml +++ b/.github/workflows/migration-version-check.yml @@ -16,24 +16,24 @@ jobs: postgres: image: postgres:16 env: - POSTGRES_USER: titanbot - POSTGRES_PASSWORD: titanbot - POSTGRES_DB: titanbot + POSTGRES_USER: nexobot + POSTGRES_PASSWORD: nexobot + POSTGRES_DB: nexobot ports: - 5432:5432 options: >- - --health-cmd "pg_isready -U titanbot -d titanbot" + --health-cmd "pg_isready -U nexobot -d nexobot" --health-interval 10s --health-timeout 5s --health-retries 5 env: - POSTGRES_URL: postgresql://titanbot:titanbot@localhost:5432/titanbot + POSTGRES_URL: postgresql://nexobot:nexobot@localhost:5432/nexobot POSTGRES_HOST: localhost POSTGRES_PORT: 5432 - POSTGRES_DB: titanbot - POSTGRES_USER: titanbot - POSTGRES_PASSWORD: titanbot + POSTGRES_DB: nexobot + POSTGRES_USER: nexobot + POSTGRES_PASSWORD: nexobot POSTGRES_SSL: "false" SCHEMA_VERSION: "1" SCHEMA_VERSION_LABEL: "baseline-v1" diff --git a/.github/workflows/restore-drill.yml b/.github/workflows/restore-drill.yml index 325045f7b3..7e94be88ca 100644 --- a/.github/workflows/restore-drill.yml +++ b/.github/workflows/restore-drill.yml @@ -13,24 +13,24 @@ jobs: postgres: image: postgres:16 env: - POSTGRES_USER: titanbot - POSTGRES_PASSWORD: titanbot - POSTGRES_DB: titanbot + POSTGRES_USER: nexobot + POSTGRES_PASSWORD: nexobot + POSTGRES_DB: nexobot ports: - 5432:5432 options: >- - --health-cmd "pg_isready -U titanbot -d titanbot" + --health-cmd "pg_isready -U nexobot -d nexobot" --health-interval 10s --health-timeout 5s --health-retries 5 env: - POSTGRES_URL: postgresql://titanbot:titanbot@localhost:5432/titanbot + POSTGRES_URL: postgresql://nexobot:nexobot@localhost:5432/nexobot POSTGRES_HOST: localhost POSTGRES_PORT: 5432 - POSTGRES_DB: titanbot - POSTGRES_USER: titanbot - POSTGRES_PASSWORD: titanbot + POSTGRES_DB: nexobot + POSTGRES_USER: nexobot + POSTGRES_PASSWORD: nexobot POSTGRES_SSL: "false" SCHEMA_VERSION: "1" SCHEMA_VERSION_LABEL: "baseline-v1" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0b7e3d974b..37a17382b5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ -# Contributing to TitanBot +# Contributing to NexoBot -Thank you for your interest in contributing to TitanBot! This guide covers local setup, project conventions, and what we look for in pull requests. +Thank you for your interest in contributing to NexoBot! This guide covers local setup, project conventions, and what we look for in pull requests. ## Ways to Contribute @@ -8,7 +8,7 @@ Thank you for your interest in contributing to TitanBot! This guide covers local - New commands or enhancements to existing features - Documentation updates -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. +Before starting large features, open an issue on [GitHub](https://github.com/Robertwlt03/NexoBot/issues) so we can align on scope and avoid duplicate work. ## Getting Started @@ -55,7 +55,7 @@ Use descriptive branch names, for example: ## 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. +NexoBot 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. Useful commands: diff --git a/LICENSE b/LICENSE index 8df7f6c941..3b2f04d781 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,6 @@ MIT License +Copyright (c) 2026 Robertwlt03 (NexoBot) Copyright (c) 2026 codebymitch Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/README.md b/README.md index 700ac20739..82b63c770a 100644 --- a/README.md +++ b/README.md @@ -1,286 +1,140 @@ -# TitanBot - Ultimate Discord Bot +# Nexo – Ein Bot. Alle Funktionen. -**TitanBot** is a powerful, feature-rich Discord bot designed to enhance your server experience with comprehensive moderation tools, engaging economy systems, utility features, and much more. Built with modern Discord.js v14 and PostgreSQL for optimal performance and data persistence. +**NexoBot** (Anzeigename „Nexo“) ist ein modularer Discord-Community-Bot mit Moderation, Economy, Leveling, Tickets, Musik, Giveaways, Verifizierung und vielen Werkzeugen. Geschrieben in TypeScript mit discord.js v14, PostgreSQL als Datenspeicher. -[![Support Server](https://img.shields.io/badge/-Support%20Server-%235865F2?logo=discord&logoColor=white&style=flat-square&logoWidth=20)](https://discord.gg/8kJBYhTGW9) [![Discord.js](https://img.shields.io/npm/v/discord.js?style=flat-square&labelColor=%23202225&color=%23202225&logo=npm&logoColor=white&logoWidth=20)](https://www.npmjs.com/package/discord.js) +![TypeScript](https://img.shields.io/badge/-TypeScript-%233178C6?logo=typescript&logoColor=white&style=flat-square) ![PostgreSQL](https://img.shields.io/badge/-PostgreSQL-%23336791?logo=postgresql&logoColor=white&style=flat-square&logoWidth=20) -## Table of Contents - -- [Features Overview](#features-overview) -- [Quick Setup](#quick-setup) -- [Manual Installation Steps](#manual-installation-steps) -- [Support Server](https://discord.gg/QnWNz2dKCE) -- [Required Bot Intents](#bot-intents) -- [Contributing](CONTRIBUTING.md) - - -## Features Overview - -TitanBot offers a complete suite of tools for Discord server management and community engagement: - - - - - - -
- -### Moderation & Administration -- **Mass Actions** - Bulk ban/kick capabilities -- **User Notes** - Keep detailed moderation records -- **Case Management** - View and track all mod actions - -### Economy System -- **Shop & Inventory** - Buy and manage items -- **Gambling** - Risk it for rewards -- **Pay System** - Transfer money between users - -### Fun & Entertainment -- **Random Facts** - Learn something new -- **Wanted Poster** - Create fun wanted images -- **Text Reversal** - Reverse any text - -### Advanced Ticket System -- **Claim & Priority** - Staff ticket management -- **Ticket Limits** - Prevent spam -- **Transcript System** - Save ticket history - -### Server Stats -- **Member Counter** - Live member count channels -- **Voice Counters** - Track voice stats -- **Dynamic Updates** - Real-time channel updates - -### Reaction Roles -- **Role Assignment** - Self-assignable roles -- **Emoji Selection** - Reaction-based system -- **Multi-role Support** - Multiple role options - - - -### Leveling & XP System -- **XP Tracking** - Automatic message-based XP -- **Level Roles** - Auto-assign roles by level -- **Custom Configuration** - Personalize leveling - -### Giveaways & Events -- **Multiple Winners** - Support multi-winner giveaways -- **Auto Picking** - Automatic winner selection -- **Reroll System** - Pick new winners if needed - -### Birthday System -- **Birthday Tracking** - Never miss a birthday -- **Auto Announcements** - Celebrate automatically -- **Timezone Support** - Accurate worldwide tracking - -### Utility Tools -- **Report System** - Report issues to staff -- **Todo Lists** - Personal task management -- **First Message** - Jump to channel's first message - -### Welcome System -- **Welcome Messages** - Greet new members -- **Auto Roles** - Assign roles on join -- **Custom Embeds** - Personalized messages - -### Music -- **24/7 Mode** - Play music 24/7 -- **Interative Button System** - Manage music through buttons -- **Supports EVERY platform** - Supports spotify, deezer, youtube, apple music - -
- - -## Quick Setup (Recommended for non-coders) - -### Video Tutorial -For a detailed step-by-step setup guide, watch our comprehensive video tutorial: -[**TitanBot Setup Tutorial**](https://www.youtube.com/@TouchDisc) - -## Docker Deployment (Recommended) - -TitanBot is fully containerized for easy deployment. - -1. **Clone the repository:** - ```bash - git clone https://github.com/codebymitch/TitanBot.git - cd TitanBot - ``` - -2. **Configure environment variables:** - ```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. **Build and start the containers:** - ```bash - docker compose up -d --build - ``` - -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. 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 - ``` - 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 - -The bot is automatically published to GitHub Container Registry on every push to main. +## Inhalt + +- [Funktionen](#funktionen) +- [Commands](#commands) +- [Einrichtung](#einrichtung) + - [Lokal](#lokal) + - [Docker](#docker) + - [Railway](#railway) +- [Umgebungsvariablen](#umgebungsvariablen) +- [Intents und Berechtigungen](#intents-und-berechtigungen) +- [Mitwirken](CONTRIBUTING.md) +- [Lizenz und Credits](#lizenz-und-credits) + +## Funktionen + +- **Moderation** – Ban, Kick, Timeout, Verwarnungen, Fälle, Massenaktionen, Kanal-Sperren, Nutzernotizen +- **Economy** – Kontostand, Arbeit, Shop, Inventar, Glücksspiel, Überweisungen, Bestenliste +- **Leveling** – XP für Nachrichten, Rangkarten, Level-Rollen, Bestenliste +- **Tickets** – Panels, Claim, Priorität, Transkripte, Limits +- **Musik** – Lavalink v4 über Riffy, Warteschlange, Buttons, 24/7-Modus +- **Giveaways** – mehrere Gewinner, automatische Auslosung, Reroll +- **Willkommen** – Begrüßung, Abschied, Auto-Rollen +- **Verifizierung** – Button-Verifizierung und Auto-Verify +- **Weitere Module** – Geburtstage, Reaktionsrollen, Join-to-Create, Server-Statistiken, Logging, Bewerbungen, Zählspiel, Todo-Listen, Suche und viele Werkzeuge + +Alle Funktionen lassen sich pro Server über `/configwizard` und `/commands` konfigurieren und einzeln sperren. + +## Commands + +NexoBot bündelt die Funktionen in thematische Commands mit Subcommands, zum Beispiel `/eco balance`, `/mod warn` oder `/music play`. Die vollständige Zuordnung der früheren Einzel-Commands steht in [docs/COMMAND_MIGRATION.md](docs/COMMAND_MIGRATION.md). `/help` zeigt alle Commands im Bot an. + +## Einrichtung + +Voraussetzungen: ein Bot im [Discord Developer Portal](https://discord.com/developers/applications) mit Token und Client-ID sowie den unten genannten Intents. + +### Lokal + +Benötigt Node.js 20.10 oder neuer und PostgreSQL. + +```bash +git clone https://github.com/Robertwlt03/NexoBot.git +cd NexoBot +npm install +cp .env.example .env # DISCORD_TOKEN, CLIENT_ID und Datenbank eintragen +npm run build +npm start +``` + +Für die Entwicklung startet `npm run dev` den Bot mit automatischem Neuladen direkt aus den TypeScript-Quellen. `npm run typecheck` prüft die Typen ohne Build. + +Eine lokale Datenbank lässt sich am einfachsten per Docker starten: + +```bash +docker compose up -d db +``` + +### Docker + +```bash +cp .env.example .env # mindestens DISCORD_TOKEN, CLIENT_ID, GUILD_ID setzen +docker compose up -d --build +``` + +Compose startet den Bot und PostgreSQL. Die Datenbank-Defaults (`nexobot` / `password` / `nexobot`) lassen sich über `POSTGRES_USER`, `POSTGRES_PASSWORD` und `POSTGRES_DB` in der `.env` überschreiben. Einen eigenen Lavalink-Knoten startet das Profil `local-lavalink`: + +```bash +docker compose --profile local-lavalink up -d +``` + +Fertige Images: `ghcr.io/robertwlt03/nexobot`. + +### Railway + +1. Repository in Railway verbinden und einen PostgreSQL-Dienst hinzufügen. +2. Im Bot-Dienst `DISCORD_TOKEN`, `CLIENT_ID` und `GUILD_ID` setzen und `POSTGRES_URL` bzw. `DATABASE_URL` auf die private Railway-Datenbank-URL verweisen lassen. +3. Railway baut über das `Dockerfile`. Migrationen laufen beim Start automatisch (`AUTO_MIGRATE=true`). + +Healthchecks: `GET /health` (Prozess lebt) und `GET /ready` (Bot verbunden und Datenbank bereit). + +## Umgebungsvariablen + +Alle Variablen mit Beispielwerten stehen in [.env.example](.env.example). Die wichtigsten: + +| Variable | Pflicht | Beschreibung | +| --- | --- | --- | +| `DISCORD_TOKEN` | ja | Bot-Token aus dem Developer Portal | +| `CLIENT_ID` | ja | Application-ID; Slash-Commands werden global registriert | +| `GUILD_ID` | nein | Server-ID für die Einrichtung | +| `OWNER_IDS` | nein | Kommagetrennte Discord-IDs der Bot-Besitzer | +| `POSTGRES_URL` / `DATABASE_URL` | empfohlen | Verbindungs-URL zur Datenbank | +| `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD` | alternativ | Einzelwerte statt URL | +| `POSTGRES_SSL` | nein | SSL erzwingen (`true`/`false`) | +| `AUTO_MIGRATE` | nein | Migrationen beim Start ausführen (Standard `true`) | +| `PORT`, `WEB_HOST` | nein | Port und Host für `/health` und `/ready` (Standard `3000`, `0.0.0.0`) | +| `LOG_LEVEL`, `LOG_TO_FILE` | nein | Log-Level und Datei-Logging | +| `LAVALINK_NODES_FILE`, `LAVALINK_NODES` | nein | Lavalink-Knoten für Musik | +| `TMDB_API_KEY` | nein | Für die Film- und Seriensuche | + +Ohne erreichbare Datenbank läuft der Bot in einem eingeschränkten In-Memory-Modus. Für den Produktivbetrieb ist PostgreSQL Pflicht. + +### Datenbank-Werkzeuge ```bash -docker pull ghcr.io/codebymitch/titanbot:main +npm run migrate # Schema-Migrationen anwenden +npm run migrate:status # Stand anzeigen +npm run backup:db # Backup erstellen (pg_dump) +npm run restore:db # Backup einspielen ``` - -## Manual Installation Steps - -### Prerequisites -- Node.js 20.10.0 or higher -- PostgreSQL server (recommended) or memory storage fallback -- Discord bot application with proper intents - -1. **Clone the Repository** - ```bash - git clone https://github.com/codebymitch/TitanBot.git - cd TitanBot - ``` - -2. **Install Dependencies** - ```bash - npm install - ``` - -3. **Configure Environment Variables** - ```bash - cp .env.example .env - ``` - Edit `.env` with your configuration (only the following variables require configuration, leave remaining variables as default): - ```env - # Discord Bot Configuration - DISCORD_TOKEN=your_discord_bot_token_here - CLIENT_ID=your_discord_client_id_here - GUILD_ID=your_discord_guild_id_here - - # PostgreSQL Configuration (Primary Database) - POSTGRES_URL=postgresql://postgres:yourpassword@localhost:5432/titanbot - POSTGRES_HOST=localhost - POSTGRES_PORT=5432 - POSTGRES_DB=titanbot - POSTGRES_USER=postgres - POSTGRES_PASSWORD=yourpassword - ``` - - Production note: - - `NODE_ENV=production` - - `LOG_LEVEL=warn` for a clean production console (critical issues + startup status) - - `LOG_LEVEL=info` if you want more detailed operational logs - - If your chosen `PORT` is already used, TitanBot automatically tries the next port(s) - - Environment options reference: - - `NODE_ENV`: `development`, `production`, `test` (any non-`production` value is treated as non-production) - - `LOG_LEVEL`: `error`, `warn`, `info`, `http`, `verbose`, `debug`, `silly` - - Accepted aliases for `LOG_LEVEL` in this bot: `warns`, `warning`, `warnings` → `warn` - - Recommended production `.env` (easy mode + default mode): - ```env - NODE_ENV=production - LOG_LEVEL=warn - WEB_HOST=0.0.0.0 - PORT=3000 - PORT_RETRY_ATTEMPTS=5 - ``` - 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`). - -### Multiple servers - -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: -- Global slash commands may take up to about an hour to propagate on first deploy -- 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`) - -4. **Setup PostgreSQL Database** (Optional but recommended) - ```bash - # Create database and user - createdb titanbot - createuser titanbot - psql -c "ALTER USER titanbot PASSWORD 'yourpassword';" - psql -c "GRANT ALL PRIVILEGES ON DATABASE titanbot TO titanbot;" - ``` - -5. **Verify Database Setup** - ```bash - npm run migrate:check - ``` - -6. **Start the Bot** - ```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 -TitanBot requires the following Discord intents: -- **Guilds** -- **Guild Messages** -- **Message Content** -- **Guild Members** -- **Guild Message Reactions** -- **Guild Voice States** -- **Direct Messages** -- **Bot** -- **Applications.commands** - -### Required Permissions -- **View Channels** -- **Send Messages** -- **Embed Links** -- **Attach Files** -- **Read Message History** -- **Manage Messages** -- **Manage Channels** -- **Manage Roles** -- **Kick Members** -- **Manage Messages** -- **Ban Members** -- **Moderate Members** -- **Connect** - -## License - -TitanBot is released under the MIT License. See [LICENSE](LICENSE) for details. - -## Thank You - -Thank you for choosing TitanBot for your Discord server! We're constantly working to improve and add new features based on community feedback. - -*Last updated: May 2026* +Die Skripte laufen aus `dist/scripts/`, also vorher `npm run build` ausführen. + +## Intents und Berechtigungen + +Im Developer Portal aktivieren (Privileged Gateway Intents): + +- **Server Members Intent** +- **Message Content Intent** + +Der Bot nutzt außerdem die Intents Guilds, Guild Messages, Guild Message Reactions, Guild Voice States, Guild Moderation (Bans) und Direct Messages. + +Einladung mit den Scopes `bot` und `applications.commands` und diesen Berechtigungen: + +- Kanäle ansehen, Nachrichten senden, Links einbetten, Dateien anhängen, Nachrichtenverlauf lesen +- Nachrichten verwalten, Kanäle verwalten, Rollen verwalten +- Mitglieder kicken, Mitglieder bannen, Mitglieder moderieren (Timeout) +- Verbinden und Sprechen (Musik) + +## Lizenz und Credits + +NexoBot steht unter der MIT-Lizenz, siehe [LICENSE](LICENSE). + +Based on TitanBot by codebymitch, licensed under MIT. Basiert auf [TitanBot](https://github.com/codebymitch/TitanBot) von codebymitch. diff --git a/SECURITY.md b/SECURITY.md index cc4d3a8b46..2312162b6a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,16 +1,16 @@ -# Security Policy — TitanBot +# Security Policy — NexoBot We take security seriously. If you discover a vulnerability, please follow the policy below so we can triage and address it safely. ## Summary / Self-hosting clarity -TitanBot is distributed as open-source software for self-hosting. We (the maintainers) do not operate self-hosted instances and do not have access to user data or configurations on third-party deployments. Self-hosters are responsible for securing and operating their instances. This policy describes how to report vulnerabilities affecting the project code and guidance for self-hosters who discover security issues in their deployment. +NexoBot is distributed as open-source software for self-hosting. We (the maintainers) do not operate self-hosted instances and do not have access to user data or configurations on third-party deployments. Self-hosters are responsible for securing and operating their instances. This policy describes how to report vulnerabilities affecting the project code and guidance for self-hosters who discover security issues in their deployment. ## Reporting a Vulnerability (preferred) - Preferred channel: Open a private GitHub Security Advisory for this repository (recommended). -- If you cannot use GitHub Advisories, open a ticket: https: +- If you cannot use GitHub Advisories, open an issue without exploit details at https://github.com/Robertwlt03/NexoBot/issues and ask for a private contact. - Do NOT open a public issue with exploit details. -If you found a problem in your self-hosted instance that appears to be due to misconfiguration, please contact the instance operator/host first. If you believe the issue is caused by a vulnerability in TitanBot code, follow the reporting steps above and indicate whether the report comes from a self-hosted deployment. +If you found a problem in your self-hosted instance that appears to be due to misconfiguration, please contact the instance operator/host first. If you believe the issue is caused by a vulnerability in NexoBot code, follow the reporting steps above and indicate whether the report comes from a self-hosted deployment. ## Response timelines (what to expect) - Acknowledgement: within 72 hours. @@ -39,10 +39,10 @@ If you found a problem in your self-hosted instance that appears to be due to mi - Contact info for follow-up. ## Incident reporting from self-hosted deployments -If you operate a self-hosted TitanBot instance and suffer a security incident: +If you operate a self-hosted NexoBot instance and suffer a security incident: - Immediately rotate any exposed secrets (bot token, DB credentials, API keys). - Take a snapshot of logs/configuration for investigation (avoid sharing secrets). -- If you need upstream help, file a private security advisory and include sanitized reproduction steps and the TitanBot version/commit. +- If you need upstream help, file a private security advisory and include sanitized reproduction steps and the NexoBot version/commit. - The maintainers can only fix vulnerabilities in upstream code; we cannot rotate tokens, restore data, or remediate other hosts' deployments. ## Scope @@ -66,11 +66,11 @@ If you operate a self-hosted TitanBot instance and suffer a security incident: - We may assign a CVE or coordinate with CERT/other bodies for high-severity issues. ## Privacy & Telemetry -- TitanBot does not phone home or collect usage data by default. (If you plan to add telemetry, it must be opt-in, documented, and transparent.) +- NexoBot does not phone home or collect usage data by default. (If you plan to add telemetry, it must be opt-in, documented, and transparent.) - Maintainers do not receive data from self-hosted instances. If you choose to enable any telemetry, document what is collected and how to opt out. ## Contact - Preferred: GitHub Security Advisory for this repository -- Ticket: https: +- Issues (no exploit details): https://github.com/Robertwlt03/NexoBot/issues -Thank you for helping keep TitanBot safe. +Thank you for helping keep NexoBot safe. diff --git a/docker-compose.yml b/docker-compose.yml index 9de8fec548..6f0e30f5dc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ services: bot: build: . - container_name: titanbot + container_name: nexobot restart: unless-stopped env_file: - .env @@ -10,12 +10,12 @@ services: - DISCORD_TOKEN=${DISCORD_TOKEN} - CLIENT_ID=${CLIENT_ID} - GUILD_ID=${GUILD_ID} - - 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} + - DATABASE_URL=postgres://${POSTGRES_USER:-nexobot}:${POSTGRES_PASSWORD:-password}@db:5432/${POSTGRES_DB:-nexobot} + - POSTGRES_URL=postgres://${POSTGRES_USER:-nexobot}:${POSTGRES_PASSWORD:-password}@db:5432/${POSTGRES_DB:-nexobot} - POSTGRES_HOST=db - POSTGRES_PORT=5432 - - POSTGRES_DB=${POSTGRES_DB:-titanbot} - - POSTGRES_USER=${POSTGRES_USER:-titanbot} + - POSTGRES_DB=${POSTGRES_DB:-nexobot} + - POSTGRES_USER=${POSTGRES_USER:-nexobot} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-password} - POSTGRES_SSL=false - AUTO_MIGRATE=true @@ -35,29 +35,29 @@ services: retries: 3 start_period: 40s networks: - - titan-network + - nexo-network lavalink: profiles: - local-lavalink image: ghcr.io/lavalink-devs/lavalink:4 - container_name: titanbot-lavalink + container_name: nexobot-lavalink restart: unless-stopped environment: - LAVALINK_PASSWORD=${LAVALINK_PASSWORD:-youshallnotpass} volumes: - ./lavalink/application.yml:/opt/Lavalink/application.yml networks: - - titan-network + - nexo-network db: image: postgres:15-alpine - container_name: titanbot-db + container_name: nexobot-db restart: unless-stopped environment: - - POSTGRES_USER=${POSTGRES_USER:-titanbot} + - POSTGRES_USER=${POSTGRES_USER:-nexobot} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-password} - - POSTGRES_DB=${POSTGRES_DB:-titanbot} + - POSTGRES_DB=${POSTGRES_DB:-nexobot} volumes: - postgres_data:/var/lib/postgresql/data healthcheck: @@ -66,10 +66,10 @@ services: timeout: 5s retries: 5 networks: - - titan-network + - nexo-network networks: - titan-network: + nexo-network: driver: bridge volumes: diff --git a/package.json b/package.json index 2430aec17b..f0cdb249f8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,17 @@ { - "name": "titanbot", - "version": "2.1.0", - "description": "Modular Ultimate Community Bot by Touchpoint Support", + "name": "nexobot", + "version": "1.0.0", + "description": "Nexo - Ein Bot. Alle Funktionen. Modular Discord community bot.", + "author": "Robertwlt03", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/Robertwlt03/NexoBot.git" + }, + "bugs": { + "url": "https://github.com/Robertwlt03/NexoBot/issues" + }, + "homepage": "https://github.com/Robertwlt03/NexoBot#readme", "main": "src/app.js", "type": "module", "scripts": { diff --git a/roadmap.txt b/roadmap.txt index 890cfbe3df..4d06efe6b1 100644 --- a/roadmap.txt +++ b/roadmap.txt @@ -1,4 +1,4 @@ -TitanBot Roadmap / Structure Overview +NexoBot Roadmap / Structure Overview Goal - Keep this as a practical map of the codebase. diff --git a/scripts/backup.js b/scripts/backup.js index 4db7296ceb..18414f968c 100644 --- a/scripts/backup.js +++ b/scripts/backup.js @@ -104,7 +104,7 @@ async function run() { const outputPath = args.output ? path.resolve(args.output) - : path.join(backupDir, `titanbot-backup-${createTimestamp()}.dump`); + : path.join(backupDir, `nexobot-backup-${createTimestamp()}.dump`); const dumpArgs = [ '--format=custom', diff --git a/scripts/migrate.js b/scripts/migrate.js index 27dd907d93..24ea9b0b31 100644 --- a/scripts/migrate.js +++ b/scripts/migrate.js @@ -143,7 +143,7 @@ const migrate = async () => { logger.info('✨ Migration completed successfully!'); logger.info(`📌 Schema version recorded: v${EXPECTED_SCHEMA_VERSION} (${EXPECTED_SCHEMA_LABEL})`); - logger.info('📚 Your database is now ready for TitanBot.'); + logger.info('📚 Your database is now ready for NexoBot.'); } catch (error) { logger.error('❌ Migration failed:', error); process.exit(1); diff --git a/scripts/restore-drill.js b/scripts/restore-drill.js index a78cdcb3b8..58811224ed 100644 --- a/scripts/restore-drill.js +++ b/scripts/restore-drill.js @@ -116,7 +116,7 @@ async function run() { const stamp = createTimestamp(); const backupPath = path.join(backupDir, `restore-drill-${stamp}.dump`); - const drillDatabaseName = `titanbot_restore_drill_${stamp}`; + const drillDatabaseName = `nexobot_restore_drill_${stamp}`; const maintenanceUrl = buildDatabaseUrlWithName(sourceDatabaseUrl, 'postgres'); const drillDatabaseUrl = buildDatabaseUrlWithName(sourceDatabaseUrl, drillDatabaseName); diff --git a/src/app.js b/src/app.js index 693cd228ea..3addffff58 100644 --- a/src/app.js +++ b/src/app.js @@ -50,7 +50,7 @@ class TitanBot extends Client { async start() { try { - startupLog('Starting TitanBot...'); + startupLog('Starting NexoBot...'); await new Promise(resolve => setTimeout(resolve, 1000)); startupLog('Initializing database...'); @@ -204,7 +204,7 @@ class TitanBot extends Client { app.get('/', (req, res) => { res.status(200).json({ - message: 'TitanBot System Online', + message: 'NexoBot System Online', version: pkg.version, timestamp: new Date().toISOString() }); diff --git a/src/commands/Core/help.js b/src/commands/Core/help.js index 3d9d7eb783..e489777412 100644 --- a/src/commands/Core/help.js +++ b/src/commands/Core/help.js @@ -101,7 +101,7 @@ export async function createInitialHelpMenu(client) { }, { name: '\u200B', - value: `-# ${botName} is [open source](https://youtu.be/1jCZX8s3bJE?si=NPOYx-vxVE1I5vJK)`, + value: `-# ${botName} is [open source](https://github.com/Robertwlt03/NexoBot)`, inline: false, }, ], @@ -117,10 +117,6 @@ export async function createInitialHelpMenu(client) { .setLabel("Report Bug") .setStyle(ButtonStyle.Danger); - const supportButton = new ButtonBuilder() - .setLabel("Support Server") - .setURL("https://discord.gg/QnWNz2dKCE") - .setStyle(ButtonStyle.Link); const selectRow = createSelectMenu( CATEGORY_SELECT_ID, @@ -130,7 +126,6 @@ export async function createInitialHelpMenu(client) { const buttonRow = new ActionRowBuilder().addComponents([ bugReportButton, - supportButton, ]); return { diff --git a/src/commands/Core/support.js b/src/commands/Core/support.js index 032f3c894d..5983883f39 100644 --- a/src/commands/Core/support.js +++ b/src/commands/Core/support.js @@ -3,24 +3,24 @@ import { createEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; -const SUPPORT_SERVER_URL = "https://discord.gg/QnWNz2dKCE"; +const SUPPORT_URL = "https://github.com/Robertwlt03/NexoBot/issues"; export default { data: new SlashCommandBuilder() .setName("support") - .setDescription("Get link to the support server"), + .setDescription("Get help and report issues on GitHub"), async execute(interaction) { try { const supportButton = new ButtonBuilder() - .setLabel("Join Support Server") + .setLabel("Open GitHub Issues") .setStyle(ButtonStyle.Link) - .setURL(SUPPORT_SERVER_URL); + .setURL(SUPPORT_URL); const actionRow = new ActionRowBuilder().addComponents(supportButton); await InteractionHelper.safeReply(interaction, { embeds: [ - createEmbed({ title: "Need Help?", description: "Join our official support server for assistance, report bugs, or suggest features. If you are customizing this bot, remember to change the link in the code!" }), + createEmbed({ title: "Need Help?", description: "Need help, found a bug or have a feature idea? Open an issue on GitHub." }), ], components: [actionRow], flags: MessageFlags.Ephemeral, diff --git a/src/commands/Tools/embedbuilder.js b/src/commands/Tools/embedbuilder.js index 3da94f3dc1..03066238f8 100644 --- a/src/commands/Tools/embedbuilder.js +++ b/src/commands/Tools/embedbuilder.js @@ -431,7 +431,7 @@ async function handleSetFooter(selectInteraction, rootInteraction, state) { .setValue(state.footer?.text || '') .setMaxLength(2048) .setRequired(false) - .setPlaceholder('Built with TitanBot'), + .setPlaceholder('Built with Nexo'), ), new ActionRowBuilder().addComponents( new TextInputBuilder() diff --git a/src/commands/Tools/shorten.js b/src/commands/Tools/shorten.js index 4b5b9af5f5..b78057fb1f 100644 --- a/src/commands/Tools/shorten.js +++ b/src/commands/Tools/shorten.js @@ -69,7 +69,7 @@ export default { response = await fetch(apiUrl, { signal: controller.signal, headers: { - 'User-Agent': 'TitanBot URL Shortener/1.0' + 'User-Agent': 'NexoBot URL Shortener/1.0' } }); } catch (networkError) { diff --git a/src/config/bot.js b/src/config/bot.js index 86dc861cc3..d3623f485f 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -93,8 +93,8 @@ export const botConfig = { embeds: { colors: { // Main brand colors. - primary: "#336699", - secondary: "#2F3136", + primary: "#7C5CFF", + secondary: "#22D3EE", // Standard status colors for success/error/warning/info messages. success: "#57F287", @@ -141,7 +141,7 @@ export const botConfig = { }, footer: { // Default footer text used in bot embeds. - text: "Titan Bot", + text: "Nexo", // Footer icon URL (null = no icon). icon: null, }, diff --git a/src/config/shop/index.js b/src/config/shop/index.js index 17f6917989..b9159f7c84 100644 --- a/src/config/shop/index.js +++ b/src/config/shop/index.js @@ -4,7 +4,7 @@ import { botConfig } from '../bot.js'; const { currency } = botConfig.economy; export const shopConfig = { - name: 'TitanBot Shop', + name: 'Nexo Shop', currency: currency.name, currencyName: currency.name, currencyNamePlural: currency.namePlural || `${currency.name}s`, diff --git a/src/handlers/help/helpButtons.js b/src/handlers/help/helpButtons.js index b63568865f..9cf5cca946 100644 --- a/src/handlers/help/helpButtons.js +++ b/src/handlers/help/helpButtons.js @@ -43,7 +43,7 @@ export const helpBugReportButton = { const githubButton = new ButtonBuilder() .setLabel('🐛 Report Bug on GitHub') .setStyle(ButtonStyle.Link) - .setURL('https://github.com/codebymitch/TitanBot/issues'); + .setURL('https://github.com/Robertwlt03/NexoBot/issues'); const bugRow = new ActionRowBuilder().addComponents(githubButton); @@ -59,7 +59,7 @@ export const helpBugReportButton = { color: 'error' }); bugReportEmbed.setFooter({ - text: 'TitanBot Bug Reporting System', + text: 'Nexo Bug Reporting System', iconURL: client.user.displayAvatarURL() }); bugReportEmbed.setTimestamp(); diff --git a/src/utils/ticket/ticketLogging.js b/src/utils/ticket/ticketLogging.js index 0439783e40..ec16ef0402 100644 --- a/src/utils/ticket/ticketLogging.js +++ b/src/utils/ticket/ticketLogging.js @@ -119,7 +119,7 @@ async function createTicketLogEmbed(guild, event) { let inlineFields = []; let fields = []; let author = null; - let footer = { text: 'TitanBot Ticketing' }; + let footer = { text: 'Nexo Ticketing' }; switch (event.type) { case 'open': From 86786cfc37ad9a1c013fb3a0ca4e6cff7099c447 Mon Sep 17 00:00:00 2001 From: robertwalter Date: Sat, 17 Oct 2026 15:59:50 +0200 Subject: [PATCH 02/20] refactor: rename internal TitanBot identifiers to NexoBot Co-Authored-By: Claude Opus 5.5 --- src/app.js | 6 +- .../Community/modules/app_dashboard.js | 12 ++-- .../Economy/modules/economy_dashboard.js | 10 +-- src/commands/Fun/flip.js | 2 +- src/commands/Fun/roll.js | 8 +-- src/commands/Giveaway/gcreate.js | 8 +-- src/commands/Giveaway/gdelete.js | 14 ++-- src/commands/Giveaway/gend.js | 14 ++-- src/commands/Giveaway/greroll.js | 14 ++-- src/commands/JoinToCreate/jointocreate.js | 40 +++++------ .../JoinToCreate/modules/config_setup.js | 28 ++++---- src/commands/JoinToCreate/modules/setup.js | 6 +- src/commands/Leveling/leaderboard.js | 4 +- src/commands/Leveling/level.js | 4 +- src/commands/Leveling/leveladd.js | 4 +- src/commands/Leveling/levelremove.js | 6 +- src/commands/Leveling/levelset.js | 4 +- .../Leveling/modules/level_dashboard.js | 8 +-- src/commands/Leveling/rank.js | 4 +- src/commands/Moderation/ban.js | 8 +-- src/commands/Moderation/kick.js | 10 +-- src/commands/Moderation/massban.js | 4 +- src/commands/Moderation/masskick.js | 4 +- src/commands/Moderation/timeout.js | 10 +-- src/commands/Moderation/untimeout.js | 6 +- src/commands/Moderation/warn.js | 8 +-- src/commands/Reaction_roles/reactroles.js | 2 +- .../Ticket/modules/ticket_dashboard.js | 10 +-- src/commands/Tools/embedbuilder.js | 8 +-- .../modules/autoVerifyDashboard.js | 10 +-- .../modules/verification_dashboard.js | 10 +-- src/commands/Welcome/greet.js | 4 +- .../Welcome/modules/greet_dashboard.js | 12 ++-- src/handlers/giveawayButtons.js | 22 +++--- src/services/birthdayService.js | 8 +-- src/services/giveawayService.js | 36 +++++----- src/services/joinToCreateService.js | 68 +++++++++---------- src/services/leveling/leveling.js | 66 +++++++++--------- src/services/moderation/moderationService.js | 28 ++++---- src/services/music/musicActions.js | 64 ++++++++--------- src/services/reactionRoleService.js | 14 ++-- src/utils/commandPipeline.js | 2 +- src/utils/dashboardSession.js | 6 +- src/utils/embeds.js | 4 +- src/utils/errorHandler.js | 16 ++--- src/utils/giveaways.js | 10 +-- src/utils/interactionHelper.js | 4 +- src/utils/logger.js | 2 +- src/utils/serviceErrorBoundary.js | 4 +- 49 files changed, 328 insertions(+), 328 deletions(-) diff --git a/src/app.js b/src/app.js index 3addffff58..50d61419d0 100644 --- a/src/app.js +++ b/src/app.js @@ -18,7 +18,7 @@ import { shutdownMusic } from './services/music/playerHandler.js'; import pkg from '../package.json' with { type: 'json' }; import { EXPECTED_SCHEMA_VERSION, EXPECTED_SCHEMA_LABEL } from './config/database/schemaVersion.js'; -class TitanBot extends Client { +class NexoBot extends Client { constructor() { super({ intents: [ @@ -388,7 +388,7 @@ class TitanBot extends Client { } try { - const bot = new TitanBot(); + const bot = new NexoBot(); const setupShutdown = () => { process.on('SIGTERM', () => bot.shutdown('SIGTERM')); @@ -428,4 +428,4 @@ try { process.exit(1); } -export default TitanBot; \ No newline at end of file +export default NexoBot; \ No newline at end of file diff --git a/src/commands/Community/modules/app_dashboard.js b/src/commands/Community/modules/app_dashboard.js index 856a28b918..dbb7e3e2d0 100644 --- a/src/commands/Community/modules/app_dashboard.js +++ b/src/commands/Community/modules/app_dashboard.js @@ -21,7 +21,7 @@ import { 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 { NexoBotError, ErrorTypes, replyUserError } from '../../../utils/errorHandler.js'; import { safeDeferInteraction } from '../../../utils/interactionValidator.js'; import { getApplicationSettings, @@ -159,7 +159,7 @@ export default { roles.length === 0; if (isCompletelyUnconfigured) { - throw new TitanBotError( + throw new NexoBotError( 'Applications system not set up', ErrorTypes.CONFIGURATION, 'The applications system has not been configured yet. Please run `/app-admin setup` to create your first application.', @@ -184,9 +184,9 @@ export default { await showApplicationDashboard(interaction, defaultRole, settings, roles, guildId, client); } catch (error) { - if (error instanceof TitanBotError) throw error; + if (error instanceof NexoBotError) throw error; logger.error('Unexpected error in app_dashboard:', error); - throw new TitanBotError( + throw new NexoBotError( `Applications dashboard failed: ${error.message}`, ErrorTypes.UNKNOWN, 'Failed to open the applications dashboard.', @@ -389,14 +389,14 @@ function setupCollectors(interaction, settings, roles, guildId, client, selected break; } } catch (error) { - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { logger.debug(`Applications config validation error: ${error.message}`); } else { logger.error('Unexpected applications dashboard error:', error); } const errorMessage = - error instanceof TitanBotError + error instanceof NexoBotError ? error.userMessage || 'An error occurred while processing your selection.' : 'An unexpected error occurred while updating the configuration.'; diff --git a/src/commands/Economy/modules/economy_dashboard.js b/src/commands/Economy/modules/economy_dashboard.js index 04f507ff97..db2514b827 100644 --- a/src/commands/Economy/modules/economy_dashboard.js +++ b/src/commands/Economy/modules/economy_dashboard.js @@ -17,7 +17,7 @@ import { getColor, BotConfig } from '../../../config/bot.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; import { successEmbed } from '../../../utils/embeds.js'; import { logger } from '../../../utils/logger.js'; -import { TitanBotError, ErrorTypes, replyUserError } from '../../../utils/errorHandler.js'; +import { NexoBotError, 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'; @@ -177,14 +177,14 @@ export default { break; } } catch (error) { - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { logger.debug(`Economy dashboard validation error: ${error.message}`); } else { logger.error('Unexpected economy dashboard error:', error); } const errorMessage = - error instanceof TitanBotError + error instanceof NexoBotError ? error.userMessage || 'An error occurred while processing your selection.' : 'An unexpected error occurred while processing your request.'; @@ -213,9 +213,9 @@ export default { } }); } catch (error) { - if (error instanceof TitanBotError) throw error; + if (error instanceof NexoBotError) throw error; logger.error('Unexpected error in economy_dashboard:', error); - throw new TitanBotError( + throw new NexoBotError( `Economy dashboard failed: ${error.message}`, ErrorTypes.UNKNOWN, 'Failed to open the economy dashboard.', diff --git a/src/commands/Fun/flip.js b/src/commands/Fun/flip.js index 2eeef5cfa9..8fedb9090d 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 { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { diff --git a/src/commands/Fun/roll.js b/src/commands/Fun/roll.js index 988fe1b663..e9eb0d31b5 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 { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { @@ -28,7 +28,7 @@ export default { const match = notation.match(/^(\d*)d(\d+)([\+\-]\d+)?$/); if (!match) { - throw new TitanBotError( + throw new NexoBotError( `Invalid dice notation: ${notation}`, ErrorTypes.USER_INPUT, 'Invalid notation. Use format like `1d20` or `3d6+5`.' @@ -40,7 +40,7 @@ export default { const modifier = parseInt(match[3] || "0", 10); if (numDice < 1 || numDice > 20) { - throw new TitanBotError( + throw new NexoBotError( `Too many dice requested: ${numDice}`, ErrorTypes.VALIDATION, 'Please keep the number of dice between 1 and 20.' @@ -48,7 +48,7 @@ export default { } if (numSides < 1 || numSides > 1000) { - throw new TitanBotError( + throw new NexoBotError( `Invalid number of sides: ${numSides}`, ErrorTypes.VALIDATION, 'Please keep the number of sides between 1 and 1000.' diff --git a/src/commands/Giveaway/gcreate.js b/src/commands/Giveaway/gcreate.js index e5f19c048c..0a199ef5b0 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 } from '../../utils/errorHandler.js'; +import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { saveGiveaway } from '../../utils/giveaways.js'; import { parseDuration, @@ -58,7 +58,7 @@ export default { await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!interaction.inGuild()) { - throw new TitanBotError( + throw new NexoBotError( 'Giveaway command used outside guild', ErrorTypes.VALIDATION, 'This command can only be used in a server.', @@ -67,7 +67,7 @@ export default { } if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { - throw new TitanBotError( + throw new NexoBotError( 'User lacks ManageGuild permission', ErrorTypes.PERMISSION, "You need the 'Manage Server' permission to start a giveaway.", @@ -87,7 +87,7 @@ export default { const prizeName = validatePrize(prize); if (!targetChannel.isTextBased()) { - throw new TitanBotError( + throw new NexoBotError( 'Target channel is not text-based', ErrorTypes.VALIDATION, 'The channel must be a text channel.', diff --git a/src/commands/Giveaway/gdelete.js b/src/commands/Giveaway/gdelete.js index 838209c0c0..264e6c69da 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 } from '../../utils/errorHandler.js'; +import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { getGuildGiveaways, deleteGiveaway } from '../../utils/giveaways.js'; import { logEvent, EVENT_TYPES } from '../../services/loggingService.js'; @@ -22,7 +22,7 @@ export default { async execute(interaction) { if (!interaction.inGuild()) { - throw new TitanBotError( + throw new NexoBotError( 'Giveaway command used outside guild', ErrorTypes.VALIDATION, 'This command can only be used in a server.', @@ -31,7 +31,7 @@ export default { } if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { - throw new TitanBotError( + throw new NexoBotError( 'User lacks ManageGuild permission', ErrorTypes.PERMISSION, "You need the 'Manage Server' permission to delete a giveaway.", @@ -44,7 +44,7 @@ export default { const messageId = interaction.options.getString("messageid"); if (!messageId || !/^\d+$/.test(messageId)) { - throw new TitanBotError( + throw new NexoBotError( 'Invalid message ID format', ErrorTypes.VALIDATION, 'Please provide a valid message ID.', @@ -56,7 +56,7 @@ export default { const giveaway = giveaways.find(g => g.messageId === messageId); if (!giveaway) { - throw new TitanBotError( + throw new NexoBotError( `Giveaway not found: ${messageId}`, ErrorTypes.VALIDATION, "No giveaway was found with that message ID.", @@ -113,7 +113,7 @@ export default { ); if (!removedFromDatabase) { - throw new TitanBotError( + throw new NexoBotError( `Failed to delete giveaway from database: ${messageId}`, ErrorTypes.UNKNOWN, 'The giveaway could not be removed from the database. Please try again.', @@ -125,7 +125,7 @@ export default { const stillExistsInDatabase = giveawaysAfterDelete.some(g => g.messageId === messageId); if (stillExistsInDatabase) { - throw new TitanBotError( + throw new NexoBotError( `Giveaway still exists after deletion: ${messageId}`, ErrorTypes.UNKNOWN, 'Deletion did not persist in the database. Please try again.', diff --git a/src/commands/Giveaway/gend.js b/src/commands/Giveaway/gend.js index 700ca5eaeb..53f42a3f39 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 } from '../../utils/errorHandler.js'; +import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { getGuildGiveaways, saveGiveaway } from '../../utils/giveaways.js'; import { endGiveaway as endGiveawayService, @@ -27,7 +27,7 @@ export default { async execute(interaction) { if (!interaction.inGuild()) { - throw new TitanBotError( + throw new NexoBotError( 'Giveaway command used outside guild', ErrorTypes.VALIDATION, 'This command can only be used in a server.', @@ -36,7 +36,7 @@ export default { } if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { - throw new TitanBotError( + throw new NexoBotError( 'User lacks ManageGuild permission', ErrorTypes.PERMISSION, "You need the 'Manage Server' permission to end a giveaway.", @@ -49,7 +49,7 @@ export default { const messageId = interaction.options.getString("messageid"); if (!messageId || !/^\d+$/.test(messageId)) { - throw new TitanBotError( + throw new NexoBotError( 'Invalid message ID format', ErrorTypes.VALIDATION, 'Please provide a valid message ID.', @@ -61,7 +61,7 @@ export default { const giveaway = giveaways.find(g => g.messageId === messageId); if (!giveaway) { - throw new TitanBotError( + throw new NexoBotError( `Giveaway not found: ${messageId}`, ErrorTypes.VALIDATION, "No giveaway was found with that message ID in the database.", @@ -87,7 +87,7 @@ export default { }); if (!channel || !channel.isTextBased()) { - throw new TitanBotError( + throw new NexoBotError( `Channel not found: ${updatedGiveaway.channelId}`, ErrorTypes.VALIDATION, "Could not find the channel where the giveaway was hosted. The giveaway state has been updated.", @@ -103,7 +103,7 @@ export default { }); if (!message) { - throw new TitanBotError( + throw new NexoBotError( `Message not found: ${messageId}`, ErrorTypes.VALIDATION, "Could not find the giveaway message. The giveaway state has been updated.", diff --git a/src/commands/Giveaway/greroll.js b/src/commands/Giveaway/greroll.js index 64be4b3e03..0f97dccddc 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 } from '../../utils/errorHandler.js'; +import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { getGuildGiveaways, saveGiveaway } from '../../utils/giveaways.js'; import { selectWinners, @@ -25,7 +25,7 @@ export default { async execute(interaction) { if (!interaction.inGuild()) { - throw new TitanBotError( + throw new NexoBotError( 'Giveaway command used outside guild', ErrorTypes.VALIDATION, 'This command can only be used in a server.', @@ -34,7 +34,7 @@ export default { } if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { - throw new TitanBotError( + throw new NexoBotError( 'User lacks ManageGuild permission', ErrorTypes.PERMISSION, "You need the 'Manage Server' permission to reroll a giveaway.", @@ -47,7 +47,7 @@ export default { const messageId = interaction.options.getString("messageid"); if (!messageId || !/^\d+$/.test(messageId)) { - throw new TitanBotError( + throw new NexoBotError( 'Invalid message ID format', ErrorTypes.VALIDATION, 'Please provide a valid message ID.', @@ -63,7 +63,7 @@ export default { const giveaway = giveaways.find(g => g.messageId === messageId); if (!giveaway) { - throw new TitanBotError( + throw new NexoBotError( `Giveaway not found: ${messageId}`, ErrorTypes.VALIDATION, "No giveaway was found with that message ID in the database.", @@ -72,7 +72,7 @@ export default { } if (!giveaway.isEnded && !giveaway.ended) { - throw new TitanBotError( + throw new NexoBotError( `Giveaway still active: ${messageId}`, ErrorTypes.VALIDATION, "This giveaway is still active. Please use `/gend` to end it first.", @@ -83,7 +83,7 @@ export default { const participants = giveaway.participants || []; if (participants.length < giveaway.winnerCount) { - throw new TitanBotError( + throw new NexoBotError( `Insufficient participants for reroll: ${participants.length} < ${giveaway.winnerCount}`, ErrorTypes.VALIDATION, "Not enough entries to pick the required number of winners.", diff --git a/src/commands/JoinToCreate/jointocreate.js b/src/commands/JoinToCreate/jointocreate.js index 593212f856..0eae039ff6 100644 --- a/src/commands/JoinToCreate/jointocreate.js +++ b/src/commands/JoinToCreate/jointocreate.js @@ -2,7 +2,7 @@ import { getColor } from '../../config/bot.js'; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, StringSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, EmbedBuilder, LabelBuilder } from 'discord.js'; import { successEmbed, warningEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; -import { TitanBotError, ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; +import { NexoBotError, ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { initializeJoinToCreate, @@ -76,7 +76,7 @@ export default { try { if (!hasManageGuildPermission(interaction.member)) { - throw new TitanBotError( + throw new NexoBotError( 'User lacks ManageGuild permission', ErrorTypes.PERMISSION, 'You need **Manage Server** permission to use this command.' @@ -100,9 +100,9 @@ export default { try { let errorMessage = 'An error occurred while executing this command.'; - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { errorMessage = error.userMessage || 'An error occurred. Please try again.'; - logger.debug(`TitanBotError [${error.type}]: ${error.message}`, error.context || {}); + logger.debug(`NexoBotError [${error.type}]: ${error.message}`, error.context || {}); } else { logger.error('Unexpected error in jointocreate command:', error); errorMessage = 'An unexpected error occurred. Please try again or contact support.'; @@ -152,7 +152,7 @@ async function handleSetupSubcommand(interaction, client) { const primaryTrigger = activeTriggerChannels[0]; const errorMessage = `This server already has a Join to Create channel set up: ${primaryTrigger}\n\nUse \`/jointocreate dashboard\` to modify it, or remove it first before creating a new one.`; - throw new TitanBotError( + throw new NexoBotError( 'Guild already has a Join to Create channel', ErrorTypes.VALIDATION, errorMessage, @@ -213,10 +213,10 @@ async function handleSetupSubcommand(interaction, client) { } catch (error) { logger.error('Error in handleSetupSubcommand:', error); - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { throw error; } - throw new TitanBotError( + throw new NexoBotError( `Setup failed: ${error.message}`, ErrorTypes.DISCORD_API, 'Failed to set up Join to Create system. Please check bot permissions.' @@ -286,7 +286,7 @@ async function handleConfigSubcommand(interaction, client) { const message = await interaction.fetchReply(); if (!message || typeof message.createMessageComponentCollector !== 'function') { - throw new TitanBotError( + throw new NexoBotError( 'Failed to fetch interaction reply for collector setup', ErrorTypes.DISCORD_API, 'Failed to open configuration controls. Please run `/jointocreate dashboard` again.' @@ -321,11 +321,11 @@ async function handleConfigSubcommand(interaction, client) { await handleChannelDeletion(buttonInteraction, triggerChannel, currentConfig, client); } } catch (error) { - const userMessage = error instanceof TitanBotError + const userMessage = error instanceof NexoBotError ? error.userMessage || 'An error occurred.' : 'An error occurred while processing your request.'; - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { logger.debug(`Button interaction validation error: ${error.message}`, error.context || {}); } else { logger.error('Unexpected error in config button interaction:', error); @@ -353,10 +353,10 @@ async function handleConfigSubcommand(interaction, client) { }); } catch (error) { - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { throw error; } - throw new TitanBotError( + throw new NexoBotError( `Config failed: ${error.message}`, ErrorTypes.DATABASE, 'Failed to load configuration.' @@ -438,11 +438,11 @@ async function handleNameTemplateModal(interaction, triggerChannel, currentConfi if (error.code === 'INTERACTION_COLLECTOR_ERROR') { return; } - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { throw error; } logger.error('Unexpected error in name template modal:', error); - throw new TitanBotError( + throw new NexoBotError( `Modal error: ${error.message}`, ErrorTypes.UNKNOWN, 'An error occurred while updating the template.' @@ -506,11 +506,11 @@ async function handleUserLimitModal(interaction, triggerChannel, currentConfig, if (error.code === 'INTERACTION_COLLECTOR_ERROR') { return; } - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { throw error; } logger.error('Unexpected error in user limit modal:', error); - throw new TitanBotError( + throw new NexoBotError( `Modal error: ${error.message}`, ErrorTypes.UNKNOWN, 'An error occurred while updating the user limit.' @@ -574,11 +574,11 @@ async function handleBitrateModal(interaction, triggerChannel, currentConfig, cl if (error.code === 'INTERACTION_COLLECTOR_ERROR') { return; } - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { throw error; } logger.error('Unexpected error in bitrate modal:', error); - throw new TitanBotError( + throw new NexoBotError( `Modal error: ${error.message}`, ErrorTypes.UNKNOWN, 'An error occurred while updating the bitrate.' @@ -671,11 +671,11 @@ async function handleChannelDeletion(interaction, triggerChannel, currentConfig, }); } catch (error) { - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { throw error; } logger.error('Unexpected error in handleChannelDeletion:', error); - throw new TitanBotError( + throw new NexoBotError( `Deletion error: ${error.message}`, ErrorTypes.UNKNOWN, 'An error occurred while removing the channel.' diff --git a/src/commands/JoinToCreate/modules/config_setup.js b/src/commands/JoinToCreate/modules/config_setup.js index 460d337f2f..2e18c9caed 100644 --- a/src/commands/JoinToCreate/modules/config_setup.js +++ b/src/commands/JoinToCreate/modules/config_setup.js @@ -13,7 +13,7 @@ import { 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 { NexoBotError, ErrorTypes, replyUserError } from '../../../utils/errorHandler.js'; import { getJoinToCreateConfig, updateJoinToCreateConfig, @@ -30,7 +30,7 @@ export default { const currentConfig = await getJoinToCreateConfig(client, guildId); if (!currentConfig.triggerChannels.includes(triggerChannel.id)) { - throw new TitanBotError( + throw new NexoBotError( `Channel ${triggerChannel.id} is not a Join to Create trigger`, ErrorTypes.VALIDATION, `${triggerChannel} is not configured as a Join to Create trigger channel.` @@ -126,13 +126,13 @@ time: 60000 break; } } catch (error) { - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { logger.debug(`Configuration validation error: ${error.message}`, error.context || {}); } else { logger.error('Unexpected configuration menu error:', error); } - const errorMessage = error instanceof TitanBotError + const errorMessage = error instanceof NexoBotError ? error.userMessage || 'An error occurred while processing your selection.' : 'An error occurred while processing your selection.'; @@ -155,11 +155,11 @@ time: 60000 } }); } catch (error) { - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { throw error; } logger.error('Unexpected error in config_setup:', error); - throw new TitanBotError( + throw new NexoBotError( `Config setup failed: ${error.message}`, ErrorTypes.UNKNOWN, 'Failed to configure Join to Create system.' @@ -224,13 +224,13 @@ time: 600_000, await message.delete().catch(() => {}); } catch (error) { - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { logger.debug(`Template validation error: ${error.message}`); } else { logger.error('Template update error:', error); } - const errorMessage = error instanceof TitanBotError + const errorMessage = error instanceof NexoBotError ? error.userMessage || 'Could not update the channel name template.' : 'Could not update the channel name template.'; @@ -302,13 +302,13 @@ async function handleUserLimitChange(interaction, triggerChannel, currentConfig, await message.delete().catch(() => {}); } catch (error) { - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { logger.debug(`User limit validation error: ${error.message}`); } else { logger.error('User limit update error:', error); } - const errorMessage = error instanceof TitanBotError + const errorMessage = error instanceof NexoBotError ? error.userMessage || 'Could not update the user limit.' : 'Could not update the user limit.'; @@ -385,13 +385,13 @@ async function handleBitrateChange(interaction, triggerChannel, currentConfig, c await message.delete().catch(() => {}); } catch (error) { - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { logger.debug(`Bitrate validation error: ${error.message}`); } else { logger.error('Bitrate update error:', error); } - const errorMessage = error instanceof TitanBotError + const errorMessage = error instanceof NexoBotError ? error.userMessage || 'Could not update the bitrate.' : 'Could not update the bitrate.'; @@ -463,13 +463,13 @@ async function handleRemoveTrigger(interaction, triggerChannel, currentConfig, c }); } } catch (error) { - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { logger.debug(`Trigger removal validation error: ${error.message}`); } else { logger.error('Remove trigger error:', error); } - const errorMessage = error instanceof TitanBotError + const errorMessage = error instanceof NexoBotError ? error.userMessage || 'An error occurred while removing the trigger channel.' : 'An error occurred while removing the trigger channel.'; diff --git a/src/commands/JoinToCreate/modules/setup.js b/src/commands/JoinToCreate/modules/setup.js index 00ebd31053..e3158b1b6b 100644 --- a/src/commands/JoinToCreate/modules/setup.js +++ b/src/commands/JoinToCreate/modules/setup.js @@ -1,7 +1,7 @@ import { ChannelType, MessageFlags, PermissionFlagsBits } from 'discord.js'; import { successEmbed, errorEmbed } from '../../../utils/embeds.js'; import { logger } from '../../../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { NexoBotError, ErrorTypes } from '../../../utils/errorHandler.js'; import { addJoinToCreateTrigger, getJoinToCreateConfig } from '../../../utils/database.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; @@ -64,11 +64,11 @@ export default { } } } catch (error) { - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { throw error; } logger.error('Error in JoinToCreate setup:', error); - throw new TitanBotError( + throw new NexoBotError( `Setup failed: ${error.message}`, ErrorTypes.DISCORD_API, 'Failed to set up Join to Create system.' diff --git a/src/commands/Leveling/leaderboard.js b/src/commands/Leveling/leaderboard.js index 52169ae6cb..edea5cc4e4 100644 --- a/src/commands/Leveling/leaderboard.js +++ b/src/commands/Leveling/leaderboard.js @@ -1,6 +1,6 @@ import { SlashCommandBuilder, EmbedBuilder, MessageFlags } from 'discord.js'; import { logger } from '../../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { getLeaderboard, getLevelingConfig, getXpForLevel } from '../../services/leveling/leveling.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; @@ -31,7 +31,7 @@ export default { const leaderboard = await getLeaderboard(client, interaction.guildId, 10); if (leaderboard.length === 0) { - throw new TitanBotError( + throw new NexoBotError( 'No leaderboard data found', ErrorTypes.DATABASE, 'No level data found yet. Start chatting to gain XP!' diff --git a/src/commands/Leveling/level.js b/src/commands/Leveling/level.js index ecfb5620e3..2e24a55902 100644 --- a/src/commands/Leveling/level.js +++ b/src/commands/Leveling/level.js @@ -3,7 +3,7 @@ import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, MessageFlags } f import { createEmbed } from '../../utils/embeds.js'; import { getLevelingConfig, saveLevelingConfig } from '../../services/leveling/leveling.js'; import { botHasPermission } from '../../utils/permissionGuard.js'; -import { TitanBotError, ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; +import { NexoBotError, 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'; @@ -96,7 +96,7 @@ export default { } if (!botHasPermission(channel, ['SendMessages', 'EmbedLinks'])) { - throw new TitanBotError( + throw new NexoBotError( 'Bot missing permissions in the specified channel', ErrorTypes.PERMISSION, `I need **SendMessages** and **EmbedLinks** permissions in ${channel} to send level-up notifications.`, diff --git a/src/commands/Leveling/leveladd.js b/src/commands/Leveling/leveladd.js index 9d439696b7..c181bee195 100644 --- a/src/commands/Leveling/leveladd.js +++ b/src/commands/Leveling/leveladd.js @@ -1,6 +1,6 @@ import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, MessageFlags } from 'discord.js'; import { logger } from '../../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { checkUserPermissions } from '../../utils/permissionGuard.js'; import { addLevels, getLevelingConfig } from '../../services/leveling/leveling.js'; import { createEmbed } from '../../utils/embeds.js'; @@ -55,7 +55,7 @@ export default { const member = await interaction.guild.members.fetch(targetUser.id).catch(() => null); if (!member) { - throw new TitanBotError( + throw new NexoBotError( `User ${targetUser.id} not found in this guild`, ErrorTypes.USER_INPUT, 'The specified user is not in this server.' diff --git a/src/commands/Leveling/levelremove.js b/src/commands/Leveling/levelremove.js index f781d33df8..28ee38f43f 100644 --- a/src/commands/Leveling/levelremove.js +++ b/src/commands/Leveling/levelremove.js @@ -1,6 +1,6 @@ import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, MessageFlags } from 'discord.js'; import { logger } from '../../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { checkUserPermissions } from '../../utils/permissionGuard.js'; import { removeLevels, getUserLevelData, getLevelingConfig } from '../../services/leveling/leveling.js'; import { createEmbed } from '../../utils/embeds.js'; @@ -55,7 +55,7 @@ export default { const member = await interaction.guild.members.fetch(targetUser.id).catch(() => null); if (!member) { - throw new TitanBotError( + throw new NexoBotError( `User ${targetUser.id} not found in this guild`, ErrorTypes.USER_INPUT, 'The specified user is not in this server.' @@ -64,7 +64,7 @@ export default { const userData = await getUserLevelData(client, interaction.guildId, targetUser.id); if (userData.level === 0) { - throw new TitanBotError( + throw new NexoBotError( `User ${targetUser.id} is already at minimum level`, ErrorTypes.VALIDATION, `${targetUser.tag} is already at level 0 and cannot have levels removed.` diff --git a/src/commands/Leveling/levelset.js b/src/commands/Leveling/levelset.js index 4ce8589ac0..9c5fa9c760 100644 --- a/src/commands/Leveling/levelset.js +++ b/src/commands/Leveling/levelset.js @@ -1,6 +1,6 @@ import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, MessageFlags } from 'discord.js'; import { logger } from '../../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { checkUserPermissions } from '../../utils/permissionGuard.js'; import { setUserLevel, getLevelingConfig } from '../../services/leveling/leveling.js'; import { createEmbed } from '../../utils/embeds.js'; @@ -55,7 +55,7 @@ export default { const member = await interaction.guild.members.fetch(targetUser.id).catch(() => null); if (!member) { - throw new TitanBotError( + throw new NexoBotError( `User ${targetUser.id} not found in this guild`, ErrorTypes.USER_INPUT, 'The specified user is not in this server.' diff --git a/src/commands/Leveling/modules/level_dashboard.js b/src/commands/Leveling/modules/level_dashboard.js index 3d8ee34b61..ac8b89c478 100644 --- a/src/commands/Leveling/modules/level_dashboard.js +++ b/src/commands/Leveling/modules/level_dashboard.js @@ -19,7 +19,7 @@ import { 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 { NexoBotError, ErrorTypes, replyUserError } from '../../../utils/errorHandler.js'; import { getLevelingConfig, saveLevelingConfig } from '../../../services/leveling/leveling.js'; import { botHasPermission } from '../../../utils/permissionGuard.js'; import { startDashboardSession } from '../../../utils/dashboardSession.js'; @@ -149,7 +149,7 @@ export default { const cfg = await getLevelingConfig(client, guildId); if (!cfg.configured) { - throw new TitanBotError( + throw new NexoBotError( 'Leveling system not configured', ErrorTypes.CONFIGURATION, 'The leveling system has not been set up yet. Run `/level setup` first to configure it.', @@ -231,9 +231,9 @@ export default { }, }); } catch (error) { - if (error instanceof TitanBotError) throw error; + if (error instanceof NexoBotError) throw error; logger.error('Unexpected error in level_dashboard:', error); - throw new TitanBotError( + throw new NexoBotError( `Level dashboard failed: ${error.message}`, ErrorTypes.UNKNOWN, 'Failed to open the leveling dashboard.', diff --git a/src/commands/Leveling/rank.js b/src/commands/Leveling/rank.js index fea894105c..ea468d066b 100644 --- a/src/commands/Leveling/rank.js +++ b/src/commands/Leveling/rank.js @@ -1,6 +1,6 @@ import { SlashCommandBuilder, EmbedBuilder, MessageFlags } from 'discord.js'; import { logger } from '../../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { getUserLevelData, getLevelingConfig, getXpForLevel } from '../../services/leveling/leveling.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; @@ -39,7 +39,7 @@ export default { .catch(() => null); if (!member) { - throw new TitanBotError( + throw new NexoBotError( `User ${targetUser.id} not found in guild`, ErrorTypes.USER_INPUT, 'Could not find the specified user in this server.' diff --git a/src/commands/Moderation/ban.js b/src/commands/Moderation/ban.js index 86dd778858..ea802720f2 100644 --- a/src/commands/Moderation/ban.js +++ b/src/commands/Moderation/ban.js @@ -2,7 +2,7 @@ import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; import { successEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { ModerationService } from '../../services/moderation/moderationService.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; export default { data: new SlashCommandBuilder() @@ -25,7 +25,7 @@ export default { const reason = interaction.options.getString("reason") || "No reason provided"; if (!user) { - throw new TitanBotError( + throw new NexoBotError( 'Missing target user', ErrorTypes.USER_INPUT, 'You must specify a user to ban.', @@ -34,14 +34,14 @@ export default { } if (user.id === interaction.user.id) { - throw new TitanBotError( + throw new NexoBotError( 'Cannot ban self', ErrorTypes.VALIDATION, 'You cannot ban yourself.', ); } if (user.id === client.user.id) { - throw new TitanBotError( + throw new NexoBotError( 'Cannot ban bot', ErrorTypes.VALIDATION, 'You cannot ban the bot.', diff --git a/src/commands/Moderation/kick.js b/src/commands/Moderation/kick.js index 03fa9f677c..40bf825395 100644 --- a/src/commands/Moderation/kick.js +++ b/src/commands/Moderation/kick.js @@ -2,7 +2,7 @@ import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; import { successEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { ModerationService } from '../../services/moderation/moderationService.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; export default { data: new SlashCommandBuilder() @@ -26,7 +26,7 @@ export default { const reason = interaction.options.getString("reason") || "No reason provided"; if (!targetUser) { - throw new TitanBotError( + throw new NexoBotError( 'Missing target user', ErrorTypes.USER_INPUT, 'You must specify a user to kick.', @@ -35,7 +35,7 @@ export default { } if (targetUser.id === interaction.user.id) { - throw new TitanBotError( + throw new NexoBotError( "Cannot kick self", ErrorTypes.VALIDATION, "You cannot kick yourself.", @@ -43,7 +43,7 @@ export default { } if (targetUser.id === client.user.id) { - throw new TitanBotError( + throw new NexoBotError( "Cannot kick bot", ErrorTypes.VALIDATION, "You cannot kick the bot.", @@ -51,7 +51,7 @@ export default { } if (!member) { - throw new TitanBotError( + throw new NexoBotError( "Target not found", ErrorTypes.USER_INPUT, "The target user is not currently in this server.", diff --git a/src/commands/Moderation/massban.js b/src/commands/Moderation/massban.js index 9f6f8637a4..b599e4c0c2 100644 --- a/src/commands/Moderation/massban.js +++ b/src/commands/Moderation/massban.js @@ -3,7 +3,7 @@ import { createEmbed, successEmbed, warningEmbed } from '../../utils/embeds.js'; import { logModerationAction } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; import { ModerationService } from '../../services/moderation/moderationService.js'; -import { TitanBotError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; +import { NexoBotError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { @@ -135,7 +135,7 @@ export default { } catch (error) { logger.error(`Failed to ban user ${userId}:`, error); - const reason = error instanceof TitanBotError + const reason = error instanceof NexoBotError ? (error.userMessage || error.message) : (error.message || "Unknown error"); results.failed.push({ diff --git a/src/commands/Moderation/masskick.js b/src/commands/Moderation/masskick.js index 26e7e21a48..f6de30579d 100644 --- a/src/commands/Moderation/masskick.js +++ b/src/commands/Moderation/masskick.js @@ -3,7 +3,7 @@ import { createEmbed, successEmbed, warningEmbed } from '../../utils/embeds.js'; import { logModerationAction } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; import { ModerationService } from '../../services/moderation/moderationService.js'; -import { TitanBotError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; +import { NexoBotError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { @@ -127,7 +127,7 @@ export default { } catch (error) { logger.error(`Failed to kick user ${userId}:`, error); - const reason = error instanceof TitanBotError + const reason = error instanceof NexoBotError ? (error.userMessage || error.message) : (error.message || "Unknown error"); results.failed.push({ diff --git a/src/commands/Moderation/timeout.js b/src/commands/Moderation/timeout.js index 936464ced6..cc974d39ee 100644 --- a/src/commands/Moderation/timeout.js +++ b/src/commands/Moderation/timeout.js @@ -1,7 +1,7 @@ import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; import { successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { ModerationService } from '../../services/moderation/moderationService.js'; @@ -56,7 +56,7 @@ export default { const reason = interaction.options.getString("reason") || "No reason provided"; if (!targetUser) { - throw new TitanBotError( + throw new NexoBotError( 'Missing target user', ErrorTypes.USER_INPUT, 'You must specify a user to timeout.', @@ -65,21 +65,21 @@ export default { } if (targetUser.id === interaction.user.id) { - throw new TitanBotError( + throw new NexoBotError( "Cannot timeout self", ErrorTypes.VALIDATION, "You cannot timeout yourself.", ); } if (targetUser.id === client.user.id) { - throw new TitanBotError( + throw new NexoBotError( "Cannot timeout bot", ErrorTypes.VALIDATION, "You cannot timeout the bot.", ); } if (!member) { - throw new TitanBotError( + throw new NexoBotError( "Target not found", ErrorTypes.USER_INPUT, "The target user is not currently in this server.", diff --git a/src/commands/Moderation/untimeout.js b/src/commands/Moderation/untimeout.js index 104a108f2e..744597c555 100644 --- a/src/commands/Moderation/untimeout.js +++ b/src/commands/Moderation/untimeout.js @@ -2,7 +2,7 @@ import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; import { successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { ModerationService } from '../../services/moderation/moderationService.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { @@ -33,7 +33,7 @@ export default { const member = interaction.options.getMember("target"); if (!targetUser) { - throw new TitanBotError( + throw new NexoBotError( 'Missing target user', ErrorTypes.USER_INPUT, 'You must specify a user to untimeout.', @@ -42,7 +42,7 @@ export default { } if (!member) { - throw new TitanBotError( + throw new NexoBotError( "Target not found", ErrorTypes.USER_INPUT, "The target user is not currently in this server.", diff --git a/src/commands/Moderation/warn.js b/src/commands/Moderation/warn.js index 1d1b2ef1eb..349b10bae4 100644 --- a/src/commands/Moderation/warn.js +++ b/src/commands/Moderation/warn.js @@ -4,7 +4,7 @@ import { logModerationAction } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; import { WarningService } from '../../services/moderation/warningService.js'; import { ModerationService } from '../../services/moderation/moderationService.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() @@ -43,7 +43,7 @@ export default { const guildId = interaction.guildId; if (!target) { - throw new TitanBotError( + throw new NexoBotError( 'Missing target user', ErrorTypes.USER_INPUT, 'You must specify a user to warn.', @@ -52,7 +52,7 @@ export default { } if (!reason) { - throw new TitanBotError( + throw new NexoBotError( 'Missing warning reason', ErrorTypes.VALIDATION, 'You must provide a reason for the warning.', @@ -61,7 +61,7 @@ export default { } if (!member) { - throw new TitanBotError( + throw new NexoBotError( "Target not found", ErrorTypes.USER_INPUT, "The target user is not currently in this server." diff --git a/src/commands/Reaction_roles/reactroles.js b/src/commands/Reaction_roles/reactroles.js index 692443c5e9..a31d579e54 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 { createError, TitanBotError, ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; +import { createError, NexoBotError, 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'; diff --git a/src/commands/Ticket/modules/ticket_dashboard.js b/src/commands/Ticket/modules/ticket_dashboard.js index 17f3f43f88..efadfde941 100644 --- a/src/commands/Ticket/modules/ticket_dashboard.js +++ b/src/commands/Ticket/modules/ticket_dashboard.js @@ -19,7 +19,7 @@ import { 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 { NexoBotError, ErrorTypes, replyUserError } from '../../../utils/errorHandler.js'; import { getGuildConfig, setGuildConfig } from '../../../services/config/guildConfig.js'; import { getGuildTicketStats } from '../../../utils/database/tickets.js'; import { getUserTicketCount } from '../../../services/ticket.js'; @@ -99,7 +99,7 @@ function buildPanelButtonRow(config) { async function repostTicketPanel(client, guild, guildConfig, guildId) { const channel = await guild.channels.fetch(guildConfig.ticketPanelChannelId).catch(() => null); if (!channel) { - throw new TitanBotError( + throw new NexoBotError( 'Panel channel missing', ErrorTypes.CONFIGURATION, 'The configured ticket panel channel no longer exists. Set a new panel channel from the dashboard.', @@ -263,7 +263,7 @@ export default { const guildConfig = await getGuildConfig(client, guildId); if (!guildConfig.ticketPanelChannelId) { - throw new TitanBotError( + throw new NexoBotError( 'Ticket system not configured', ErrorTypes.CONFIGURATION, 'The ticket system has not been set up yet. Run `/ticket setup` first to configure it.', @@ -332,9 +332,9 @@ export default { }, }); } catch (error) { - if (error instanceof TitanBotError) throw error; + if (error instanceof NexoBotError) throw error; logger.error('Unexpected error in ticket_config:', error); - throw new TitanBotError( + throw new NexoBotError( `Ticket config failed: ${error.message}`, ErrorTypes.UNKNOWN, 'Failed to open the ticket configuration dashboard.', diff --git a/src/commands/Tools/embedbuilder.js b/src/commands/Tools/embedbuilder.js index 03066238f8..1a99152ebf 100644 --- a/src/commands/Tools/embedbuilder.js +++ b/src/commands/Tools/embedbuilder.js @@ -20,7 +20,7 @@ import { import { InteractionHelper } from '../../utils/interactionHelper.js'; import { successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; -import { TitanBotError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; +import { NexoBotError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { getColor } from '../../config/bot.js'; const MAX_FIELDS = 25; @@ -1129,7 +1129,7 @@ export default { } catch (error) { logger.error('Error in embedbuilder collector:', error); const msg = - error instanceof TitanBotError + error instanceof NexoBotError ? error.userMessage || 'An error occurred.' : 'An unexpected error occurred.'; if (!ci.replied && !ci.deferred) await ci.deferUpdate().catch(() => {}); @@ -1146,9 +1146,9 @@ export default { } }); } catch (error) { - if (error instanceof TitanBotError) throw error; + if (error instanceof NexoBotError) throw error; logger.error('Unexpected error in embedbuilder:', error); - throw new TitanBotError( + throw new NexoBotError( `embedbuilder failed: ${error.message}`, ErrorTypes.UNKNOWN, 'Failed to open the embed builder.', diff --git a/src/commands/Verification/modules/autoVerifyDashboard.js b/src/commands/Verification/modules/autoVerifyDashboard.js index 7209da79fd..48c77c9b9f 100644 --- a/src/commands/Verification/modules/autoVerifyDashboard.js +++ b/src/commands/Verification/modules/autoVerifyDashboard.js @@ -16,7 +16,7 @@ import { 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 { NexoBotError, ErrorTypes, replyUserError } from '../../../utils/errorHandler.js'; import { getGuildConfig, setGuildConfig } from '../../../services/config/guildConfig.js'; import { getWelcomeConfig } from '../../../utils/database.js'; import { validateAutoVerifyCriteria } from '../../../services/verificationService.js'; @@ -220,14 +220,14 @@ export default { break; } } catch (error) { - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { logger.debug(`Autoverify config validation error: ${error.message}`); } else { logger.error('Unexpected autoverify dashboard error:', error); } const errorMessage = - error instanceof TitanBotError + error instanceof NexoBotError ? error.userMessage || 'An error occurred while processing your selection.' : 'An unexpected error occurred while updating the configuration.'; @@ -295,9 +295,9 @@ export default { } }); } catch (error) { - if (error instanceof TitanBotError) throw error; + if (error instanceof NexoBotError) throw error; logger.error('Unexpected error in autoverify_dashboard:', error); - throw new TitanBotError( + throw new NexoBotError( `Auto-verification dashboard failed: ${error.message}`, ErrorTypes.UNKNOWN, 'Failed to open the auto-verification dashboard.', diff --git a/src/commands/Verification/modules/verification_dashboard.js b/src/commands/Verification/modules/verification_dashboard.js index ab32689527..cd2a0fe340 100644 --- a/src/commands/Verification/modules/verification_dashboard.js +++ b/src/commands/Verification/modules/verification_dashboard.js @@ -18,7 +18,7 @@ import { 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 { NexoBotError, ErrorTypes, replyUserError } from '../../../utils/errorHandler.js'; import { getGuildConfig, setGuildConfig } from '../../../services/config/guildConfig.js'; import { getWelcomeConfig } from '../../../utils/database.js'; import { botHasPermission } from '../../../utils/permissionGuard.js'; @@ -148,7 +148,7 @@ function buildButtonRow(cfg, guildId, disabled = false, panelStatus = null) { async function repostVerificationPanel(guild, cfg) { const channel = await guild.channels.fetch(cfg.channelId).catch(() => null); if (!channel) { - throw new TitanBotError( + throw new NexoBotError( 'Panel channel missing', ErrorTypes.CONFIGURATION, 'The configured verification channel no longer exists. Set a new channel from the dashboard.', @@ -234,7 +234,7 @@ export default { const cfg = guildConfig.verification; if (!cfg?.channelId) { - throw new TitanBotError( + throw new NexoBotError( 'Verification not configured', ErrorTypes.CONFIGURATION, 'The verification system has not been set up yet. Run `/verification setup` first.', @@ -384,9 +384,9 @@ export default { }, }); } catch (error) { - if (error instanceof TitanBotError) throw error; + if (error instanceof NexoBotError) throw error; logger.error('Unexpected error in verification_dashboard:', error); - throw new TitanBotError( + throw new NexoBotError( `Verification dashboard failed: ${error.message}`, ErrorTypes.UNKNOWN, 'Failed to open the verification dashboard.', diff --git a/src/commands/Welcome/greet.js b/src/commands/Welcome/greet.js index aa823e14bb..b2d760e52b 100644 --- a/src/commands/Welcome/greet.js +++ b/src/commands/Welcome/greet.js @@ -1,7 +1,7 @@ import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { logger } from '../../utils/logger.js'; -import { handleInteractionError, TitanBotError, ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; +import { handleInteractionError, NexoBotError, ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; import greetDashboard from './modules/greet_dashboard.js'; export default { @@ -31,7 +31,7 @@ export default { logger.warn(`Unknown /greet subcommand: ${subcommand}`); } } catch (error) { - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { return await replyUserError(interaction, { type: ErrorTypes.CONFIGURATION, message: error.userMessage || 'Something went wrong.' }); } await handleInteractionError(interaction, error, { command: 'greet' }); diff --git a/src/commands/Welcome/modules/greet_dashboard.js b/src/commands/Welcome/modules/greet_dashboard.js index 97581e7cd5..a8c1e3a92e 100644 --- a/src/commands/Welcome/modules/greet_dashboard.js +++ b/src/commands/Welcome/modules/greet_dashboard.js @@ -20,7 +20,7 @@ import { 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 { NexoBotError, ErrorTypes, replyUserError } from '../../../utils/errorHandler.js'; import { getWelcomeConfig, saveWelcomeConfig } from '../../../utils/database.js'; import { botHasPermission } from '../../../utils/permissionGuard.js'; @@ -177,7 +177,7 @@ export default { const cfg = await getWelcomeConfig(client, guildId); if (!cfg.channelId && !cfg.goodbyeChannelId) { - throw new TitanBotError( + throw new NexoBotError( 'Greet system not configured', ErrorTypes.CONFIGURATION, 'Neither Welcome nor Goodbye has been set up yet. Run `/welcome setup` or `/goodbye setup` first.', @@ -230,14 +230,14 @@ export default { break; } } catch (error) { - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { logger.debug(`Greet config validation error: ${error.message}`); } else { logger.error('Unexpected greet dashboard error:', error); } const errorMessage = - error instanceof TitanBotError + error instanceof NexoBotError ? error.userMessage || 'An error occurred while processing your selection.' : 'An unexpected error occurred while updating the configuration.'; @@ -342,9 +342,9 @@ export default { } }); } catch (error) { - if (error instanceof TitanBotError) throw error; + if (error instanceof NexoBotError) throw error; logger.error('Unexpected error in greet_dashboard:', error); - throw new TitanBotError( + throw new NexoBotError( `Greet dashboard failed: ${error.message}`, ErrorTypes.UNKNOWN, 'Failed to open the greet dashboard.', diff --git a/src/handlers/giveawayButtons.js b/src/handlers/giveawayButtons.js index ab0f46603e..e97d84aece 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, replyUserError } from '../utils/errorHandler.js'; +import { NexoBotError, ErrorTypes, handleInteractionError, replyUserError } from '../utils/errorHandler.js'; import { getGuildGiveaways, saveGiveaway, @@ -34,7 +34,7 @@ export const giveawayJoinHandler = { const giveaway = guildGiveaways.find(g => g.messageId === interaction.message.id); if (!giveaway) { - throw new TitanBotError( + throw new NexoBotError( 'Giveaway not found in database', ErrorTypes.VALIDATION, 'This giveaway is no longer active.', @@ -98,7 +98,7 @@ export const giveawayEndHandler = { try { if (!interaction.inGuild()) { - throw new TitanBotError( + throw new NexoBotError( 'Button used outside guild', ErrorTypes.VALIDATION, 'This button can only be used in a server.', @@ -114,7 +114,7 @@ export const giveawayEndHandler = { const giveaway = guildGiveaways.find(g => g.messageId === interaction.message.id); if (!giveaway) { - throw new TitanBotError( + throw new NexoBotError( 'Giveaway not found in database', ErrorTypes.VALIDATION, 'This giveaway is no longer active.', @@ -123,7 +123,7 @@ export const giveawayEndHandler = { } if (giveaway.ended || giveaway.isEnded || isGiveawayEnded(giveaway)) { - throw new TitanBotError( + throw new NexoBotError( 'Giveaway already ended', ErrorTypes.VALIDATION, 'This giveaway has already ended.', @@ -214,7 +214,7 @@ export const giveawayRerollHandler = { try { if (!interaction.inGuild()) { - throw new TitanBotError( + throw new NexoBotError( 'Button used outside guild', ErrorTypes.VALIDATION, 'This button can only be used in a server.', @@ -230,7 +230,7 @@ export const giveawayRerollHandler = { const giveaway = guildGiveaways.find(g => g.messageId === interaction.message.id); if (!giveaway) { - throw new TitanBotError( + throw new NexoBotError( 'Giveaway not found in database', ErrorTypes.VALIDATION, 'This giveaway is no longer active.', @@ -239,7 +239,7 @@ export const giveawayRerollHandler = { } if (!giveaway.ended && !giveaway.isEnded) { - throw new TitanBotError( + throw new NexoBotError( 'Giveaway still active', ErrorTypes.VALIDATION, 'This giveaway has not ended yet. Please end it first.', @@ -250,7 +250,7 @@ export const giveawayRerollHandler = { const participants = giveaway.participants || []; if (participants.length === 0) { - throw new TitanBotError( + throw new NexoBotError( 'No participants to reroll', ErrorTypes.VALIDATION, 'There are no entries to reroll from.', @@ -335,7 +335,7 @@ export const giveawayViewHandler = { async execute(interaction, client) { try { if (!interaction.inGuild()) { - throw new TitanBotError( + throw new NexoBotError( 'Button used outside guild', ErrorTypes.VALIDATION, 'This button can only be used in a server.', @@ -347,7 +347,7 @@ export const giveawayViewHandler = { const giveaway = guildGiveaways.find(g => g.messageId === interaction.message.id); if (!giveaway) { - throw new TitanBotError( + throw new NexoBotError( 'Giveaway not found in database', ErrorTypes.VALIDATION, 'This giveaway could not be found.', diff --git a/src/services/birthdayService.js b/src/services/birthdayService.js index 002665d4eb..e2cde99e3a 100644 --- a/src/services/birthdayService.js +++ b/src/services/birthdayService.js @@ -3,7 +3,7 @@ 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'; +import { NexoBotError, ErrorTypes } from '../utils/errorHandler.js'; export function validateBirthday(month, day) { @@ -54,7 +54,7 @@ export async function setBirthday(client, guildId, userId, month, day) { error: validation.error }); - throw new TitanBotError( + throw new NexoBotError( validation.error, ErrorTypes.VALIDATION, validation.error, @@ -65,7 +65,7 @@ export async function setBirthday(client, guildId, userId, month, day) { const success = await dbSetBirthday(client, guildId, userId, month, day); if (!success) { - throw new TitanBotError( + throw new NexoBotError( 'Failed to save birthday to database', ErrorTypes.DATABASE, 'Failed to set your birthday. Please try again later.', @@ -170,7 +170,7 @@ export async function deleteBirthday(client, guildId, userId) { const success = await dbDeleteBirthday(client, guildId, userId); if (!success) { - throw new TitanBotError( + throw new NexoBotError( 'Failed to delete birthday from database', ErrorTypes.DATABASE, 'Failed to remove your birthday. Please try again.', diff --git a/src/services/giveawayService.js b/src/services/giveawayService.js index 7f07fff939..e3334a8072 100644 --- a/src/services/giveawayService.js +++ b/src/services/giveawayService.js @@ -2,7 +2,7 @@ import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js'; import { logger } from '../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../utils/errorHandler.js'; +import { NexoBotError, ErrorTypes } from '../utils/errorHandler.js'; import { getColor, botConfig } from '../config/bot.js'; import { getEndedGiveaways, markGiveawayEnded } from '../utils/database.js'; import { checkRateLimit, getRateLimitStatus } from '../utils/rateLimiter.js'; @@ -17,7 +17,7 @@ function getGiveawayInteractionKey(userId, giveawayId) { export function parseDuration(durationString) { if (!durationString || typeof durationString !== 'string') { - throw new TitanBotError( + throw new NexoBotError( 'Invalid duration format provided', ErrorTypes.VALIDATION, 'Please provide a valid duration (e.g., 1h, 30m, 5d, 10s).', @@ -29,7 +29,7 @@ export function parseDuration(durationString) { const match = durationString.trim().match(regex); if (!match) { - throw new TitanBotError( + throw new NexoBotError( `Invalid duration format: ${durationString}`, ErrorTypes.VALIDATION, 'Invalid duration format. Use: 1h, 30m, 5d, 10s (min: 10s, max: 30d)', @@ -41,7 +41,7 @@ export function parseDuration(durationString) { const unit = match[2].toLowerCase(); if (amount <= 0 || amount > 999) { - throw new TitanBotError( + throw new NexoBotError( `Duration amount out of range: ${amount}`, ErrorTypes.VALIDATION, 'Duration amount must be between 1 and 999.', @@ -64,7 +64,7 @@ export function parseDuration(durationString) { ms = amount * 24 * 60 * 60 * 1000; break; default: - throw new TitanBotError( + throw new NexoBotError( `Unknown duration unit: ${unit}`, ErrorTypes.VALIDATION, 'Please use s (seconds), m (minutes), h (hours), or d (days).', @@ -74,7 +74,7 @@ export function parseDuration(durationString) { const maxDuration = GIVEAWAY_CONFIG.maximumDuration ?? 30 * 24 * 60 * 60 * 1000; if (ms > maxDuration) { - throw new TitanBotError( + throw new NexoBotError( `Duration exceeds maximum: ${ms}ms > ${maxDuration}ms`, ErrorTypes.VALIDATION, `Maximum duration is ${Math.floor(maxDuration / (24 * 60 * 60 * 1000))} days.`, @@ -84,7 +84,7 @@ export function parseDuration(durationString) { const minDuration = GIVEAWAY_CONFIG.minimumDuration ?? 10 * 1000; if (ms < minDuration) { - throw new TitanBotError( + throw new NexoBotError( `Duration below minimum: ${ms}ms < ${minDuration}ms`, ErrorTypes.VALIDATION, `Minimum duration is ${Math.ceil(minDuration / 1000)} seconds.`, @@ -97,7 +97,7 @@ export function parseDuration(durationString) { export function validatePrize(prize) { if (!prize || typeof prize !== 'string') { - throw new TitanBotError( + throw new NexoBotError( 'Prize must be a non-empty string', ErrorTypes.VALIDATION, 'Please provide a valid prize description.', @@ -107,7 +107,7 @@ export function validatePrize(prize) { const trimmed = prize.trim(); if (trimmed.length === 0 || trimmed.length > 256) { - throw new TitanBotError( + throw new NexoBotError( `Prize length out of range: ${trimmed.length}`, ErrorTypes.VALIDATION, 'Prize must be between 1 and 256 characters.', @@ -123,7 +123,7 @@ export function validateWinnerCount(winnerCount) { const maximumWinners = GIVEAWAY_CONFIG.maximumWinners ?? 10; if (!Number.isInteger(winnerCount) || winnerCount < minimumWinners || winnerCount > maximumWinners) { - throw new TitanBotError( + throw new NexoBotError( `Invalid winner count: ${winnerCount}`, ErrorTypes.VALIDATION, `Winner count must be between ${minimumWinners} and ${maximumWinners}.`, @@ -163,7 +163,7 @@ export function createGiveawayEmbed(giveaway, status, winners = []) { return embed; } catch (error) { logger.error('Error creating giveaway embed:', error); - throw new TitanBotError( + throw new NexoBotError( 'Failed to create giveaway embed', ErrorTypes.UNKNOWN, 'An internal error occurred while formatting the giveaway.', @@ -207,7 +207,7 @@ export function createGiveawayButtons(ended = false) { return row; } catch (error) { logger.error('Error creating giveaway buttons:', error); - throw new TitanBotError( + throw new NexoBotError( 'Failed to create giveaway buttons', ErrorTypes.UNKNOWN, 'An internal error occurred while creating interactive buttons.', @@ -224,7 +224,7 @@ export function selectWinners(participants, winnerCount) { const uniqueParticipants = [...new Set(participants)]; if (!Number.isInteger(winnerCount) || winnerCount < 1) { - throw new TitanBotError( + throw new NexoBotError( 'Invalid winner count for selection', ErrorTypes.VALIDATION, 'Winner count must be at least 1.', @@ -244,7 +244,7 @@ export function selectWinners(participants, winnerCount) { return shuffled.slice(0, requested); } catch (error) { logger.error('Error selecting winners:', error); - throw new TitanBotError( + throw new NexoBotError( 'Failed to select winners', ErrorTypes.UNKNOWN, 'An error occurred while selecting winners.', @@ -272,7 +272,7 @@ export async function recordUserInteraction(userId, giveawayId) { export async function endGiveaway(client, giveaway, guildId, endedBy) { try { if (!giveaway) { - throw new TitanBotError( + throw new NexoBotError( 'Giveaway object is null or undefined', ErrorTypes.VALIDATION, 'Cannot end a non-existent giveaway.', @@ -281,7 +281,7 @@ export async function endGiveaway(client, giveaway, guildId, endedBy) { } if (giveaway.ended === true || giveaway.isEnded === true) { - throw new TitanBotError( + throw new NexoBotError( `Giveaway ${giveaway.messageId} is already ended`, ErrorTypes.VALIDATION, 'This giveaway has already ended.', @@ -310,12 +310,12 @@ export async function endGiveaway(client, giveaway, guildId, endedBy) { participantCount: participants.length }; } catch (error) { - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { logger.debug(`Giveaway end validation error: ${error.message}`, error.context || {}); throw error; } logger.error('Error ending giveaway:', error); - throw new TitanBotError( + throw new NexoBotError( 'Failed to end giveaway', ErrorTypes.UNKNOWN, 'An error occurred while ending the giveaway.', diff --git a/src/services/joinToCreateService.js b/src/services/joinToCreateService.js index a39318716f..9e782d9cd8 100644 --- a/src/services/joinToCreateService.js +++ b/src/services/joinToCreateService.js @@ -8,7 +8,7 @@ import { formatChannelName as formatChannelNameUtil } from '../utils/database.js'; import { logger } from '../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../utils/errorHandler.js'; +import { NexoBotError, ErrorTypes } from '../utils/errorHandler.js'; import { logEvent, EVENT_TYPES } from './loggingService.js'; import { formatLogLine } from '../utils/logging/logEmbeds.js'; import { ChannelType, PermissionFlagsBits } from 'discord.js'; @@ -29,7 +29,7 @@ const ALLOWED_TEMPLATE_PLACEHOLDERS = new Set([ export function validateChannelNameTemplate(template) { if (!template || typeof template !== 'string') { - throw new TitanBotError( + throw new NexoBotError( 'Invalid channel template: must be a non-empty string', ErrorTypes.VALIDATION, 'Channel name template must be valid text.' @@ -39,7 +39,7 @@ export function validateChannelNameTemplate(template) { const normalizedTemplate = template.normalize('NFKC').replace(CONTROL_AND_INVISIBLE_CHARS_REGEX, '').trim(); if (normalizedTemplate.length > CHANNEL_NAME_MAX_LENGTH) { - throw new TitanBotError( + throw new NexoBotError( 'Channel template exceeds maximum length', ErrorTypes.VALIDATION, `Channel name template cannot exceed ${CHANNEL_NAME_MAX_LENGTH} characters.` @@ -47,7 +47,7 @@ export function validateChannelNameTemplate(template) { } if (/[@#:`]/.test(normalizedTemplate)) { - throw new TitanBotError( + throw new NexoBotError( 'Channel template contains forbidden characters', ErrorTypes.VALIDATION, 'Channel template cannot contain @, #, :, or backtick characters.' @@ -57,7 +57,7 @@ export function validateChannelNameTemplate(template) { const placeholders = normalizedTemplate.match(/\{[^}]+\}/g) || []; for (const placeholder of placeholders) { if (!ALLOWED_TEMPLATE_PLACEHOLDERS.has(placeholder)) { - throw new TitanBotError( + throw new NexoBotError( 'Channel template contains unknown placeholders', ErrorTypes.VALIDATION, `Unknown placeholder: ${placeholder}. Allowed placeholders are ${Array.from(ALLOWED_TEMPLATE_PLACEHOLDERS).join(', ')}` @@ -72,7 +72,7 @@ export function validateBitrate(bitrate) { const bitrateNum = parseInt(bitrate); if (isNaN(bitrateNum)) { - throw new TitanBotError( + throw new NexoBotError( 'Bitrate must be a valid number', ErrorTypes.VALIDATION, 'Please enter a valid number for bitrate.' @@ -80,7 +80,7 @@ export function validateBitrate(bitrate) { } if (bitrateNum < 8 || bitrateNum > 384) { - throw new TitanBotError( + throw new NexoBotError( 'Bitrate out of valid range', ErrorTypes.VALIDATION, 'Bitrate must be between 8 and 384 kbps.' @@ -94,7 +94,7 @@ export function validateUserLimit(limit) { const limitNum = parseInt(limit); if (isNaN(limitNum)) { - throw new TitanBotError( + throw new NexoBotError( 'User limit must be a valid number', ErrorTypes.VALIDATION, 'Please enter a valid number for user limit.' @@ -102,7 +102,7 @@ export function validateUserLimit(limit) { } if (limitNum < 0 || limitNum > 99) { - throw new TitanBotError( + throw new NexoBotError( 'User limit out of valid range', ErrorTypes.VALIDATION, 'User limit must be between 0 (no limit) and 99.' @@ -118,7 +118,7 @@ export function formatChannelName(template, variables) { validateChannelNameTemplate(safeTemplate); if (!variables || typeof variables !== 'object') { - throw new TitanBotError( + throw new NexoBotError( 'Invalid variables object for channel formatting', ErrorTypes.VALIDATION ); @@ -180,7 +180,7 @@ export function formatChannelName(template, variables) { export async function initializeJoinToCreate(client, guildId, channelId, options = {}) { try { if (!client || !client.db) { - throw new TitanBotError( + throw new NexoBotError( 'Database service not available', ErrorTypes.DATABASE, 'System error occurred. Please try again.' @@ -188,7 +188,7 @@ export async function initializeJoinToCreate(client, guildId, channelId, options } if (!guildId || !channelId) { - throw new TitanBotError( + throw new NexoBotError( 'Missing required guild or channel ID', ErrorTypes.VALIDATION, 'Invalid guild or channel information provided.' @@ -208,7 +208,7 @@ export async function initializeJoinToCreate(client, guildId, channelId, options const config = await getJoinToCreateConfig(client, guildId); if (config.triggerChannels.includes(channelId)) { - throw new TitanBotError( + throw new NexoBotError( 'Channel already configured as Join to Create trigger', ErrorTypes.VALIDATION, 'This channel is already set up as a Join to Create trigger.' @@ -216,7 +216,7 @@ export async function initializeJoinToCreate(client, guildId, channelId, options } if (Array.isArray(config.triggerChannels) && config.triggerChannels.length > 0) { - throw new TitanBotError( + throw new NexoBotError( 'Guild already has a Join to Create trigger configured', ErrorTypes.VALIDATION, 'This server already has a Join to Create channel configured. Use `/jointocreate dashboard` to modify it, or remove it before creating a new one.', @@ -247,7 +247,7 @@ export async function initializeJoinToCreate(client, guildId, channelId, options const saveResult = await saveJoinToCreateConfig(client, guildId, config); if (!saveResult) { - throw new TitanBotError( + throw new NexoBotError( 'Failed to save Join to Create configuration', ErrorTypes.DATABASE, 'Failed to set up Join to Create system. Please try again.' @@ -259,10 +259,10 @@ export async function initializeJoinToCreate(client, guildId, channelId, options return config; } catch (error) { - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { throw error; } - throw new TitanBotError( + throw new NexoBotError( `Failed to initialize Join to Create: ${error.message}`, ErrorTypes.DATABASE, 'Failed to set up Join to Create system.' @@ -273,7 +273,7 @@ export async function initializeJoinToCreate(client, guildId, channelId, options export async function updateChannelConfig(client, guildId, channelId, updates) { try { if (!client || !client.db) { - throw new TitanBotError( + throw new NexoBotError( 'Database service not available', ErrorTypes.DATABASE, 'Database service is currently unavailable. Please try again later.' @@ -283,7 +283,7 @@ export async function updateChannelConfig(client, guildId, channelId, updates) { const config = await getJoinToCreateConfig(client, guildId); if (!config.triggerChannels.includes(channelId)) { - throw new TitanBotError( + throw new NexoBotError( 'Channel is not configured as a Join to Create trigger', ErrorTypes.VALIDATION, 'This channel is not set up as a Join to Create trigger.' @@ -319,10 +319,10 @@ export async function updateChannelConfig(client, guildId, channelId, updates) { return config.channelOptions[channelId]; } catch (error) { - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { throw error; } - throw new TitanBotError( + throw new NexoBotError( `Failed to update channel config: ${error.message}`, ErrorTypes.DATABASE, 'Failed to update configuration.' @@ -333,7 +333,7 @@ export async function updateChannelConfig(client, guildId, channelId, updates) { export async function removeTriggerChannel(client, guildId, channelId) { try { if (!client || !client.db) { - throw new TitanBotError( + throw new NexoBotError( 'Database service not available', ErrorTypes.DATABASE, 'Database service is currently unavailable. Please try again later.' @@ -344,7 +344,7 @@ export async function removeTriggerChannel(client, guildId, channelId) { const index = config.triggerChannels.indexOf(channelId); if (index === -1) { - throw new TitanBotError( + throw new NexoBotError( 'Channel not found in Join to Create triggers', ErrorTypes.VALIDATION, 'This channel is not configured as a Join to Create trigger.' @@ -373,10 +373,10 @@ export async function removeTriggerChannel(client, guildId, channelId) { return true; } catch (error) { - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { throw error; } - throw new TitanBotError( + throw new NexoBotError( `Failed to remove trigger channel: ${error.message}`, ErrorTypes.DATABASE, 'Failed to remove trigger channel.' @@ -387,7 +387,7 @@ export async function removeTriggerChannel(client, guildId, channelId) { export async function getConfiguration(client, guildId) { try { if (!client || !client.db) { - throw new TitanBotError( + throw new NexoBotError( 'Database service not available', ErrorTypes.DATABASE, 'Database service is currently unavailable. Please try again later.' @@ -397,10 +397,10 @@ export async function getConfiguration(client, guildId) { return await getJoinToCreateConfig(client, guildId); } catch (error) { - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { throw error; } - throw new TitanBotError( + throw new NexoBotError( `Failed to retrieve configuration: ${error.message}`, ErrorTypes.DATABASE, 'Failed to retrieve settings.' @@ -423,7 +423,7 @@ export async function getChannelConfiguration(client, guildId, channelId) { const config = await getConfiguration(client, guildId); if (!config.triggerChannels || !Array.isArray(config.triggerChannels) || !config.triggerChannels.includes(channelId)) { - throw new TitanBotError( + throw new NexoBotError( 'Channel is not a valid Join to Create trigger', ErrorTypes.VALIDATION, 'This channel is not set up as a Join to Create trigger.' @@ -436,10 +436,10 @@ export async function getChannelConfiguration(client, guildId, channelId) { }; } catch (error) { - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { throw error; } - throw new TitanBotError( + throw new NexoBotError( `Failed to get channel configuration: ${error.message}`, ErrorTypes.DATABASE, 'Failed to retrieve channel configuration. Please try again.' @@ -482,7 +482,7 @@ export async function logConfigurationChange(client, guildId, userId, action, de export async function createTemporaryChannel(guild, member, options = {}) { try { if (!guild || !member) { - throw new TitanBotError( + throw new NexoBotError( 'Invalid guild or member', ErrorTypes.VALIDATION ); @@ -539,10 +539,10 @@ export async function createTemporaryChannel(guild, member, options = {}) { }; } catch (error) { - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { throw error; } - throw new TitanBotError( + throw new NexoBotError( `Failed to create temporary channel: ${error.message}`, ErrorTypes.DISCORD_API, 'Failed to create your temporary voice channel. Please contact an administrator.' diff --git a/src/services/leveling/leveling.js b/src/services/leveling/leveling.js index f301b4e19e..cbac144872 100644 --- a/src/services/leveling/leveling.js +++ b/src/services/leveling/leveling.js @@ -3,7 +3,7 @@ import { EmbedBuilder } from 'discord.js'; import { logger } from '../../utils/logger.js'; import { getGuildConfig, setGuildConfig } from '../config/guildConfig.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { addXp } from './xpSystem.js'; import { getUserLevelKey } from '../../utils/database/keys.js'; @@ -14,7 +14,7 @@ const MIN_LEVEL = 0; export function getXpForLevel(level) { if (!Number.isInteger(level) || level < 0 || level > MAX_LEVEL) { - throw new TitanBotError( + throw new NexoBotError( `Invalid level: ${level}. Must be between ${MIN_LEVEL} and ${MAX_LEVEL}`, ErrorTypes.VALIDATION, 'The level must be a valid number.' @@ -25,7 +25,7 @@ export function getXpForLevel(level) { export function getLevelFromXp(xp) { if (!Number.isInteger(xp) || xp < 0) { - throw new TitanBotError( + throw new NexoBotError( `Invalid XP: ${xp}`, ErrorTypes.VALIDATION, 'XP must be a non-negative number.' @@ -60,7 +60,7 @@ export async function getLeaderboard(client, guildId, limit = 10) { try { if (!guildId || typeof guildId !== 'string') { - throw new TitanBotError( + throw new NexoBotError( 'Invalid guild ID', ErrorTypes.VALIDATION, 'Guild ID is required.' @@ -108,8 +108,8 @@ export async function getLeaderboard(client, guildId, limit = 10) { } catch (error) { logger.error('Error getting leaderboard:', error); - if (error instanceof TitanBotError) throw error; - throw new TitanBotError( + if (error instanceof NexoBotError) throw error; + throw new NexoBotError( `Failed to fetch leaderboard: ${error.message}`, ErrorTypes.DATABASE, 'Could not fetch the leaderboard at this time.' @@ -184,7 +184,7 @@ export async function getLevelingConfig(client, guildId) { export async function getUserLevelData(client, guildId, userId) { try { if (!guildId || !userId) { - throw new TitanBotError( + throw new NexoBotError( 'Guild ID and User ID are required', ErrorTypes.VALIDATION ); @@ -212,8 +212,8 @@ export async function getUserLevelData(client, guildId, userId) { }; } catch (error) { logger.error(`Error getting user level data for ${userId}:`, error); - if (error instanceof TitanBotError) throw error; - throw new TitanBotError( + if (error instanceof NexoBotError) throw error; + throw new NexoBotError( `Failed to fetch user data: ${error.message}`, ErrorTypes.DATABASE, 'Could not fetch level data at this time.' @@ -224,14 +224,14 @@ export async function getUserLevelData(client, guildId, userId) { export async function saveUserLevelData(client, guildId, userId, data) { try { if (!guildId || !userId) { - throw new TitanBotError( + throw new NexoBotError( 'Guild ID and User ID are required', ErrorTypes.VALIDATION ); } if (!data || typeof data !== 'object') { - throw new TitanBotError( + throw new NexoBotError( 'Invalid user level data', ErrorTypes.VALIDATION ); @@ -249,8 +249,8 @@ export async function saveUserLevelData(client, guildId, userId, data) { await client.db.set(key, sanitizedData); } catch (error) { logger.error(`Error saving user level data for ${userId}:`, error); - if (error instanceof TitanBotError) throw error; - throw new TitanBotError( + if (error instanceof NexoBotError) throw error; + throw new NexoBotError( `Failed to save user data: ${error.message}`, ErrorTypes.DATABASE, 'Could not save level data at this time.' @@ -261,7 +261,7 @@ export async function saveUserLevelData(client, guildId, userId, data) { export async function saveLevelingConfig(client, guildId, config) { try { if (!guildId || !config) { - throw new TitanBotError( + throw new NexoBotError( 'Guild ID and config are required', ErrorTypes.VALIDATION ); @@ -270,7 +270,7 @@ export async function saveLevelingConfig(client, guildId, config) { const guildConfig = await getGuildConfig(client, guildId); if (config.xpCooldown && (config.xpCooldown < 0 || config.xpCooldown > 3600)) { - throw new TitanBotError( + throw new NexoBotError( 'XP cooldown must be between 0 and 3600 seconds', ErrorTypes.VALIDATION, 'Cooldown must be between 0 and 3600 seconds.' @@ -278,7 +278,7 @@ export async function saveLevelingConfig(client, guildId, config) { } if (config.xpRange && (config.xpRange.min < 1 || config.xpRange.max < 1 || config.xpRange.min > config.xpRange.max)) { - throw new TitanBotError( + throw new NexoBotError( 'Invalid XP range configuration', ErrorTypes.VALIDATION, 'Minimum XP must be less than maximum XP, and both must be positive.' @@ -291,8 +291,8 @@ export async function saveLevelingConfig(client, guildId, config) { logger.info(`Leveling config updated for guild ${guildId}`); } catch (error) { logger.error(`Error saving leveling config for guild ${guildId}:`, error); - if (error instanceof TitanBotError) throw error; - throw new TitanBotError( + if (error instanceof NexoBotError) throw error; + throw new NexoBotError( `Failed to save config: ${error.message}`, ErrorTypes.DATABASE, 'Could not save configuration at this time.' @@ -304,7 +304,7 @@ export async function addLevels(client, guildId, userId, levels) { try { const levelingConfig = await getLevelingConfig(client, guildId); if (!levelingConfig?.enabled) { - throw new TitanBotError( + throw new NexoBotError( 'Leveling system is disabled on this server', ErrorTypes.CONFIGURATION, 'The leveling system is currently disabled on this server.' @@ -312,7 +312,7 @@ export async function addLevels(client, guildId, userId, levels) { } if (!Number.isInteger(levels) || levels <= 0) { - throw new TitanBotError( + throw new NexoBotError( `Invalid level amount: ${levels}`, ErrorTypes.VALIDATION, 'You must add a positive number of levels.' @@ -323,7 +323,7 @@ export async function addLevels(client, guildId, userId, levels) { const newLevel = userData.level + levels; if (newLevel > MAX_LEVEL) { - throw new TitanBotError( + throw new NexoBotError( `Level ${newLevel} exceeds maximum level ${MAX_LEVEL}`, ErrorTypes.VALIDATION, `Maximum level is ${MAX_LEVEL}.` @@ -343,8 +343,8 @@ export async function addLevels(client, guildId, userId, levels) { return userData; } catch (error) { logger.error(`Error adding levels for user ${userId}:`, error); - if (error instanceof TitanBotError) throw error; - throw new TitanBotError( + if (error instanceof NexoBotError) throw error; + throw new NexoBotError( `Failed to add levels: ${error.message}`, ErrorTypes.DATABASE, 'Could not add levels at this time.' @@ -356,7 +356,7 @@ export async function removeLevels(client, guildId, userId, levels) { try { const levelingConfig = await getLevelingConfig(client, guildId); if (!levelingConfig?.enabled) { - throw new TitanBotError( + throw new NexoBotError( 'Leveling system is disabled on this server', ErrorTypes.CONFIGURATION, 'The leveling system is currently disabled on this server.' @@ -364,7 +364,7 @@ export async function removeLevels(client, guildId, userId, levels) { } if (!Number.isInteger(levels) || levels <= 0) { - throw new TitanBotError( + throw new NexoBotError( `Invalid level amount: ${levels}`, ErrorTypes.VALIDATION, 'You must remove a positive number of levels.' @@ -387,8 +387,8 @@ export async function removeLevels(client, guildId, userId, levels) { return userData; } catch (error) { logger.error(`Error removing levels for user ${userId}:`, error); - if (error instanceof TitanBotError) throw error; - throw new TitanBotError( + if (error instanceof NexoBotError) throw error; + throw new NexoBotError( `Failed to remove levels: ${error.message}`, ErrorTypes.DATABASE, 'Could not remove levels at this time.' @@ -400,7 +400,7 @@ export async function setUserLevel(client, guildId, userId, level) { try { const levelingConfig = await getLevelingConfig(client, guildId); if (!levelingConfig?.enabled) { - throw new TitanBotError( + throw new NexoBotError( 'Leveling system is disabled on this server', ErrorTypes.CONFIGURATION, 'The leveling system is currently disabled on this server.' @@ -408,7 +408,7 @@ export async function setUserLevel(client, guildId, userId, level) { } if (!Number.isInteger(level) || level < MIN_LEVEL || level > MAX_LEVEL) { - throw new TitanBotError( + throw new NexoBotError( `Invalid level: ${level}`, ErrorTypes.VALIDATION, `Level must be between ${MIN_LEVEL} and ${MAX_LEVEL}.` @@ -430,8 +430,8 @@ export async function setUserLevel(client, guildId, userId, level) { return userData; } catch (error) { logger.error(`Error setting level for user ${userId}:`, error); - if (error instanceof TitanBotError) throw error; - throw new TitanBotError( + if (error instanceof NexoBotError) throw error; + throw new NexoBotError( `Failed to set level: ${error.message}`, ErrorTypes.DATABASE, 'Could not set level at this time.' @@ -442,7 +442,7 @@ export async function setUserLevel(client, guildId, userId, level) { export async function deleteUserLevelData(client, guildId, userId) { try { if (!guildId || !userId) { - throw new TitanBotError( + throw new NexoBotError( 'Guild ID and User ID are required', ErrorTypes.VALIDATION ); @@ -454,7 +454,7 @@ export async function deleteUserLevelData(client, guildId, userId) { logger.debug(`Deleted level data for user ${userId} in guild ${guildId}`); } catch (error) { logger.error(`Error deleting level data for user ${userId}:`, error); - if (error instanceof TitanBotError) throw error; + if (error instanceof NexoBotError) throw error; logger.warn(`Could not delete level data for user ${userId} in guild ${guildId}`); } } \ No newline at end of file diff --git a/src/services/moderation/moderationService.js b/src/services/moderation/moderationService.js index ee99845f1b..fb9abb99a8 100644 --- a/src/services/moderation/moderationService.js +++ b/src/services/moderation/moderationService.js @@ -2,7 +2,7 @@ import { PermissionFlagsBits } from 'discord.js'; import { logger } from '../../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { logModerationAction } from '../../utils/moderation.js'; function getTargetLabel(target) { @@ -123,12 +123,12 @@ export class ModerationService { static assertModerationHierarchy(moderator, target, action) { const botCheck = this.validateBotHierarchy(target, action); if (!botCheck.valid) { - throw new TitanBotError(botCheck.error, ErrorTypes.PERMISSION, botCheck.error); + throw new NexoBotError(botCheck.error, ErrorTypes.PERMISSION, botCheck.error); } const modCheck = this.validateHierarchy(moderator, target, action); if (!modCheck.valid) { - throw new TitanBotError(modCheck.error, ErrorTypes.PERMISSION, modCheck.error); + throw new NexoBotError(modCheck.error, ErrorTypes.PERMISSION, modCheck.error); } } @@ -141,7 +141,7 @@ export class ModerationService { }) { try { if (!guild || !user || !moderator) { - throw new TitanBotError( + throw new NexoBotError( 'Missing required parameters', ErrorTypes.VALIDATION, 'Guild, user, and moderator are required' @@ -166,7 +166,7 @@ export class ModerationService { ]); if (!isOwner && !hasHighPerms) { - throw new TitanBotError( + throw new NexoBotError( 'You do not have sufficient permissions to ban users who are not in the server.', ErrorTypes.PERMISSION, 'You need "Manage Server" or "Administrator" permissions to ban users not currently in the guild.' @@ -214,7 +214,7 @@ export class ModerationService { }) { try { if (!guild || !member || !moderator) { - throw new TitanBotError( + throw new NexoBotError( 'Missing required parameters', ErrorTypes.VALIDATION, 'Guild, member, and moderator are required' @@ -225,7 +225,7 @@ export class ModerationService { if (!member.kickable) { const targetLabel = getTargetLabel(member); - throw new TitanBotError( + throw new NexoBotError( 'Cannot kick member', ErrorTypes.PERMISSION, `I cannot kick **${targetLabel}**. They may have **Administrator** permission or a managed/integration role. ` + @@ -272,7 +272,7 @@ export class ModerationService { }) { try { if (!guild || !member || !moderator || !durationMs) { - throw new TitanBotError( + throw new NexoBotError( 'Missing required parameters', ErrorTypes.VALIDATION, 'Guild, member, moderator, and duration are required' @@ -283,7 +283,7 @@ export class ModerationService { if (!member.moderatable) { const targetLabel = getTargetLabel(member); - throw new TitanBotError( + throw new NexoBotError( 'Cannot timeout member', ErrorTypes.PERMISSION, `I cannot timeout **${targetLabel}**. They may have **Administrator** permission or a managed/integration role. ` + @@ -333,7 +333,7 @@ export class ModerationService { }) { try { if (!guild || !member || !moderator) { - throw new TitanBotError( + throw new NexoBotError( 'Missing required parameters', ErrorTypes.VALIDATION, 'Guild, member, and moderator are required' @@ -344,7 +344,7 @@ export class ModerationService { if (!member.moderatable) { const targetLabel = getTargetLabel(member); - throw new TitanBotError( + throw new NexoBotError( 'Cannot modify member', ErrorTypes.PERMISSION, `I cannot modify **${targetLabel}**. They may have **Administrator** permission or a managed/integration role. ` + @@ -353,7 +353,7 @@ export class ModerationService { } if (!member.isCommunicationDisabled()) { - throw new TitanBotError( + throw new NexoBotError( 'User not timed out', ErrorTypes.VALIDATION, `${member.user.tag} is not currently timed out` @@ -396,7 +396,7 @@ export class ModerationService { }) { try { if (!guild || !user || !moderator) { - throw new TitanBotError( + throw new NexoBotError( 'Missing required parameters', ErrorTypes.VALIDATION, 'Guild, user, and moderator are required' @@ -407,7 +407,7 @@ export class ModerationService { const banInfo = bans.get(user.id); if (!banInfo) { - throw new TitanBotError( + throw new NexoBotError( 'User not banned', ErrorTypes.VALIDATION, `${user.tag} is not currently banned from this server` diff --git a/src/services/music/musicActions.js b/src/services/music/musicActions.js index be46fb85d8..62b36ca96d 100644 --- a/src/services/music/musicActions.js +++ b/src/services/music/musicActions.js @@ -3,7 +3,7 @@ import { MessageFlags, PermissionFlagsBits } from 'discord.js'; import { successEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { botHasPermission } from '../../utils/permissionGuard.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { getGuildMusicData, clearUpdateInterval } from './playerStore.js'; import { canControlMusic, requireVoiceChannel, VOICE_CHANNEL_DENIAL } from './permissions.js'; import { @@ -27,7 +27,7 @@ function getConnectedLavalinkNodes(client) { export function assertLavalinkNodeAvailable(client) { if (!getConnectedLavalinkNodes(client).length) { - throw new TitanBotError( + throw new NexoBotError( 'Lavalink unavailable', ErrorTypes.CONFIGURATION, 'Music is temporarily unavailable — no Lavalink nodes are connected. Try again shortly or configure your own Lavalink server.', @@ -37,7 +37,7 @@ export function assertLavalinkNodeAvailable(client) { function assertBotVoicePermissions(channel) { if (!channel) { - throw new TitanBotError( + throw new NexoBotError( 'Voice channel unavailable', ErrorTypes.CONFIGURATION, 'Could not access that voice channel.', @@ -45,7 +45,7 @@ function assertBotVoicePermissions(channel) { } if (!botHasPermission(channel, [PermissionFlagsBits.Connect, PermissionFlagsBits.Speak])) { - throw new TitanBotError( + throw new NexoBotError( 'Missing voice permissions', ErrorTypes.PERMISSION, 'I need **Connect** and **Speak** permissions in your voice channel.', @@ -77,7 +77,7 @@ async function waitForPlayerConnection(player) { } if (!player.connected) { - throw new TitanBotError( + throw new NexoBotError( 'Voice connection failed', ErrorTypes.CONFIGURATION, 'Could not connect to the voice channel. Ensure Lavalink is online, the bot has Connect and Speak permissions, then try again.', @@ -96,7 +96,7 @@ export function getPlayer(client, guildId) { export function assertRiffyAvailable(client) { if (!client.riffy) { - throw new TitanBotError( + throw new NexoBotError( 'Lavalink not configured', ErrorTypes.CONFIGURATION, 'Music is unavailable — Lavalink is not configured.', @@ -106,7 +106,7 @@ export function assertRiffyAvailable(client) { export function assertInVoice(member) { if (!requireVoiceChannel(member)) { - throw new TitanBotError( + throw new NexoBotError( 'Not in voice channel', ErrorTypes.USER_INPUT, 'You need to be in a voice channel.', @@ -116,7 +116,7 @@ export function assertInVoice(member) { export function assertCanControl(member, player) { if (!canControlMusic(member, player)) { - throw new TitanBotError( + throw new NexoBotError( 'Wrong voice channel', ErrorTypes.PERMISSION, VOICE_CHANNEL_DENIAL, @@ -197,7 +197,7 @@ export async function joinVoiceChannel(client, interaction) { export async function playQuery(client, interaction, query) { if (YOUTUBE_URL_PATTERN.test(query)) { - throw new TitanBotError( + throw new NexoBotError( 'YouTube URL blocked', ErrorTypes.USER_INPUT, 'YouTube links are not supported. Try a song name instead.', @@ -247,11 +247,11 @@ export async function playQuery(client, interaction, query) { ) { const track = tracks?.[0]; if (!track) { - throw new TitanBotError('No results', ErrorTypes.USER_INPUT, 'No results found for that query.'); + throw new NexoBotError('No results', ErrorTypes.USER_INPUT, 'No results found for that query.'); } if (isDuplicateTrack(player, track)) { - throw new TitanBotError( + throw new NexoBotError( 'Duplicate track', ErrorTypes.USER_INPUT, `**${track.info.title}** is already in the queue or playing.`, @@ -278,13 +278,13 @@ export async function playQuery(client, interaction, query) { }; } - throw new TitanBotError('No results', ErrorTypes.USER_INPUT, `No results found. (loadType: ${loadType})`); + throw new NexoBotError('No results', ErrorTypes.USER_INPUT, `No results found. (loadType: ${loadType})`); } export async function skipTrack(client, interaction) { const player = getPlayer(client, interaction.guild.id); if (!player?.current) { - throw new TitanBotError('No player', ErrorTypes.USER_INPUT, 'Nothing is playing right now.'); + throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'Nothing is playing right now.'); } assertCanControl(interaction.member, player); const title = player.current.info?.title || 'Unknown'; @@ -300,7 +300,7 @@ export async function skipTrack(client, interaction) { export async function stopPlayback(client, interaction) { const player = getPlayer(client, interaction.guild.id); if (!player) { - throw new TitanBotError('No player', ErrorTypes.USER_INPUT, 'No active music player.'); + throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'No active music player.'); } assertCanControl(interaction.member, player); @@ -350,12 +350,12 @@ export async function applyResume(client, guildId) { export async function pausePlayback(client, interaction) { const player = getPlayer(client, interaction.guild.id); if (!player?.current) { - throw new TitanBotError('No player', ErrorTypes.USER_INPUT, 'Nothing is playing right now.'); + throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'Nothing is playing right now.'); } assertCanControl(interaction.member, player); if (player.paused) { - throw new TitanBotError('Already paused', ErrorTypes.USER_INPUT, 'Playback is already paused.'); + throw new NexoBotError('Already paused', ErrorTypes.USER_INPUT, 'Playback is already paused.'); } await applyPause(client, interaction.guild.id); @@ -365,12 +365,12 @@ export async function pausePlayback(client, interaction) { export async function resumePlayback(client, interaction) { const player = getPlayer(client, interaction.guild.id); if (!player?.current) { - throw new TitanBotError('No player', ErrorTypes.USER_INPUT, 'Nothing is playing right now.'); + throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'Nothing is playing right now.'); } assertCanControl(interaction.member, player); if (!player.paused) { - throw new TitanBotError('Not paused', ErrorTypes.USER_INPUT, 'Playback is not paused.'); + throw new NexoBotError('Not paused', ErrorTypes.USER_INPUT, 'Playback is not paused.'); } await applyResume(client, interaction.guild.id); @@ -380,7 +380,7 @@ export async function resumePlayback(client, interaction) { export async function shuffleQueue(client, interaction) { const player = getPlayer(client, interaction.guild.id); if (!player?.queue?.length) { - throw new TitanBotError('Empty queue', ErrorTypes.USER_INPUT, 'The queue is empty.'); + throw new NexoBotError('Empty queue', ErrorTypes.USER_INPUT, 'The queue is empty.'); } assertCanControl(interaction.member, player); player.queue.shuffle(); @@ -392,7 +392,7 @@ export async function shuffleQueue(client, interaction) { export async function setLoopMode(client, interaction, mode) { const player = getPlayer(client, interaction.guild.id); if (!player) { - throw new TitanBotError('No player', ErrorTypes.USER_INPUT, 'No active music player.'); + throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'No active music player.'); } assertCanControl(interaction.member, player); @@ -414,7 +414,7 @@ export async function toggleLoop(client, interaction) { export async function setVolume(client, interaction, volume) { const player = getPlayer(client, interaction.guild.id); if (!player) { - throw new TitanBotError('No player', ErrorTypes.USER_INPUT, 'No active music player.'); + throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'No active music player.'); } assertCanControl(interaction.member, player); @@ -433,13 +433,13 @@ export async function adjustVolume(client, interaction, delta) { export async function seekTrack(client, interaction, seconds) { const player = getPlayer(client, interaction.guild.id); if (!player?.current) { - throw new TitanBotError('No player', ErrorTypes.USER_INPUT, 'Nothing is playing right now.'); + throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'Nothing is playing right now.'); } assertCanControl(interaction.member, player); const info = player.current.info || {}; if (info.isStream || info.isSeekable === false) { - throw new TitanBotError( + throw new NexoBotError( 'Not seekable', ErrorTypes.USER_INPUT, 'This track cannot be seeked (it may be a live stream).', @@ -448,7 +448,7 @@ export async function seekTrack(client, interaction, seconds) { const position = Math.max(0, seconds * 1000); if (info.length && position > info.length) { - throw new TitanBotError( + throw new NexoBotError( 'Seek out of range', ErrorTypes.USER_INPUT, `You can only seek up to ${Math.floor(info.length / 1000)}s for this track.`, @@ -463,13 +463,13 @@ export async function seekTrack(client, interaction, seconds) { export async function removeFromQueue(client, interaction, index) { const player = getPlayer(client, interaction.guild.id); if (!player?.queue?.length) { - throw new TitanBotError('Empty queue', ErrorTypes.USER_INPUT, 'The queue is empty.'); + throw new NexoBotError('Empty queue', ErrorTypes.USER_INPUT, 'The queue is empty.'); } assertCanControl(interaction.member, player); const queueIndex = index - 1; if (queueIndex < 0 || queueIndex >= player.queue.length) { - throw new TitanBotError('Invalid index', ErrorTypes.USER_INPUT, `Invalid queue position. Queue has ${player.queue.length} track(s).`); + throw new NexoBotError('Invalid index', ErrorTypes.USER_INPUT, `Invalid queue position. Queue has ${player.queue.length} track(s).`); } const removed = player.queue[queueIndex]; @@ -481,14 +481,14 @@ export async function removeFromQueue(client, interaction, index) { export async function moveInQueue(client, interaction, from, to) { const player = getPlayer(client, interaction.guild.id); if (!player?.queue?.length) { - throw new TitanBotError('Empty queue', ErrorTypes.USER_INPUT, 'The queue is empty.'); + throw new NexoBotError('Empty queue', ErrorTypes.USER_INPUT, 'The queue is empty.'); } assertCanControl(interaction.member, player); const fromIndex = from - 1; const toIndex = to - 1; if (fromIndex < 0 || fromIndex >= player.queue.length || toIndex < 0 || toIndex >= player.queue.length) { - throw new TitanBotError('Invalid index', ErrorTypes.USER_INPUT, 'Invalid queue positions.'); + throw new NexoBotError('Invalid index', ErrorTypes.USER_INPUT, 'Invalid queue positions.'); } const track = player.queue[fromIndex]; @@ -501,7 +501,7 @@ export async function moveInQueue(client, interaction, from, to) { export async function clearQueue(client, interaction) { const player = getPlayer(client, interaction.guild.id); if (!player?.queue?.length) { - throw new TitanBotError('Empty queue', ErrorTypes.USER_INPUT, 'The queue is already empty.'); + throw new NexoBotError('Empty queue', ErrorTypes.USER_INPUT, 'The queue is already empty.'); } assertCanControl(interaction.member, player); player.queue.clear(); @@ -523,7 +523,7 @@ export async function setTwentyFourSeven(client, interaction, enabled) { export function buildNowPlayingReply(client, guildId) { const player = getPlayer(client, guildId); if (!player?.current) { - throw new TitanBotError('No player', ErrorTypes.USER_INPUT, 'Nothing is playing right now.'); + throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'Nothing is playing right now.'); } const guildData = getGuildMusicData(guildId); return { @@ -534,7 +534,7 @@ export function buildNowPlayingReply(client, guildId) { export function buildQueueReply(client, guildId, page = 0) { const player = getPlayer(client, guildId); if (!player) { - throw new TitanBotError('No player', ErrorTypes.USER_INPUT, 'No active music player.'); + throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'No active music player.'); } const totalPages = Math.max(1, Math.ceil((player.queue?.length || 0) / getQueuePageSize())); @@ -590,7 +590,7 @@ export async function leaveVoiceChannel(client, interaction) { const guildId = interaction.guild.id; const player = getPlayer(client, guildId); if (!player) { - throw new TitanBotError('No player', ErrorTypes.USER_INPUT, 'I am not in a voice channel.'); + throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'I am not in a voice channel.'); } assertCanControl(interaction.member, player); diff --git a/src/services/reactionRoleService.js b/src/services/reactionRoleService.js index 70c9be2134..7bd2863f3e 100644 --- a/src/services/reactionRoleService.js +++ b/src/services/reactionRoleService.js @@ -110,7 +110,7 @@ export async function getReactionRoleMessage(client, guildId, messageId) { const data = await client.db.get(key); return data || null; } catch (error) { - if (error.name === 'TitanBotError') { + if (error.name === 'NexoBotError') { throw error; } logger.error(`Error getting reaction role message ${messageId} in guild ${guildId}:`, error); @@ -174,7 +174,7 @@ export async function createReactionRoleMessage(client, guildId, channelId, mess logger.info(`Created reaction role message ${messageId} in guild ${guildId} with ${roleIds.length} roles`); return reactionRoleData; } catch (error) { - if (error.name === 'TitanBotError') { + if (error.name === 'NexoBotError') { throw error; } logger.error(`Error creating reaction role message in guild ${guildId}:`, error); @@ -208,7 +208,7 @@ export async function addReactionRole(client, guildId, messageId, emoji, roleId) logger.info(`Added reaction role for emoji ${emoji} to message ${messageId} in guild ${guildId}`); return true; } catch (error) { - if (error.name === 'TitanBotError') { + if (error.name === 'NexoBotError') { throw error; } logger.error(`Error adding reaction role in guild ${guildId}:`, error); @@ -239,7 +239,7 @@ export async function deleteReactionRoleMessage(client, guildId, messageId) { logger.info(`Deleted reaction role message ${messageId} in guild ${guildId}`); return true; } catch (error) { - if (error.name === 'TitanBotError') { + if (error.name === 'NexoBotError') { throw error; } logger.error(`Error deleting reaction role message in guild ${guildId}:`, error); @@ -276,7 +276,7 @@ export async function removeReactionRole(client, guildId, messageId, emoji) { return true; } catch (error) { - if (error.name === 'TitanBotError') { + if (error.name === 'NexoBotError') { throw error; } logger.error(`Error removing reaction role in guild ${guildId}:`, error); @@ -362,7 +362,7 @@ export async function getAllReactionRoleMessages(client, guildId) { return messages; } catch (error) { - if (error.name === 'TitanBotError') { + if (error.name === 'NexoBotError') { throw error; } logger.error(`Error getting all reaction role messages for guild ${guildId}:`, error); @@ -402,7 +402,7 @@ export async function setReactionRoleChannel(client, guildId, messageId, channel logger.info(`Set channel ${channelId} for reaction role message ${messageId}`); return true; } catch (error) { - if (error.name === 'TitanBotError') { + if (error.name === 'NexoBotError') { throw error; } logger.error(`Error setting channel for reaction role message ${messageId}:`, error); diff --git a/src/utils/commandPipeline.js b/src/utils/commandPipeline.js index 5d518d0650..ecb15276bb 100644 --- a/src/utils/commandPipeline.js +++ b/src/utils/commandPipeline.js @@ -6,7 +6,7 @@ * data: new SlashCommandBuilder()..., * category: 'economy', * async execute(interaction, config, client) { - * // throw TitanBotError / createError on failure + * // throw NexoBotError / createError on failure * // use replyUserError for early validation returns * // do NOT wrap in try/catch — interactionCreate handles errors * }, diff --git a/src/utils/dashboardSession.js b/src/utils/dashboardSession.js index 688fe1d066..6ed51698dc 100644 --- a/src/utils/dashboardSession.js +++ b/src/utils/dashboardSession.js @@ -1,6 +1,6 @@ import { ComponentType, EmbedBuilder } from 'discord.js'; import { getColor } from '../config/bot.js'; -import { TitanBotError, ErrorTypes, replyUserError } from './errorHandler.js'; +import { NexoBotError, ErrorTypes, replyUserError } from './errorHandler.js'; import { InteractionHelper } from './interactionHelper.js'; import { logger } from './logger.js'; @@ -17,14 +17,14 @@ function wrapHandler(handler, interactionLabel = 'dashboard') { } catch (error) { if (error?.code === 40060) return; - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { logger.debug(`${interactionLabel} error: ${error.message}`); } else { logger.error(`Unexpected ${interactionLabel} error:`, error); } const errorMessage = - error instanceof TitanBotError + error instanceof NexoBotError ? error.userMessage || 'An error occurred while processing your selection.' : 'An unexpected error occurred while updating the configuration.'; diff --git a/src/utils/embeds.js b/src/utils/embeds.js index 67b40ad2ab..42012ce86a 100644 --- a/src/utils/embeds.js +++ b/src/utils/embeds.js @@ -4,8 +4,8 @@ import { EmbedBuilder } from 'discord.js'; import { getColor, botConfig } from '../config/bot.js'; const EMOJI_REGEX = /[\p{Extended_Pictographic}\uFE0F]/gu; -const EMBED_FOOTER_SYMBOL = Symbol('titanbotFooterText'); -const EMBED_BASE_DESCRIPTION_SYMBOL = Symbol('titanbotBaseDescription'); +const EMBED_FOOTER_SYMBOL = Symbol('nexobotFooterText'); +const EMBED_BASE_DESCRIPTION_SYMBOL = Symbol('nexobotBaseDescription'); function sanitizeEmbedText(text = '') { if (typeof text !== 'string') { diff --git a/src/utils/errorHandler.js b/src/utils/errorHandler.js index b7e22f1330..4d13c3bf86 100644 --- a/src/utils/errorHandler.js +++ b/src/utils/errorHandler.js @@ -1,7 +1,7 @@ // errorHandler.js — the single entry point for all error handling. // // Rules: -// 1. Commands/handlers: throw TitanBotError (via createError) or let errors propagate; +// 1. Commands/handlers: throw NexoBotError (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 @@ -38,10 +38,10 @@ export const ErrorTypes = { UNKNOWN: 'unknown' }; -export class TitanBotError extends Error { +export class NexoBotError extends Error { constructor(message, type = ErrorTypes.UNKNOWN, userMessage = null, context = {}) { super(message); - this.name = 'TitanBotError'; + this.name = 'NexoBotError'; this.type = type; this.userMessage = userMessage; this.context = context; @@ -98,7 +98,7 @@ const USER_FACING_ERROR_HINTS = [ ]; function isUserFacingPlainError(error) { - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { return false; } @@ -163,7 +163,7 @@ function inferErrorTypeFromUserMessage(message = '') { } function normalizeInteractionError(error, context = {}) { - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { return error; } @@ -182,7 +182,7 @@ function normalizeInteractionError(error, context = {}) { } export function categorizeError(error) { - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { return error.type; } @@ -592,12 +592,12 @@ export function createError(message, type = ErrorTypes.UNKNOWN, userMessage = nu errorCode: context?.errorCode || getDefaultErrorCodeByType(type) }; - return new TitanBotError(message, type, userMessage, normalizedContext); + return new NexoBotError(message, type, userMessage, normalizedContext); } export default { ErrorTypes, - TitanBotError, + NexoBotError, categorizeError, getUserMessage, replyUserError, diff --git a/src/utils/giveaways.js b/src/utils/giveaways.js index 5ecd56ef4c..1719a27b2a 100644 --- a/src/utils/giveaways.js +++ b/src/utils/giveaways.js @@ -2,7 +2,7 @@ import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; import { logger } from './logger.js'; -import { TitanBotError, ErrorTypes } from './errorHandler.js'; +import { NexoBotError, ErrorTypes } from './errorHandler.js'; import { unwrapReplitData } from './database.js'; import { createGiveawayEmbed as createGiveawayEmbedService, @@ -55,7 +55,7 @@ export async function saveGiveaway(client, guildId, giveawayData) { } if (!giveawayData || !giveawayData.messageId) { - throw new TitanBotError( + throw new NexoBotError( 'Invalid giveaway data: missing messageId', ErrorTypes.VALIDATION, 'Cannot save giveaway without a message ID.', @@ -75,7 +75,7 @@ export async function saveGiveaway(client, guildId, giveawayData) { return true; } catch (error) { logger.error(`Error saving giveaway in guild ${guildId}:`, error); - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { throw error; } return false; @@ -90,7 +90,7 @@ export async function deleteGiveaway(client, guildId, messageId) { } if (!messageId) { - throw new TitanBotError( + throw new NexoBotError( 'Missing messageId parameter', ErrorTypes.VALIDATION, 'Cannot delete giveaway without a message ID.', @@ -115,7 +115,7 @@ export async function deleteGiveaway(client, guildId, messageId) { return true; } catch (error) { logger.error(`Error deleting giveaway ${messageId} in guild ${guildId}:`, error); - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { throw error; } return false; diff --git a/src/utils/interactionHelper.js b/src/utils/interactionHelper.js index 2c159f9cd9..7f7a6d2cd9 100644 --- a/src/utils/interactionHelper.js +++ b/src/utils/interactionHelper.js @@ -32,7 +32,7 @@ export class InteractionHelper { } static patchInteractionResponses(interaction) { - if (!interaction || interaction.__titanResponsePatched) { + if (!interaction || interaction.__nexoResponsePatched) { return; } @@ -70,7 +70,7 @@ export class InteractionHelper { return await originalFollowUp(options); }; - interaction.__titanResponsePatched = true; + interaction.__nexoResponsePatched = true; } static isInteractionValid(interaction) { diff --git a/src/utils/logger.js b/src/utils/logger.js index f8f820db3f..df14e57dd2 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -163,7 +163,7 @@ const logger = createLogger({ errors({ stack: true }), format.json() ), - defaultMeta: { service: 'titan-bot' }, + defaultMeta: { service: 'nexobot' }, transports: [ new transports.DailyRotateFile({ filename: path.join(__dirname, '../../logs/error-%DATE%.log'), diff --git a/src/utils/serviceErrorBoundary.js b/src/utils/serviceErrorBoundary.js index bfb0855106..52deed4556 100644 --- a/src/utils/serviceErrorBoundary.js +++ b/src/utils/serviceErrorBoundary.js @@ -1,6 +1,6 @@ // serviceErrorBoundary.js -import { createError, ErrorTypes, TitanBotError, categorizeError, getUserMessage } from './errorHandler.js'; +import { createError, ErrorTypes, NexoBotError, categorizeError, getUserMessage } from './errorHandler.js'; import { resolveErrorCode, getErrorMetadata } from './errorRegistry.js'; function normalizeBoundaryContext(context = {}) { @@ -12,7 +12,7 @@ function normalizeBoundaryContext(context = {}) { } export function ensureTypedServiceError(error, options = {}) { - if (error instanceof TitanBotError) { + if (error instanceof NexoBotError) { return error; } From 8c1ed952e6a032ac6c8a94e6e3d4a182a0ff1d18 Mon Sep 17 00:00:00 2001 From: robertwalter Date: Sat, 17 Oct 2026 16:01:24 +0200 Subject: [PATCH 03/20] build: add TypeScript toolchain and move scripts to src/scripts - tsconfig (NodeNext, ES2022, allowJs for the incremental migration) - build/start/dev/typecheck npm scripts; maintenance scripts run from dist/scripts - loaders accept .ts and .js modules - read package.json at runtime instead of importing it outside rootDir - multi-stage Dockerfile, CI builds before running scripts - drop root index.js Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 1 + .github/workflows/migration-version-check.yml | 3 + .github/workflows/restore-drill.yml | 3 + Dockerfile | 18 +- index.js | 1 - package-lock.json | 1007 ++++++++++++++++- package.json | 34 +- roadmap.txt | 2 +- src/app.js | 5 +- src/handlers/help/helpSelectMenus.js | 5 +- src/handlers/loaders/commandLoader.js | 5 +- src/handlers/loaders/events.js | 3 +- src/handlers/loaders/interactions.js | 3 +- {scripts => src/scripts}/backup.js | 4 +- {scripts => src/scripts}/migrate-keys.js | 10 +- {scripts => src/scripts}/migrate.js | 12 +- {scripts => src/scripts}/restore-drill.js | 4 +- {scripts => src/scripts}/restore.js | 4 +- src/utils/database/schema.js | 2 +- src/utils/helpers.js | 5 + tsconfig.json | 19 + 21 files changed, 1090 insertions(+), 60 deletions(-) delete mode 100644 index.js rename {scripts => src/scripts}/backup.js (97%) rename {scripts => src/scripts}/migrate-keys.js (79%) rename {scripts => src/scripts}/migrate.js (95%) rename {scripts => src/scripts}/restore-drill.js (98%) rename {scripts => src/scripts}/restore.js (96%) create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore index 4795f0b105..e1e8aa5234 100644 --- a/.dockerignore +++ b/.dockerignore @@ -14,3 +14,4 @@ logs .DS_Store coverage .agent-tools +dist diff --git a/.github/workflows/migration-version-check.yml b/.github/workflows/migration-version-check.yml index dbb7dd20e3..820073e2ba 100644 --- a/.github/workflows/migration-version-check.yml +++ b/.github/workflows/migration-version-check.yml @@ -51,6 +51,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Build + run: npm run build + - name: Apply migrations run: npm run migrate diff --git a/.github/workflows/restore-drill.yml b/.github/workflows/restore-drill.yml index 7e94be88ca..c5a6f8777d 100644 --- a/.github/workflows/restore-drill.yml +++ b/.github/workflows/restore-drill.yml @@ -52,6 +52,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Build + run: npm run build + - name: Apply migrations run: npm run migrate diff --git a/Dockerfile b/Dockerfile index 03b7bd00f1..1971275e61 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,14 @@ +FROM node:20-alpine AS build + +WORKDIR /usr/src/app + +COPY package*.json ./ +RUN npm ci + +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build + FROM node:20-alpine WORKDIR /usr/src/app @@ -5,10 +16,11 @@ WORKDIR /usr/src/app ENV NODE_ENV=production COPY package*.json ./ -RUN npm ci --omit=dev +RUN npm ci --omit=dev && npm cache clean --force -COPY . . +COPY --from=build /usr/src/app/dist ./dist +COPY lavalink ./lavalink EXPOSE 3000 -CMD ["npm", "start"] +CMD ["node", "dist/app.js"] diff --git a/index.js b/index.js deleted file mode 100644 index fdb508fd35..0000000000 --- a/index.js +++ /dev/null @@ -1 +0,0 @@ -import './src/app.js'; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b11b7cdec6..db1e8776d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { - "name": "titanbot", - "version": "2.1.0", + "name": "nexobot", + "version": "1.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "titanbot", - "version": "2.1.0", + "name": "nexobot", + "version": "1.0.0", + "license": "MIT", "dependencies": { "@discordjs/rest": "^2.6.1", "axios": "^1.15.2", @@ -20,6 +21,13 @@ "winston-daily-rotate-file": "^5.0.0", "zod": "^3.25.76" }, + "devDependencies": { + "@types/express": "^5.0.6", + "@types/node": "^20.19.43", + "@types/pg": "^8.23.1", + "tsx": "^4.23.15", + "typescript": "^5.9.3" + }, "engines": { "node": ">=20.10.0" } @@ -262,6 +270,448 @@ "url": "https://github.com/discordjs/discord.js?sponsor" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@sapphire/async-queue": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", @@ -301,12 +751,113 @@ "text-hex": "1.0.x" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { - "version": "25.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", - "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "dev": true, + "license": "MIT", "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" } }, "node_modules/@types/triple-beam": { @@ -725,6 +1276,48 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -892,6 +1485,21 @@ "node": ">= 0.8" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -1838,6 +2446,25 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-is": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", @@ -1867,6 +2494,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici": { "version": "6.27.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", @@ -1876,9 +2517,10 @@ } }, "node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==" + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" }, "node_modules/unpipe": { "version": "1.0.0", @@ -2195,6 +2837,188 @@ "ws": "^8.17.0" } }, + "@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "dev": true, + "optional": true + }, + "@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "dev": true, + "optional": true + }, + "@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "dev": true, + "optional": true + }, + "@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "dev": true, + "optional": true + }, + "@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "dev": true, + "optional": true + }, + "@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "dev": true, + "optional": true + }, + "@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "dev": true, + "optional": true + }, "@sapphire/async-queue": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", @@ -2223,12 +3047,102 @@ "text-hex": "1.0.x" } }, + "@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true + }, "@types/node": { - "version": "25.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", - "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "requires": { - "undici-types": ">=7.24.0 <7.24.7" + "undici-types": "~6.21.0" + } + }, + "@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "dev": true, + "requires": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true + }, + "@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true + }, + "@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "requires": { + "@types/http-errors": "*", + "@types/node": "*" } }, "@types/triple-beam": { @@ -2526,6 +3440,40 @@ "hasown": "^2.0.2" } }, + "esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "requires": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -2649,6 +3597,13 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==" }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "optional": true + }, "function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3316,6 +4271,16 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, + "tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "requires": { + "esbuild": "~0.28.0", + "fsevents": "~2.3.3" + } + }, "type-is": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", @@ -3333,15 +4298,21 @@ } } }, + "typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true + }, "undici": { "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", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==" + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" }, "unpipe": { "version": "1.0.0", diff --git a/package.json b/package.json index f0cdb249f8..00c1bda378 100644 --- a/package.json +++ b/package.json @@ -12,18 +12,21 @@ "url": "https://github.com/Robertwlt03/NexoBot/issues" }, "homepage": "https://github.com/Robertwlt03/NexoBot#readme", - "main": "src/app.js", + "main": "dist/app.js", "type": "module", "scripts": { - "start": "node src/app.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" + "build": "tsc -p tsconfig.json", + "start": "node dist/app.js", + "dev": "tsx watch src/app.ts", + "typecheck": "tsc -p tsconfig.json --noEmit", + "migrate": "node dist/scripts/migrate.js apply", + "migrate:check": "node dist/scripts/migrate.js check", + "migrate:status": "node dist/scripts/migrate.js status", + "migrate:keys": "node dist/scripts/migrate-keys.js", + "migrate:keys:dry": "node dist/scripts/migrate-keys.js --dry-run", + "backup:db": "node dist/scripts/backup.js", + "restore:db": "node dist/scripts/restore.js", + "backup:drill": "node dist/scripts/restore-drill.js" }, "dependencies": { "@discordjs/rest": "^2.6.1", @@ -33,10 +36,10 @@ "express": "^5.1.0", "node-cron": "^4.2.1", "pg": "^8.11.3", + "riffy": "1.0.12", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", - "zod": "^3.25.76", - "riffy": "1.0.12" + "zod": "^3.25.76" }, "engines": { "node": ">=20.10.0" @@ -44,5 +47,12 @@ "overrides": { "form-data": "^4.0.6", "undici": "^6.27.0" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "@types/node": "^20.19.43", + "@types/pg": "^8.23.1", + "tsx": "^4.23.15", + "typescript": "^5.9.3" } } diff --git a/roadmap.txt b/roadmap.txt index 4d06efe6b1..ec78c506eb 100644 --- a/roadmap.txt +++ b/roadmap.txt @@ -9,7 +9,7 @@ Root - package.json -> scripts, dependencies, project metadata - README.md -> setup and usage docs - SECURITY.md -> security policy -- scripts/ -> utility/maintenance scripts (backup, restore, migrate) +- src/scripts/ -> maintenance scripts (backup, restore, migrate), run from dist/scripts - logs/ -> runtime log output - src/ -> main bot application code - tests/ -> unit tests (run via npm test) diff --git a/src/app.js b/src/app.js index 50d61419d0..1f2e5db5c5 100644 --- a/src/app.js +++ b/src/app.js @@ -15,9 +15,12 @@ import { loadCommands, registerCommands as registerSlashCommands } from './handl 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 { readFileSync } from 'node:fs'; import { EXPECTED_SCHEMA_VERSION, EXPECTED_SCHEMA_LABEL } from './config/database/schemaVersion.js'; +// package.json sits outside rootDir; read it at runtime (works from src/ and dist/). +const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); + class NexoBot extends Client { constructor() { super({ diff --git a/src/handlers/help/helpSelectMenus.js b/src/handlers/help/helpSelectMenus.js index 960826e7de..bf4b8ab7db 100644 --- a/src/handlers/help/helpSelectMenus.js +++ b/src/handlers/help/helpSelectMenus.js @@ -1,4 +1,5 @@ import { createEmbed } from '../../utils/embeds.js'; +import { isLoadableModuleFile } from '../../utils/helpers.js'; import { createButton, getPaginationRow } from '../../utils/components.js'; import fs from 'fs/promises'; import path from 'path'; @@ -129,7 +130,7 @@ async function createCategoryCommandsMenu(category, client) { try { const categoryPath = path.join(__dirname, "../../commands", category); const commandFiles = (await fs.readdir(categoryPath)) - .filter((file) => file.endsWith(".js")) + .filter(isLoadableModuleFile) .sort(); for (const file of commandFiles) { @@ -258,7 +259,7 @@ export async function createAllCommandsMenu(page = 1, client) { category, ); const commandFiles = (await fs.readdir(categoryPath)) - .filter((file) => file.endsWith(".js")) + .filter(isLoadableModuleFile) .sort(); for (const file of commandFiles) { diff --git a/src/handlers/loaders/commandLoader.js b/src/handlers/loaders/commandLoader.js index ecb7ad96d4..ddc85fd7e1 100644 --- a/src/handlers/loaders/commandLoader.js +++ b/src/handlers/loaders/commandLoader.js @@ -1,4 +1,5 @@ import fs from 'fs/promises'; +import { isLoadableModuleFile } from '../../utils/helpers.js'; import path from 'path'; import { fileURLToPath, pathToFileURL } from 'url'; import { Collection } from 'discord.js'; @@ -43,7 +44,7 @@ async function getAllFiles(directory, fileList = []) { continue; } await getAllFiles(filePath, fileList); - } else if (file.name.endsWith('.js')) { + } else if (isLoadableModuleFile(file.name)) { fileList.push(filePath); } } @@ -64,7 +65,7 @@ export async function loadCommands(client) { try { const normalizedPath = filePath.replace(/\\/g, '/'); - const commandName = path.basename(filePath, '.js'); + const commandName = path.parse(filePath).name; const commandDir = path.dirname(filePath); const category = path.basename(commandDir); diff --git a/src/handlers/loaders/events.js b/src/handlers/loaders/events.js index deca4eb1b3..8fd16ad7b1 100644 --- a/src/handlers/loaders/events.js +++ b/src/handlers/loaders/events.js @@ -1,4 +1,5 @@ import { readdir } from 'fs/promises'; +import { isLoadableModuleFile } from '../../utils/helpers.js'; import { join } from 'path'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; @@ -9,7 +10,7 @@ const __dirname = dirname(__filename); export default async function loadEvents(client) { const eventsPath = join(__dirname, '../../events'); - const eventFiles = await readdir(eventsPath).then(files => files.filter(file => file.endsWith('.js'))); + const eventFiles = await readdir(eventsPath).then(files => files.filter(isLoadableModuleFile)); logger.info(`Found ${eventFiles.length} event files to load`); diff --git a/src/handlers/loaders/interactions.js b/src/handlers/loaders/interactions.js index 12a276268a..43c6d274fd 100644 --- a/src/handlers/loaders/interactions.js +++ b/src/handlers/loaders/interactions.js @@ -1,4 +1,5 @@ import { readdir } from 'fs/promises'; +import { isLoadableModuleFile } from '../../utils/helpers.js'; import { join } from 'path'; import { fileURLToPath, pathToFileURL } from 'url'; import { dirname } from 'path'; @@ -17,7 +18,7 @@ async function getAllInteractionFiles(directory, fileList = []) { if (entry.isDirectory()) { await getAllInteractionFiles(entryPath, fileList); - } else if (entry.name.endsWith('.js')) { + } else if (isLoadableModuleFile(entry.name)) { fileList.push(entryPath); } } diff --git a/scripts/backup.js b/src/scripts/backup.js similarity index 97% rename from scripts/backup.js rename to src/scripts/backup.js index 18414f968c..0086360230 100644 --- a/scripts/backup.js +++ b/src/scripts/backup.js @@ -3,10 +3,10 @@ import { mkdir, readdir, stat, unlink } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import dotenv from 'dotenv'; -import { logger } from '../src/utils/logger.js'; +import { logger } from '../utils/logger.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -dotenv.config({ path: path.join(__dirname, '..', '.env') }); +dotenv.config({ path: path.join(__dirname, '..', '..', '.env') }); function parseArgs(argv) { const args = {}; diff --git a/scripts/migrate-keys.js b/src/scripts/migrate-keys.js similarity index 79% rename from scripts/migrate-keys.js rename to src/scripts/migrate-keys.js index e82fb31ec4..b19618c5f0 100644 --- a/scripts/migrate-keys.js +++ b/src/scripts/migrate-keys.js @@ -8,19 +8,19 @@ * a dry-run preview or to force a re-run. * * Usage: - * node scripts/migrate-keys.js [--dry-run] [--force] + * node dist/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'; +import { resolveSslConfig } from '../config/database/postgres.js'; +import { runKeyMigration } from '../utils/database/keyMigration.js'; +import { logger } from '../utils/logger.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -dotenv.config({ path: path.join(__dirname, '..', '.env') }); +dotenv.config({ path: path.join(__dirname, '..', '..', '.env') }); const { Pool } = pg; const dryRun = process.argv.includes('--dry-run'); diff --git a/scripts/migrate.js b/src/scripts/migrate.js similarity index 95% rename from scripts/migrate.js rename to src/scripts/migrate.js index 24ea9b0b31..a95125d349 100644 --- a/scripts/migrate.js +++ b/src/scripts/migrate.js @@ -2,14 +2,14 @@ import pg from 'pg'; 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/database/schemaVersion.js'; +import { logger } from '../utils/logger.js'; +import { EXPECTED_SCHEMA_LABEL, EXPECTED_SCHEMA_VERSION } from '../config/database/schemaVersion.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -dotenv.config({ path: path.join(__dirname, '..', '.env') }); +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'); +const { resolveSslConfig } = await import('../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 { @@ -17,8 +17,8 @@ const { indexStatements, UPDATE_TIMESTAMP_FUNCTION, triggerDefinitions, -} = await import('../src/utils/database/schema.js'); -const { assertAllowlistedIdentifier, quoteIdentifier } = await import('../src/utils/sqlIdentifiers.js'); +} = await import('../utils/database/schema.js'); +const { assertAllowlistedIdentifier, quoteIdentifier } = await import('../utils/sqlIdentifiers.js'); const { Pool } = pg; diff --git a/scripts/restore-drill.js b/src/scripts/restore-drill.js similarity index 98% rename from scripts/restore-drill.js rename to src/scripts/restore-drill.js index 58811224ed..7f346c4a9c 100644 --- a/scripts/restore-drill.js +++ b/src/scripts/restore-drill.js @@ -4,10 +4,10 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import dotenv from 'dotenv'; import pg from 'pg'; -import { logger } from '../src/utils/logger.js'; +import { logger } from '../utils/logger.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -dotenv.config({ path: path.join(__dirname, '..', '.env') }); +dotenv.config({ path: path.join(__dirname, '..', '..', '.env') }); const { Pool } = pg; diff --git a/scripts/restore.js b/src/scripts/restore.js similarity index 96% rename from scripts/restore.js rename to src/scripts/restore.js index e5f81ac36c..f27cc7513a 100644 --- a/scripts/restore.js +++ b/src/scripts/restore.js @@ -3,10 +3,10 @@ import { readdir } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import dotenv from 'dotenv'; -import { logger } from '../src/utils/logger.js'; +import { logger } from '../utils/logger.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -dotenv.config({ path: path.join(__dirname, '..', '.env') }); +dotenv.config({ path: path.join(__dirname, '..', '..', '.env') }); function parseArgs(argv) { const args = {}; diff --git a/src/utils/database/schema.js b/src/utils/database/schema.js index 4c7d2e6594..50945419e3 100644 --- a/src/utils/database/schema.js +++ b/src/utils/database/schema.js @@ -2,7 +2,7 @@ * 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 + * standalone migration script (src/scripts/migrate.ts) build the database from these * definitions, so the schema can never diverge between them. */ diff --git a/src/utils/helpers.js b/src/utils/helpers.js index ab6a1e2076..3ddad5aa74 100644 --- a/src/utils/helpers.js +++ b/src/utils/helpers.js @@ -19,3 +19,8 @@ export function getPriorityMap() { } export const PRIORITY_MAP = getPriorityMap(); + +// Loaders accept compiled .js and source .ts (tsx dev mode), never declaration files. +export function isLoadableModuleFile(fileName) { + return /\.(js|ts)$/.test(fileName) && !fileName.endsWith('.d.ts'); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000000..119700d157 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "sourceMap": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowJs": true, + "checkJs": false, + "strict": false + }, + "include": ["src"] +} From 49388f28d11a435e4d8a2dfc2b8b2537692b9e39 Mon Sep 17 00:00:00 2001 From: robertwalter Date: Sat, 17 Oct 2026 16:19:20 +0200 Subject: [PATCH 04/20] refactor(ts): convert utils, database layer, config and scripts to TypeScript Adds shared types (src/types), central zod env validation (config/env.ts) and a typed key-value store API. Runtime behaviour unchanged. Co-Authored-By: Claude Opus 5.5 --- src/config/{application.js => application.ts} | 28 +- src/config/{bot.js => bot.ts} | 133 +++--- .../{commandAliases.js => commandAliases.ts} | 8 +- ...mandCategories.js => commandCategories.ts} | 8 +- ...xRestrictions.js => prefixRestrictions.ts} | 49 ++- .../database/{postgres.js => postgres.ts} | 28 +- .../{schemaVersion.js => schemaVersion.ts} | 0 src/config/env.ts | 117 +++++ ...nfigDefaults.js => guildConfigDefaults.ts} | 0 src/config/music/lavalink.js | 85 ---- src/config/music/lavalink.ts | 97 ++++ src/config/shop/{index.js => index.ts} | 20 +- src/config/shop/{items.js => items.ts} | 51 ++- src/scripts/{backup.js => backup.ts} | 26 +- .../{migrate-keys.js => migrate-keys.ts} | 4 +- src/scripts/{migrate.js => migrate.ts} | 34 +- .../{restore-drill.js => restore-drill.ts} | 30 +- src/scripts/{restore.js => restore.ts} | 26 +- src/types/index.ts | 118 +++++ ...{abuseProtection.js => abuseProtection.ts} | 70 ++- ...orComponents.js => collectorComponents.ts} | 2 +- ...alidation.js => commandInputValidation.ts} | 24 +- ...{commandPipeline.js => commandPipeline.ts} | 4 +- src/utils/{components.js => components.ts} | 42 +- src/utils/{constants.js => constants.ts} | 0 ...ashboardSession.js => dashboardSession.ts} | 53 ++- src/utils/{database.js => database.ts} | 415 ++++++++++++++---- ...ConfigStorage.js => guildConfigStorage.ts} | 37 +- .../{keyMigration.js => keyMigration.ts} | 59 ++- .../database/{keyParser.js => keyParser.ts} | 46 +- src/utils/database/{keys.js => keys.ts} | 80 ++-- src/utils/database/{schema.js => schema.ts} | 0 src/utils/database/{tickets.js => tickets.ts} | 67 ++- src/utils/database/{wrapper.js => wrapper.ts} | 96 ++-- src/utils/{economy.js => economy.ts} | 101 ++++- src/utils/{embeds.js => embeds.ts} | 113 +++-- .../{errorHandler.js => errorHandler.ts} | 165 ++++--- .../{errorRegistry.js => errorRegistry.ts} | 36 +- src/utils/{giveaways.js => giveaways.ts} | 49 ++- src/utils/{helpers.js => helpers.ts} | 21 +- ...eractionHelper.js => interactionHelper.ts} | 142 ++++-- ...onValidator.js => interactionValidator.ts} | 36 +- src/utils/{logger.js => logger.ts} | 57 ++- .../logging/{logEmbeds.js => logEmbeds.ts} | 90 +++- .../logging/{loggingUi.js => loggingUi.ts} | 36 +- .../{memoryStorage.js => memoryStorage.ts} | 33 +- .../{messageAdapter.js => messageAdapter.ts} | 124 +++++- src/utils/{moderation.js => moderation.ts} | 88 +++- src/utils/{mutex.js => mutex.ts} | 4 +- src/utils/{panelStatus.js => panelStatus.ts} | 95 +++- ...{permissionGuard.js => permissionGuard.ts} | 98 +++-- ...ostgresDatabase.js => postgresDatabase.ts} | 263 ++++++----- .../{prefixParser.js => prefixParser.ts} | 104 ++++- src/utils/{rateLimiter.js => rateLimiter.ts} | 23 +- src/utils/responseCoordinator.js | 197 --------- src/utils/responseCoordinator.ts | 250 +++++++++++ .../{safeMathParser.js => safeMathParser.ts} | 52 ++- src/utils/{schemas.js => schemas.ts} | 53 ++- ...rorBoundary.js => serviceErrorBoundary.ts} | 57 ++- .../{sqlIdentifiers.js => sqlIdentifiers.ts} | 8 +- .../{ticketLogging.js => ticketLogging.ts} | 93 +++- ...ketPermissions.js => ticketPermissions.ts} | 19 +- src/utils/{validation.js => validation.ts} | 26 +- src/utils/{welcome.js => welcome.ts} | 36 +- tsconfig.json | 2 +- 65 files changed, 3054 insertions(+), 1274 deletions(-) rename src/config/{application.js => application.ts} (72%) rename src/config/{bot.js => bot.ts} (81%) rename src/config/commands/{commandAliases.js => commandAliases.ts} (92%) rename src/config/commands/{commandCategories.js => commandCategories.ts} (76%) rename src/config/commands/{prefixRestrictions.js => prefixRestrictions.ts} (67%) rename src/config/database/{postgres.js => postgres.ts} (86%) rename src/config/database/{schemaVersion.js => schemaVersion.ts} (100%) create mode 100644 src/config/env.ts rename src/config/guild/{guildConfigDefaults.js => guildConfigDefaults.ts} (100%) delete mode 100644 src/config/music/lavalink.js create mode 100644 src/config/music/lavalink.ts rename src/config/shop/{index.js => index.ts} (86%) rename src/config/shop/{items.js => items.ts} (79%) rename src/scripts/{backup.js => backup.ts} (80%) rename src/scripts/{migrate-keys.js => migrate-keys.ts} (91%) rename src/scripts/{migrate.js => migrate.ts} (84%) rename src/scripts/{restore-drill.js => restore-drill.ts} (83%) rename src/scripts/{restore.js => restore.ts} (78%) create mode 100644 src/types/index.ts rename src/utils/{abuseProtection.js => abuseProtection.ts} (68%) rename src/utils/{collectorComponents.js => collectorComponents.ts} (77%) rename src/utils/{commandInputValidation.js => commandInputValidation.ts} (65%) rename src/utils/{commandPipeline.js => commandPipeline.ts} (83%) rename src/utils/{components.js => components.ts} (70%) rename src/utils/{constants.js => constants.ts} (100%) rename src/utils/{dashboardSession.js => dashboardSession.ts} (66%) rename src/utils/{database.js => database.ts} (71%) rename src/utils/database/{guildConfigStorage.js => guildConfigStorage.ts} (72%) rename src/utils/database/{keyMigration.js => keyMigration.ts} (80%) rename src/utils/database/{keyParser.js => keyParser.ts} (89%) rename src/utils/database/{keys.js => keys.ts} (68%) rename src/utils/database/{schema.js => schema.ts} (100%) rename src/utils/database/{tickets.js => tickets.ts} (67%) rename src/utils/database/{wrapper.js => wrapper.ts} (56%) rename src/utils/{economy.js => economy.ts} (80%) rename src/utils/{embeds.js => embeds.ts} (67%) rename src/utils/{errorHandler.js => errorHandler.ts} (77%) rename src/utils/{errorRegistry.js => errorRegistry.ts} (80%) rename src/utils/{giveaways.js => giveaways.ts} (77%) rename src/utils/{helpers.js => helpers.ts} (54%) rename src/utils/{interactionHelper.js => interactionHelper.ts} (66%) rename src/utils/{interactionValidator.js => interactionValidator.ts} (68%) rename src/utils/{logger.js => logger.ts} (76%) rename src/utils/logging/{logEmbeds.js => logEmbeds.ts} (64%) rename src/utils/logging/{loggingUi.js => loggingUi.ts} (76%) rename src/utils/{memoryStorage.js => memoryStorage.ts} (71%) rename src/utils/{messageAdapter.js => messageAdapter.ts} (58%) rename src/utils/{moderation.js => moderation.ts} (71%) rename src/utils/{mutex.js => mutex.ts} (81%) rename src/utils/{panelStatus.js => panelStatus.ts} (54%) rename src/utils/{permissionGuard.js => permissionGuard.ts} (70%) rename src/utils/{postgresDatabase.js => postgresDatabase.ts} (78%) rename src/utils/{prefixParser.js => prefixParser.ts} (64%) rename src/utils/{rateLimiter.js => rateLimiter.ts} (65%) delete mode 100644 src/utils/responseCoordinator.js create mode 100644 src/utils/responseCoordinator.ts rename src/utils/{safeMathParser.js => safeMathParser.ts} (83%) rename src/utils/{schemas.js => schemas.ts} (77%) rename src/utils/{serviceErrorBoundary.js => serviceErrorBoundary.ts} (55%) rename src/utils/{sqlIdentifiers.js => sqlIdentifiers.ts} (71%) rename src/utils/ticket/{ticketLogging.js => ticketLogging.ts} (73%) rename src/utils/ticket/{ticketPermissions.js => ticketPermissions.ts} (55%) rename src/utils/{validation.js => validation.ts} (77%) rename src/utils/{welcome.js => welcome.ts} (68%) diff --git a/src/config/application.js b/src/config/application.ts similarity index 72% rename from src/config/application.js rename to src/config/application.ts index 159b9eb295..5caca52651 100644 --- a/src/config/application.js +++ b/src/config/application.ts @@ -3,8 +3,10 @@ import path from "path"; import botConfig, { validateConfig } from "./bot.js"; import { shopConfig as shop } from "./shop/index.js"; import { pgConfig } from "./database/postgres.js"; +import { getEnv } from "./env.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const env = getEnv(); const appConfig = { paths: { @@ -20,10 +22,10 @@ const appConfig = { bot: { ...botConfig, - token: process.env.DISCORD_TOKEN || process.env.TOKEN, - clientId: process.env.CLIENT_ID, + token: env.DISCORD_TOKEN || env.TOKEN, + clientId: env.CLIENT_ID, // Retained for tutorial/setup compatibility; not used for command registration. - guildId: process.env.GUILD_ID, + guildId: env.GUILD_ID, shop: { ...botConfig.shop, @@ -37,9 +39,9 @@ const appConfig = { }, logging: { - level: process.env.LOG_LEVEL || "info", + level: env.LOG_LEVEL || "info", file: { - enabled: process.env.LOG_TO_FILE === "true", + enabled: env.LOG_TO_FILE === "true", path: path.join(__dirname, "../../logs"), maxSize: "20m", maxFiles: "14d", @@ -51,16 +53,16 @@ const appConfig = { timestamp: true, }, sentry: { - enabled: process.env.SENTRY_DSN ? true : false, - dsn: process.env.SENTRY_DSN, - environment: process.env.NODE_ENV || "development", + enabled: env.SENTRY_DSN ? true : false, + dsn: env.SENTRY_DSN, + environment: env.NODE_ENV || "development", }, }, api: { - port: process.env.PORT || 3000, + port: env.PORT || 3000, cors: { - origin: process.env.CORS_ORIGIN?.split(",") || "*", + origin: env.CORS_ORIGIN?.split(",") || "*", methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], allowedHeaders: ["Content-Type", "Authorization"], }, @@ -77,9 +79,9 @@ const appConfig = { music: botConfig.features?.music ?? true, }, - env: process.env.NODE_ENV || "development", - isProduction: process.env.NODE_ENV === "production", - isDevelopment: process.env.NODE_ENV !== "production", + env: env.NODE_ENV || "development", + isProduction: env.NODE_ENV === "production", + isDevelopment: env.NODE_ENV !== "production", }; Object.freeze(appConfig); diff --git a/src/config/bot.js b/src/config/bot.ts similarity index 81% rename from src/config/bot.js rename to src/config/bot.ts index d3623f485f..6f82136f69 100644 --- a/src/config/bot.js +++ b/src/config/bot.ts @@ -1,4 +1,8 @@ +import type { Guild, PresenceData } from 'discord.js'; import { logger } from '../utils/logger.js'; +import { getEnv } from './env.js'; + +const env = getEnv(); export const botConfig = { // ========================= @@ -28,7 +32,7 @@ export const botConfig = { type: 4, // Custom }, ], - }, + } satisfies PresenceData, // ========================= // COMMAND BEHAVIOR @@ -36,7 +40,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(",").map((id) => id.trim()).filter(Boolean) || [], + owners: env.OWNER_IDS?.split(",").map((id) => id.trim()).filter(Boolean) || [], // Default wait time between command uses (in seconds). defaultCooldown: 3, @@ -45,14 +49,14 @@ export const botConfig = { deleteCommands: false, // Optional server ID retained for tutorial compatibility; not used for command registration. - testGuildId: process.env.TEST_GUILD_ID, + testGuildId: env.TEST_GUILD_ID, // When true (or MAINTENANCE_MODE=true), only bot owners can run commands. - maintenanceMode: process.env.MAINTENANCE_MODE === "true", + maintenanceMode: 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 || "!", + prefix: env.PREFIX || "!", }, // ========================= @@ -83,7 +87,7 @@ export const botConfig = { deleteApprovedAfter: 30, // Role IDs allowed to manage applications. - managerRoles: [], // Will be populated from environment or database + managerRoles: [] as string[], // Will be populated from environment or database }, // ========================= @@ -143,15 +147,15 @@ export const botConfig = { // Default footer text used in bot embeds. text: "Nexo", // Footer icon URL (null = no icon). - icon: null, + icon: null as string | null, }, // Default thumbnail URL for embeds (null = no thumbnail). - thumbnail: null, + thumbnail: null as string | null, author: { // Optional default embed author block. - name: null, - icon: null, - url: null, + name: null as string | null, + icon: null as string | null, + url: null as string | null, }, }, @@ -214,10 +218,10 @@ export const botConfig = { // ========================= tickets: { // Category ID where new tickets are created (null = no forced category). - defaultCategory: null, + defaultCategory: null as string | null, // Role IDs allowed to manage/support tickets. - supportRoles: [], + supportRoles: [] as string[], // Priority options users/staff can assign. priorities: { @@ -252,10 +256,10 @@ export const botConfig = { defaultPriority: "none", // Category ID where closed tickets are archived. - archiveCategory: null, + archiveCategory: null as string | null, // Channel ID where ticket logs are sent. - logChannel: null, + logChannel: null as string | null, }, // ========================= @@ -277,10 +281,10 @@ export const botConfig = { maximumDuration: 2592000000, // Role IDs allowed to host giveaways. - allowedRoles: [], + allowedRoles: [] as string[], // Role IDs that bypass giveaway restrictions. - bypassRoles: [], + bypassRoles: [] as string[], }, // ========================= @@ -288,10 +292,10 @@ export const botConfig = { // ========================= birthday: { // Role ID given to users on their birthday. - defaultRole: null, + defaultRole: null as string | null, // Channel ID where birthday announcements are posted. - announcementChannel: null, + announcementChannel: null as string | null, // Timezone used to calculate birthday dates. timezone: "UTC", @@ -378,9 +382,9 @@ export const botConfig = { defaultGoodbyeMessage: "{user} has left the server. We now have {memberCount} members.", // Channel ID for welcome messages. - defaultWelcomeChannel: null, + defaultWelcomeChannel: null as string | null, // Channel ID for goodbye messages. - defaultGoodbyeChannel: null, + defaultGoodbyeChannel: null as string | null, }, // ========================= @@ -413,18 +417,18 @@ export const botConfig = { members: { name: "👥 Members", description: "Total members in the server", - getCount: (guild) => guild.memberCount.toString(), + getCount: (guild: Guild) => guild.memberCount.toString(), }, bots: { name: "🤖 Bots", description: "Total bot accounts in the server", - getCount: (guild) => + getCount: (guild: Guild) => guild.members.cache.filter((m) => m.user.bot).size.toString(), }, members_only: { name: "👤 Humans", description: "Total human members (non-bots)", - getCount: (guild) => + getCount: (guild: Guild) => guild.members.cache.filter((m) => !m.user.bot).size.toString(), }, }, @@ -477,40 +481,44 @@ export const botConfig = { }, }; -export function validateConfig(config) { - const errors = []; +export type BotConfigShape = typeof botConfig; +export type FeatureKey = keyof BotConfigShape["features"]; + +export function validateConfig(config: BotConfigShape): string[] { + const errors: string[] = []; + const env = getEnv(); - if (process.env.NODE_ENV !== 'production') { + if (env.NODE_ENV !== 'production') { logger.debug('Environment variables check:'); - logger.debug('DISCORD_TOKEN exists:', !!process.env.DISCORD_TOKEN); - logger.debug('TOKEN exists:', !!process.env.TOKEN); - logger.debug('CLIENT_ID exists:', !!process.env.CLIENT_ID); - logger.debug('GUILD_ID exists:', !!process.env.GUILD_ID); - logger.debug('POSTGRES_HOST exists:', !!process.env.POSTGRES_HOST); - logger.debug('NODE_ENV:', process.env.NODE_ENV); + logger.debug('DISCORD_TOKEN exists:', !!env.DISCORD_TOKEN); + logger.debug('TOKEN exists:', !!env.TOKEN); + logger.debug('CLIENT_ID exists:', !!env.CLIENT_ID); + logger.debug('GUILD_ID exists:', !!env.GUILD_ID); + logger.debug('POSTGRES_HOST exists:', !!env.POSTGRES_HOST); + logger.debug('NODE_ENV:', env.NODE_ENV); } - if (!process.env.DISCORD_TOKEN && !process.env.TOKEN) { + if (!env.DISCORD_TOKEN && !env.TOKEN) { errors.push("Bot token is required (DISCORD_TOKEN or TOKEN environment variable)"); } - if (!process.env.CLIENT_ID) { + if (!env.CLIENT_ID) { errors.push("Client ID is required (CLIENT_ID environment variable)"); } - if (process.env.NODE_ENV === 'production') { + if (env.NODE_ENV === 'production') { // 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); + const hasConnectionUrl = Boolean(env.POSTGRES_URL || env.DATABASE_URL); if (!hasConnectionUrl) { - if (!process.env.POSTGRES_HOST) { + if (!env.POSTGRES_HOST) { errors.push("PostgreSQL connection is required in production (set DATABASE_URL/POSTGRES_URL, or POSTGRES_HOST)"); } - if (!process.env.POSTGRES_USER) { + if (!env.POSTGRES_USER) { errors.push("PostgreSQL user is required in production (set DATABASE_URL/POSTGRES_URL, or POSTGRES_USER)"); } - if (!process.env.POSTGRES_PASSWORD) { + if (!env.POSTGRES_PASSWORD) { errors.push("PostgreSQL password is required in production (set DATABASE_URL/POSTGRES_URL, or POSTGRES_PASSWORD)"); } } @@ -522,14 +530,14 @@ export function validateConfig(config) { const configErrors = validateConfig(botConfig); if (configErrors.length > 0) { logger.error("Bot configuration errors:", configErrors.join("\n")); - if (process.env.NODE_ENV === "production") { + if (env.NODE_ENV === "production") { process.exit(1); } } export const BotConfig = botConfig; -const COMMAND_CATEGORY_FEATURE_MAP = { +const COMMAND_CATEGORY_FEATURE_MAP: Record = { birthday: "birthday", community: "community", economy: "economy", @@ -550,21 +558,21 @@ const COMMAND_CATEGORY_FEATURE_MAP = { welcome: "welcome", }; -function normalizeCategoryKey(category) { +function normalizeCategoryKey(category: string | null | undefined): string { return String(category || "").trim().toLowerCase().replace(/\s+/g, "_"); } -export function getCommandPrefix() { +export function getCommandPrefix(): string { return botConfig.commands?.prefix ?? "!"; } -export function getBotOwners() { +export function getBotOwners(): string[] { return (botConfig.commands?.owners ?? []) .map((id) => String(id).trim()) .filter(Boolean); } -export function isBotOwner(userId) { +export function isBotOwner(userId: string | null | undefined): boolean { if (!userId) { return false; } @@ -572,12 +580,13 @@ export function isBotOwner(userId) { return getBotOwners().includes(String(userId)); } -export function isMaintenanceMode() { +export function isMaintenanceMode(): boolean { return botConfig.commands?.maintenanceMode === true; } -export function getBotMessage(key, replacements = {}) { - let message = botConfig.messages?.[key] || key; +export function getBotMessage(key: string, replacements: Record = {}): string { + const messages: Partial> = botConfig.messages; + let message = messages?.[key] || key; for (const [placeholder, value] of Object.entries(replacements)) { message = message.replace(new RegExp(`\\{${placeholder}\\}`, "g"), String(value)); @@ -586,22 +595,23 @@ export function getBotMessage(key, replacements = {}) { return message; } -export function isFeatureEnabled(featureKey) { +export function isFeatureEnabled(featureKey: string | null | undefined): boolean { if (!featureKey) { return true; } - return botConfig.features?.[featureKey] !== false; + const features: Partial> = botConfig.features; + return features?.[featureKey] !== false; } -export function isCommandCategoryEnabled(category) { +export function isCommandCategoryEnabled(category: string | null | undefined): boolean { const normalized = normalizeCategoryKey(category); if (!normalized || normalized === "core") { return true; } - const featureKey = COMMAND_CATEGORY_FEATURE_MAP[normalized]; + const featureKey: FeatureKey | undefined = COMMAND_CATEGORY_FEATURE_MAP[normalized]; if (!featureKey) { return true; } @@ -609,19 +619,19 @@ export function isCommandCategoryEnabled(category) { return isFeatureEnabled(featureKey); } -export function getApplicationStatusColor(status) { - const colors = botConfig.applications?.statusColors || {}; +export function getApplicationStatusColor(status: string): number { + const colors: Partial> = botConfig.applications?.statusColors || {}; const hex = colors[status]; return hex ? getColor(hex) : getColor(status === "approved" ? "success" : status === "denied" ? "error" : "warning"); } -export function getDefaultApplicationQuestions() { +export function getDefaultApplicationQuestions(): string[] { return (botConfig.applications?.defaultQuestions || []).map((entry) => typeof entry === "string" ? entry : entry.question, ).filter(Boolean); } -export function getColor(path, fallback = "#99AAB5") { +export function getColor(path: string | number, fallback: string = "#99AAB5"): number { if (typeof path === "number") return path; if (typeof path === "string" && path.startsWith("#")) { @@ -630,18 +640,19 @@ export function getColor(path, fallback = "#99AAB5") { } const result = path .split(".") - .reduce( - (obj, key) => (obj && obj[key] !== undefined ? obj[key] : fallback), + .reduce( + (obj, key) => (obj && (obj as Record)[key] !== undefined ? (obj as Record)[key] : fallback), botConfig.embeds.colors, ); if (typeof result === "string" && result.startsWith("#")) { return parseInt(result.replace("#", ""), 16); } - return result; + // Non-hex results (a color group or a non-hex fallback) are passed through unchanged, as before. + return result as number; } -export function getRandomColor() { +export function getRandomColor(): string { const colors = Object.values(botConfig.embeds.colors).flatMap((color) => typeof color === "string" ? color : Object.values(color), ); diff --git a/src/config/commands/commandAliases.js b/src/config/commands/commandAliases.ts similarity index 92% rename from src/config/commands/commandAliases.js rename to src/config/commands/commandAliases.ts index b23023a143..77eb4072ab 100644 --- a/src/config/commands/commandAliases.js +++ b/src/config/commands/commandAliases.ts @@ -3,7 +3,7 @@ * Maps shortened command names to their full command names */ -export const commandAliases = { +export const commandAliases: Record = { 'bal': 'balance', 'money': 'balance', 'cash': 'balance', @@ -106,7 +106,7 @@ export const commandAliases = { 'now': 'nowplaying', }; -export const subcommandAliases = { +export const subcommandAliases: Record = { 'l': 'list', 'ls': 'list', 's': 'set', @@ -136,7 +136,7 @@ export const subcommandAliases = { * @param {string} commandName - The command name (could be an alias) * @returns {string} - The full command name, or the original if not an alias */ -export function resolveCommandAlias(commandName) { +export function resolveCommandAlias(commandName: string): string { const normalized = commandName.toLowerCase(); return commandAliases[normalized] || commandName; } @@ -146,7 +146,7 @@ export function resolveCommandAlias(commandName) { * @param {string} subcommandName - The subcommand name (could be an alias) * @returns {string} - The full subcommand name, or the original if not an alias */ -export function resolveSubcommandAlias(subcommandName) { +export function resolveSubcommandAlias(subcommandName: string): string { const normalized = subcommandName.toLowerCase(); return subcommandAliases[normalized] || subcommandName; } diff --git a/src/config/commands/commandCategories.js b/src/config/commands/commandCategories.ts similarity index 76% rename from src/config/commands/commandCategories.js rename to src/config/commands/commandCategories.ts index 3d3ec1b5ee..b8a1140a66 100644 --- a/src/config/commands/commandCategories.js +++ b/src/config/commands/commandCategories.ts @@ -2,7 +2,7 @@ * Command category metadata for the command access manager. */ -export const CATEGORY_ICONS = { +export const CATEGORY_ICONS: Record = { Birthday: '🎂', Community: '👥', Core: 'ℹ️', @@ -27,20 +27,20 @@ export const CATEGORY_ICONS = { /** Commands that always stay available so admins can recover access. */ export const PROTECTED_COMMANDS = new Set(['commands', 'configwizard']); -export function normalizeCategoryKey(category) { +export function normalizeCategoryKey(category: string | null | undefined): string { return String(category || '') .trim() .toLowerCase() .replace(/\s+/g, '_'); } -export function formatCategoryName(rawCategory) { +export function formatCategoryName(rawCategory: string | null | undefined): string { return String(rawCategory || '') .replace(/_/g, ' ') .replace(/([a-z])([A-Z])/g, '$1 $2') .replace(/\b\w/g, (char) => char.toUpperCase()); } -export function getCategoryIcon(category) { +export function getCategoryIcon(category: string): string { return CATEGORY_ICONS[category] || CATEGORY_ICONS[formatCategoryName(category)] || '📁'; } diff --git a/src/config/commands/prefixRestrictions.js b/src/config/commands/prefixRestrictions.ts similarity index 67% rename from src/config/commands/prefixRestrictions.js rename to src/config/commands/prefixRestrictions.ts index 2b57f7bd60..56f980d704 100644 --- a/src/config/commands/prefixRestrictions.js +++ b/src/config/commands/prefixRestrictions.ts @@ -2,6 +2,29 @@ * Prefix command restrictions — dashboard and advanced setup flows stay slash-only. */ +import type { + APIApplicationCommandOption, + APIApplicationCommandSubcommandGroupOption, + APIApplicationCommandSubcommandOption, + RESTPostAPIChatInputApplicationCommandsJSONBody, +} from 'discord.js'; +import type { Command } from '../../types/index.js'; + +export interface PrefixRestriction { + blocked: boolean; + reason?: string; +} + +type PrefixCheckedCommand = Pick; + +function isSubcommandGroupOption(opt: APIApplicationCommandOption): opt is APIApplicationCommandSubcommandGroupOption { + return opt.type === 2; +} + +function isSubcommandOption(opt: APIApplicationCommandOption): opt is APIApplicationCommandSubcommandOption { + return opt.type === 1; +} + /** Top-level commands that cannot be invoked via prefix at all. */ export const SLASH_ONLY_COMMANDS = new Set([ 'configwizard', @@ -23,7 +46,7 @@ export const GLOBAL_BLOCKED_SUBCOMMAND_GROUPS = new Set([ ]); /** Per-command subcommands that stay slash-only (beyond the global block list). */ -export const COMMAND_BLOCKED_SUBCOMMANDS = { +export const COMMAND_BLOCKED_SUBCOMMANDS: Record | undefined> = { music: new Set([ 'shuffle', 'loop', @@ -37,21 +60,21 @@ export const COMMAND_BLOCKED_SUBCOMMANDS = { report: new Set(['setchannel']), }; -function collectSubcommandNames(commandJson) { - const subcommandGroup = commandJson.options?.find((opt) => opt.type === 2); +function collectSubcommandNames(commandJson: RESTPostAPIChatInputApplicationCommandsJSONBody): string[] { + const subcommandGroup = commandJson.options?.find(isSubcommandGroupOption); if (subcommandGroup) { - const names = []; + const names: string[] = []; for (const group of subcommandGroup.options || []) { names.push(...(group.options?.map((opt) => opt.name) || [])); } return names; } - return (commandJson.options?.filter((opt) => opt.type === 1) || []).map((sub) => sub.name); + return (commandJson.options?.filter(isSubcommandOption) || []).map((sub) => sub.name); } -function isSubcommandBlocked(commandName, subcommandName) { +function isSubcommandBlocked(commandName: string, subcommandName: string | null | undefined): boolean { if (!subcommandName) { return false; } @@ -71,7 +94,11 @@ function isSubcommandBlocked(commandName, subcommandName) { * @param {(name: string) => string} resolveSubcommandAlias * @returns {{ blocked: boolean, reason?: string }} */ -export function getPrefixRestriction(command, args, resolveSubcommandAlias) { +export function getPrefixRestriction( + command: PrefixCheckedCommand | null | undefined, + args: string[], + resolveSubcommandAlias: (name: string) => string, +): PrefixRestriction { if (!command?.data?.toJSON) { return { blocked: false }; } @@ -91,7 +118,7 @@ export function getPrefixRestriction(command, args, resolveSubcommandAlias) { const resolvedFirstArg = firstArg ? resolveSubcommandAlias(firstArg) : null; const resolvedSecondArg = secondArg ? resolveSubcommandAlias(secondArg) : null; - const subcommandGroup = commandJson.options?.find((opt) => opt.type === 2); + const subcommandGroup = commandJson.options?.find(isSubcommandGroupOption); const allSubcommandNames = collectSubcommandNames(commandJson); const allSubcommandsBlocked = @@ -126,6 +153,10 @@ export function getPrefixRestriction(command, args, resolveSubcommandAlias) { return { blocked: false }; } -export function isPrefixRestrictedCommand(command, args, resolveSubcommandAlias) { +export function isPrefixRestrictedCommand( + command: PrefixCheckedCommand | null | undefined, + args: string[], + resolveSubcommandAlias: (name: string) => string, +): boolean { return getPrefixRestriction(command, args, resolveSubcommandAlias).blocked; } diff --git a/src/config/database/postgres.js b/src/config/database/postgres.ts similarity index 86% rename from src/config/database/postgres.js rename to src/config/database/postgres.ts index 79d92dbdc1..e38ed4bb64 100644 --- a/src/config/database/postgres.js +++ b/src/config/database/postgres.ts @@ -44,7 +44,7 @@ const validatedTables = Object.fromEntries( key, assertAllowlistedIdentifier(value, allowedTableIdentifiers, `PostgreSQL table identifier (${key})`), ]) -); +) as Record; const DEFAULT_POSTGRES_URL = 'postgresql://localhost:5432/titanbot'; @@ -77,10 +77,10 @@ export function resolvePostgresPoolConfig() { const ssl = resolveSslConfig(); const url = (process.env.POSTGRES_URL || process.env.DATABASE_URL || '').trim(); const sharedOptions = { - max: parseInt(process.env.POSTGRES_MAX_CONNECTIONS) || 20, - min: parseInt(process.env.POSTGRES_MIN_CONNECTIONS) || 2, - idleTimeoutMillis: parseInt(process.env.POSTGRES_IDLE_TIMEOUT) || 30000, - connectionTimeoutMillis: parseInt(process.env.POSTGRES_CONNECTION_TIMEOUT) || 10000, + max: parseInt(process.env.POSTGRES_MAX_CONNECTIONS ?? '') || 20, + min: parseInt(process.env.POSTGRES_MIN_CONNECTIONS ?? '') || 2, + idleTimeoutMillis: parseInt(process.env.POSTGRES_IDLE_TIMEOUT ?? '') || 30000, + connectionTimeoutMillis: parseInt(process.env.POSTGRES_CONNECTION_TIMEOUT ?? '') || 10000, application_name: 'titanbot', statement_timeout: process.env.NODE_ENV === 'production' ? 30000 : 0, keepalives: 1, @@ -94,7 +94,7 @@ export function resolvePostgresPoolConfig() { return { host: process.env.POSTGRES_HOST || 'localhost', - port: parseInt(process.env.POSTGRES_PORT) || 5432, + port: parseInt(process.env.POSTGRES_PORT ?? '') || 5432, database: process.env.POSTGRES_DB || 'titanbot', user: process.env.POSTGRES_USER || 'postgres', password: (process.env.POSTGRES_PASSWORD || '').toString(), @@ -108,25 +108,25 @@ export const pgConfig = { options: { host: process.env.POSTGRES_HOST || 'localhost', - port: parseInt(process.env.POSTGRES_PORT) || 5432, + port: parseInt(process.env.POSTGRES_PORT ?? '') || 5432, database: process.env.POSTGRES_DB || 'titanbot', user: process.env.POSTGRES_USER || 'postgres', password: (process.env.POSTGRES_PASSWORD || '').toString(), ssl: resolveSslConfig(), - max: parseInt(process.env.POSTGRES_MAX_CONNECTIONS) || 20, - min: parseInt(process.env.POSTGRES_MIN_CONNECTIONS) || 2, - idleTimeoutMillis: parseInt(process.env.POSTGRES_IDLE_TIMEOUT) || 30000, - connectionTimeoutMillis: parseInt(process.env.POSTGRES_CONNECTION_TIMEOUT) || 10000, + max: parseInt(process.env.POSTGRES_MAX_CONNECTIONS ?? '') || 20, + min: parseInt(process.env.POSTGRES_MIN_CONNECTIONS ?? '') || 2, + idleTimeoutMillis: parseInt(process.env.POSTGRES_IDLE_TIMEOUT ?? '') || 30000, + connectionTimeoutMillis: parseInt(process.env.POSTGRES_CONNECTION_TIMEOUT ?? '') || 10000, application_name: 'titanbot', statement_timeout: process.env.NODE_ENV === 'production' ? 30000 : 0, keepalives: 1, keepalives_idle: 30, - retries: parseInt(process.env.POSTGRES_RETRIES) || 3, - backoffBase: parseInt(process.env.POSTGRES_BACKOFF_BASE) || 100, - backoffMultiplier: parseInt(process.env.POSTGRES_BACKOFF_MULTIPLIER) || 2, + retries: parseInt(process.env.POSTGRES_RETRIES ?? '') || 3, + backoffBase: parseInt(process.env.POSTGRES_BACKOFF_BASE ?? '') || 100, + backoffMultiplier: parseInt(process.env.POSTGRES_BACKOFF_MULTIPLIER ?? '') || 2, }, tables: validatedTables, diff --git a/src/config/database/schemaVersion.js b/src/config/database/schemaVersion.ts similarity index 100% rename from src/config/database/schemaVersion.js rename to src/config/database/schemaVersion.ts diff --git a/src/config/env.ts b/src/config/env.ts new file mode 100644 index 0000000000..e2c6ec6f89 --- /dev/null +++ b/src/config/env.ts @@ -0,0 +1,117 @@ +import { z } from 'zod'; + +/** + * Central schema for every environment variable the project reads. + * Values stay (optional) strings so existing parsing and fallbacks keep working; + * numeric variables are only checked to be integers when set and non-empty. + */ +const str = z.string().optional(); +const intStr = z + .string() + .refine((value) => value.trim() === '' || /^-?\d+$/.test(value.trim()), { + message: 'must be an integer', + }) + .optional(); + +const envSchema = z.object({ + NODE_ENV: str, + + // Discord + DISCORD_TOKEN: str, + TOKEN: str, + CLIENT_ID: str, + GUILD_ID: str, + TEST_GUILD_ID: str, + OWNER_IDS: str, + PREFIX: str, + MAINTENANCE_MODE: str, + + // Logging / monitoring + LOG_LEVEL: str, + LOG_TO_FILE: str, + SENTRY_DSN: str, + + // Web server + PORT: intStr, + PORT_RETRY_ATTEMPTS: intStr, + WEB_HOST: str, + CORS_ORIGIN: str, + + // PostgreSQL + DATABASE_URL: str, + POSTGRES_URL: str, + POSTGRES_HOST: str, + POSTGRES_PORT: intStr, + POSTGRES_DB: str, + POSTGRES_USER: str, + POSTGRES_PASSWORD: str, + POSTGRES_SSL: str, + POSTGRES_MAX_CONNECTIONS: intStr, + POSTGRES_MIN_CONNECTIONS: intStr, + POSTGRES_IDLE_TIMEOUT: intStr, + POSTGRES_CONNECTION_TIMEOUT: intStr, + POSTGRES_RETRIES: intStr, + POSTGRES_BACKOFF_BASE: intStr, + POSTGRES_BACKOFF_MULTIPLIER: intStr, + POSTGRES_MIGRATION_TABLE: str, + POSTGRES_RESTORE_URL: str, + AUTO_MIGRATE: str, + SCHEMA_VERSION: intStr, + SCHEMA_VERSION_LABEL: str, + + // Backups + BACKUP_DIR: str, + BACKUP_RETENTION_DAYS: intStr, + + // Lavalink + LAVALINK_NODES: str, + LAVALINK_NODES_FILE: str, + LAVALINK_HOST: str, + LAVALINK_PORT: intStr, + LAVALINK_PASSWORD: str, + LAVALINK_SECURE: str, + LAVALINK_NAME: str, + LAVALINK_SEARCH_PLATFORM: str, + LAVALINK_REST_VERSION: str, + + // Hosting + RAILWAY_ENVIRONMENT: str, + RAILWAY_PROJECT_ID: str, +}); + +export type Env = z.infer; + +function formatProblems(problems: string[]): string { + return `Invalid environment configuration:\n${problems.map((problem) => `- ${problem}`).join('\n')}`; +} + +function issuesToProblems(error: z.ZodError): string[] { + return error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`); +} + +/** Parses process.env on every call (scripts load .env after their imports run). */ +export function getEnv(): Env { + const result = envSchema.safeParse(process.env); + if (!result.success) { + throw new Error(formatProblems(issuesToProblems(result.error))); + } + return result.data; +} + +/** Like getEnv(), but also requires the variables the bot needs to log in. */ +export function validateBotEnv(): Env { + const result = envSchema.safeParse(process.env); + const problems = result.success ? [] : issuesToProblems(result.error); + + if (!process.env.DISCORD_TOKEN && !process.env.TOKEN) { + problems.push('DISCORD_TOKEN (or legacy TOKEN) is required'); + } + if (!process.env.CLIENT_ID) { + problems.push('CLIENT_ID is required'); + } + + if (problems.length > 0 || !result.success) { + throw new Error(formatProblems(problems)); + } + return result.data; +} diff --git a/src/config/guild/guildConfigDefaults.js b/src/config/guild/guildConfigDefaults.ts similarity index 100% rename from src/config/guild/guildConfigDefaults.js rename to src/config/guild/guildConfigDefaults.ts diff --git a/src/config/music/lavalink.js b/src/config/music/lavalink.js deleted file mode 100644 index e464557a9e..0000000000 --- a/src/config/music/lavalink.js +++ /dev/null @@ -1,85 +0,0 @@ -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; - } - return ['true', '1', 'yes'].includes(String(value).toLowerCase()); -} - -function parseNodesFromEnv() { - const raw = process.env.LAVALINK_NODES?.trim(); - if (!raw) { - return null; - } - - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : null; - } catch { - return null; - } -} - -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'; - const secure = parseBoolean(process.env.LAVALINK_SECURE, false); - - return [{ - host, - port, - password, - secure, - name: process.env.LAVALINK_NAME || 'Main', - }]; -} - -export const lavalinkConfig = { - nodes: getLavalinkNodes(), - defaultSearchPlatform: process.env.LAVALINK_SEARCH_PLATFORM || 'ytmsearch', - restVersion: process.env.LAVALINK_REST_VERSION || 'v4', -}; - -export default lavalinkConfig; diff --git a/src/config/music/lavalink.ts b/src/config/music/lavalink.ts new file mode 100644 index 0000000000..895452f95a --- /dev/null +++ b/src/config/music/lavalink.ts @@ -0,0 +1,97 @@ +import { existsSync, readFileSync } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import type { LavalinkNode, SearchPlatform, Version } from 'riffy'; +import { getEnv } from '../env.js'; + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); + +function parseBoolean(value: string | null | undefined, defaultValue = false): boolean { + if (value === undefined || value === null || value === '') { + return defaultValue; + } + return ['true', '1', 'yes'].includes(String(value).toLowerCase()); +} + +function parseNodesFromEnv(): LavalinkNode[] | null { + const raw = getEnv().LAVALINK_NODES?.trim(); + if (!raw) { + return null; + } + + try { + const parsed: unknown = JSON.parse(raw); + // Node entries are passed to Riffy unvalidated, as before. + return Array.isArray(parsed) ? (parsed as LavalinkNode[]) : null; + } catch { + return null; + } +} + +function parseNodesPayload(parsed: unknown): LavalinkNode[] | null { + if (Array.isArray(parsed)) { + return parsed as LavalinkNode[]; + } + const nodes: unknown = (parsed as { nodes?: unknown } | null | undefined)?.nodes; + if (Array.isArray(nodes)) { + return nodes as LavalinkNode[]; + } + return null; +} + +function loadNodesFromFile(): LavalinkNode[] | null { + const nodesFile = getEnv().LAVALINK_NODES_FILE?.trim() + || path.join(projectRoot, 'lavalink', 'nodes.json'); + + if (!existsSync(nodesFile)) { + return null; + } + + try { + const parsed: unknown = JSON.parse(readFileSync(nodesFile, 'utf8')); + return parseNodesPayload(parsed); + } catch { + return null; + } +} + +export function getLavalinkNodes(): LavalinkNode[] { + const fromJson = parseNodesFromEnv(); + if (fromJson?.length) { + return fromJson; + } + + const fromFile = loadNodesFromFile(); + if (fromFile?.length) { + return fromFile; + } + + const env = getEnv(); + const host = env.LAVALINK_HOST || 'localhost'; + const port = Number(env.LAVALINK_PORT || 2333); + const password = env.LAVALINK_PASSWORD || 'youshallnotpass'; + const secure = parseBoolean(env.LAVALINK_SECURE, false); + + return [{ + host, + port, + password, + secure, + name: env.LAVALINK_NAME || 'Main', + }]; +} + +export interface LavalinkConfig { + nodes: LavalinkNode[]; + defaultSearchPlatform: SearchPlatform; + restVersion: Version; +} + +export const lavalinkConfig: LavalinkConfig = { + nodes: getLavalinkNodes(), + defaultSearchPlatform: getEnv().LAVALINK_SEARCH_PLATFORM || 'ytmsearch', + // Passed through unvalidated, as before. + restVersion: (getEnv().LAVALINK_REST_VERSION || 'v4') as Version, +}; + +export default lavalinkConfig; diff --git a/src/config/shop/index.js b/src/config/shop/index.ts similarity index 86% rename from src/config/shop/index.js rename to src/config/shop/index.ts index b9159f7c84..1960292cef 100644 --- a/src/config/shop/index.js +++ b/src/config/shop/index.ts @@ -1,8 +1,17 @@ import { shopItems, getItemById, getItemsByType, getItemPrice, validatePurchase } from './items.js'; +import type { ShopItem, ShopUserData } from './items.js'; import { botConfig } from '../bot.js'; const { currency } = botConfig.economy; +export interface ShopCategory { + id: string; + name: string; + description: string; + icon: string; + itemTypes: string[]; +} + export const shopConfig = { name: 'Nexo Shop', currency: currency.name, @@ -96,7 +105,7 @@ mythic: '#E74C3C' restock: { enabled: true, interval: 86400000, -announcementChannel: null, +announcementChannel: null as string | null, message: '🛒 **Shop Restocked!** New items are now available!' }, @@ -121,7 +130,10 @@ export { validatePurchase }; -export function getCurrentPrice(itemId, { quantity = 1, userData = null } = {}) { +export function getCurrentPrice( + itemId: string, + { quantity = 1, userData = null }: { quantity?: number; userData?: ShopUserData | null } = {}, +): number { const basePrice = getItemPrice(itemId) * quantity; let discount = 0; @@ -150,7 +162,7 @@ discount += 0.1; return Math.floor(basePrice * (1 - discount)); } -export function getCategoryForItem(itemType) { +export function getCategoryForItem(itemType: string): Omit & { itemTypes?: string[] } { return shopConfig.categories.find(cat => cat.itemTypes.includes(itemType) ) || { @@ -161,7 +173,7 @@ export function getCategoryForItem(itemType) { }; } -export function getItemsInCategory(categoryId) { +export function getItemsInCategory(categoryId: string): ShopItem[] { const category = shopConfig.categories.find(cat => cat.id === categoryId); if (!category) return []; diff --git a/src/config/shop/items.js b/src/config/shop/items.ts similarity index 79% rename from src/config/shop/items.js rename to src/config/shop/items.ts index 5f47eaeaf5..befd3ca480 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.ts @@ -1,4 +1,41 @@ -export const shopItems = [ +export type ShopItemType = 'consumable' | 'upgrade' | 'tool' | 'role'; + +export interface ShopItemEffect { + type: string; + command?: string; + uses?: number; + multiplier?: number; + increase?: number; + protection?: boolean; +} + +export interface ShopItem { + id: string; + name: string; + price: number; + description: string; + type: ShopItemType; + maxQuantity?: number; + maxLevel?: number; + cooldown?: number; + durability?: number | null; + roleId?: string | null; + effect: ShopItemEffect; +} + +/** Subset of the user's economy data that purchase validation looks at. */ +export interface ShopUserData { + inventory?: Record; + upgrades?: Record; + roles?: string[]; +} + +export interface PurchaseValidation { + valid: boolean; + reason?: string; +} + +export const shopItems: ShopItem[] = [ { id: 'extra_work', name: 'Extra Work Shift', @@ -137,27 +174,27 @@ roleId: null, } ]; -export function getItemById(itemId) { +export function getItemById(itemId: string): ShopItem | undefined { return shopItems.find(item => item.id === itemId); } -export function getItemsByType(type) { +export function getItemsByType(type: string): ShopItem[] { return shopItems.filter(item => item.type === type); } -export function getItemPrice(itemId) { +export function getItemPrice(itemId: string): number { const item = getItemById(itemId); return item ? item.price : 0; } -export function validatePurchase(itemId, userData) { +export function validatePurchase(itemId: string, userData: ShopUserData): PurchaseValidation { const item = getItemById(itemId); if (!item) { return { valid: false, reason: 'Item not found' }; } - const inventory = userData.inventory || {}; - const upgrades = userData.upgrades || {}; + const inventory: Record = userData.inventory || {}; + const upgrades: Record = userData.upgrades || {}; if (item.type === 'consumable' && item.maxQuantity) { const currentQuantity = inventory[itemId] || 0; diff --git a/src/scripts/backup.js b/src/scripts/backup.ts similarity index 80% rename from src/scripts/backup.js rename to src/scripts/backup.ts index 0086360230..ad71f0bc10 100644 --- a/src/scripts/backup.js +++ b/src/scripts/backup.ts @@ -4,12 +4,15 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import dotenv from 'dotenv'; import { logger } from '../utils/logger.js'; +import { getEnv, type Env } from '../config/env.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); dotenv.config({ path: path.join(__dirname, '..', '..', '.env') }); -function parseArgs(argv) { - const args = {}; +type CliArgs = Record; + +function parseArgs(argv: string[]): CliArgs { + const args: CliArgs = {}; for (let index = 0; index < argv.length; index += 1) { const token = argv[index]; @@ -36,15 +39,15 @@ function parseArgs(argv) { return args; } -function assertEnv(name) { - const value = process.env[name]; +function assertEnv(name: keyof Env): string { + const value = getEnv()[name]; if (!value) { throw new Error(`Missing required environment variable: ${name}`); } return value; } -function ensureCommand(command) { +function ensureCommand(command: string): void { const result = spawnSync(command, ['--version'], { encoding: 'utf8', stdio: 'pipe', @@ -58,11 +61,11 @@ function ensureCommand(command) { function createTimestamp() { const now = new Date(); - const pad = (value) => String(value).padStart(2, '0'); + const pad = (value: number) => String(value).padStart(2, '0'); return `${now.getUTCFullYear()}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}-${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}${pad(now.getUTCSeconds())}`; } -async function pruneBackups(backupDir, retentionDays) { +async function pruneBackups(backupDir: string, retentionDays: number): Promise { if (!Number.isFinite(retentionDays) || retentionDays <= 0) { return; } @@ -96,14 +99,15 @@ async function pruneBackups(backupDir, retentionDays) { async function run() { const args = parseArgs(process.argv.slice(2)); const databaseUrl = assertEnv('POSTGRES_URL'); - const retentionDays = Number.parseInt(args['retention-days'] || process.env.BACKUP_RETENTION_DAYS || '14', 10); - const backupDir = path.resolve(args['backup-dir'] || process.env.BACKUP_DIR || path.join(process.cwd(), 'backups')); + const env = getEnv(); + const retentionDays = Number.parseInt(String(args['retention-days'] || env.BACKUP_RETENTION_DAYS || '14'), 10); + const backupDir = path.resolve(String(args['backup-dir'] || env.BACKUP_DIR || path.join(process.cwd(), 'backups'))); ensureCommand('pg_dump'); await mkdir(backupDir, { recursive: true }); const outputPath = args.output - ? path.resolve(args.output) + ? path.resolve(String(args.output)) : path.join(backupDir, `nexobot-backup-${createTimestamp()}.dump`); const dumpArgs = [ @@ -144,7 +148,7 @@ async function run() { run().catch((error) => { logger.error('Backup command failed', { event: 'backup.failed', - error: error.message + error: error instanceof Error ? error.message : String(error) }); process.exit(1); }); \ No newline at end of file diff --git a/src/scripts/migrate-keys.js b/src/scripts/migrate-keys.ts similarity index 91% rename from src/scripts/migrate-keys.js rename to src/scripts/migrate-keys.ts index b19618c5f0..eec9ec8b47 100644 --- a/src/scripts/migrate-keys.js +++ b/src/scripts/migrate-keys.ts @@ -18,6 +18,7 @@ import { fileURLToPath } from 'url'; import { resolveSslConfig } from '../config/database/postgres.js'; import { runKeyMigration } from '../utils/database/keyMigration.js'; import { logger } from '../utils/logger.js'; +import { getEnv } from '../config/env.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); dotenv.config({ path: path.join(__dirname, '..', '..', '.env') }); @@ -27,8 +28,9 @@ const dryRun = process.argv.includes('--dry-run'); const force = process.argv.includes('--force'); async function run() { + const env = getEnv(); const pool = new Pool({ - connectionString: process.env.POSTGRES_URL || process.env.DATABASE_URL, + connectionString: env.POSTGRES_URL || env.DATABASE_URL, ssl: resolveSslConfig(), }); diff --git a/src/scripts/migrate.js b/src/scripts/migrate.ts similarity index 84% rename from src/scripts/migrate.js rename to src/scripts/migrate.ts index a95125d349..54ed9e7b16 100644 --- a/src/scripts/migrate.js +++ b/src/scripts/migrate.ts @@ -1,8 +1,10 @@ import pg from 'pg'; +import type { PoolClient } from 'pg'; import dotenv from 'dotenv'; import path from 'path'; import { fileURLToPath } from 'url'; import { logger } from '../utils/logger.js'; +import { getEnv } from '../config/env.js'; import { EXPECTED_SCHEMA_LABEL, EXPECTED_SCHEMA_VERSION } from '../config/database/schemaVersion.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -22,19 +24,27 @@ const { assertAllowlistedIdentifier, quoteIdentifier } = await import('../utils/ const { Pool } = pg; +const env = getEnv(); + const pool = new Pool({ - connectionString: process.env.POSTGRES_URL, + connectionString: env.POSTGRES_URL, ssl: resolveSslConfig(), }); -const migrationTable = process.env.POSTGRES_MIGRATION_TABLE || 'schema_migrations'; +const migrationTable = env.POSTGRES_MIGRATION_TABLE || 'schema_migrations'; const migrationTablePattern = /^[a-z_][a-z0-9_]*$/; if (!migrationTablePattern.test(migrationTable)) { throw new Error(`Invalid migration table name: ${migrationTable}`); } -const ensureMigrationLedger = async (client) => { +interface SchemaMigrationRow { + version: number; + label: string; + applied_at: Date; +} + +const ensureMigrationLedger = async (client: PoolClient): Promise => { await client.query(` CREATE TABLE IF NOT EXISTS ${migrationTable} ( version INTEGER PRIMARY KEY, @@ -44,7 +54,7 @@ const ensureMigrationLedger = async (client) => { `); }; -const recordSchemaVersion = async (client) => { +const recordSchemaVersion = async (client: PoolClient): Promise => { await ensureMigrationLedger(client); await client.query( `INSERT INTO ${migrationTable} (version, label) @@ -55,9 +65,9 @@ const recordSchemaVersion = async (client) => { ); }; -const getCurrentSchemaVersion = async (client) => { +const getCurrentSchemaVersion = async (client: PoolClient): Promise => { await ensureMigrationLedger(client); - const result = await client.query( + const result = await client.query( `SELECT version, label, applied_at FROM ${migrationTable} ORDER BY version DESC LIMIT 1` ); @@ -68,14 +78,14 @@ const getCurrentSchemaVersion = async (client) => { return result.rows[0]; }; -const createTables = async (client) => { +const createTables = async (client: PoolClient): Promise => { logger.info('📊 Creating database tables...'); for (const statement of tableStatements) { try { await client.query(statement); } catch (error) { - logger.error(`❌ Error creating table: ${error.message}`); + logger.error(`❌ Error creating table: ${error instanceof Error ? error.message : String(error)}`); throw error; } } @@ -83,14 +93,14 @@ const createTables = async (client) => { logger.info('✅ All tables created successfully'); }; -const createIndexes = async (client) => { +const createIndexes = async (client: PoolClient): Promise => { logger.info('📈 Creating indexes...'); for (const statement of indexStatements) { try { await client.query(statement); } catch (error) { - logger.error(`❌ Error creating index: ${error.message}`); + logger.error(`❌ Error creating index: ${error instanceof Error ? error.message : String(error)}`); throw error; } } @@ -98,7 +108,7 @@ const createIndexes = async (client) => { logger.info('✅ All indexes created successfully'); }; -const createTriggers = async (client) => { +const createTriggers = async (client: PoolClient): Promise => { logger.info('⏰ Setting up automatic timestamps...'); await client.query(UPDATE_TIMESTAMP_FUNCTION); @@ -122,7 +132,7 @@ const createTriggers = async (client) => { FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();` ); } catch (error) { - logger.error(`❌ Error creating trigger for ${table}: ${error.message}`); + logger.error(`❌ Error creating trigger for ${table}: ${error instanceof Error ? error.message : String(error)}`); throw error; } } diff --git a/src/scripts/restore-drill.js b/src/scripts/restore-drill.ts similarity index 83% rename from src/scripts/restore-drill.js rename to src/scripts/restore-drill.ts index 7f346c4a9c..931eb2ce74 100644 --- a/src/scripts/restore-drill.js +++ b/src/scripts/restore-drill.ts @@ -5,14 +5,17 @@ import { fileURLToPath } from 'node:url'; import dotenv from 'dotenv'; import pg from 'pg'; import { logger } from '../utils/logger.js'; +import { getEnv } from '../config/env.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); dotenv.config({ path: path.join(__dirname, '..', '..', '.env') }); const { Pool } = pg; -function parseArgs(argv) { - const args = {}; +type CliArgs = Record; + +function parseArgs(argv: string[]): CliArgs { + const args: CliArgs = {}; for (let index = 0; index < argv.length; index += 1) { const token = argv[index]; @@ -39,7 +42,7 @@ function parseArgs(argv) { return args; } -function ensureCommand(command) { +function ensureCommand(command: string): void { const result = spawnSync(command, ['--version'], { encoding: 'utf8', stdio: 'pipe', @@ -53,11 +56,11 @@ function ensureCommand(command) { function createTimestamp() { const now = new Date(); - const pad = (value) => String(value).padStart(2, '0'); + const pad = (value: number) => String(value).padStart(2, '0'); return `${now.getUTCFullYear()}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}${pad(now.getUTCSeconds())}`; } -function runCommand(command, args) { +function runCommand(command: string, args: string[]): string { const result = spawnSync(command, args, { encoding: 'utf8', stdio: 'pipe', @@ -71,7 +74,7 @@ function runCommand(command, args) { return result.stdout; } -async function pruneBackups(backupDir, retentionDays) { +async function pruneBackups(backupDir: string, retentionDays: number): Promise { if (!Number.isFinite(retentionDays) || retentionDays <= 0) { return; } @@ -92,7 +95,7 @@ async function pruneBackups(backupDir, retentionDays) { } } -function buildDatabaseUrlWithName(databaseUrl, databaseName) { +function buildDatabaseUrlWithName(databaseUrl: string, databaseName: string): string { const parsed = new URL(databaseUrl); parsed.pathname = `/${databaseName}`; return parsed.toString(); @@ -100,14 +103,15 @@ function buildDatabaseUrlWithName(databaseUrl, databaseName) { async function run() { const args = parseArgs(process.argv.slice(2)); - const sourceDatabaseUrl = process.env.POSTGRES_URL; + const env = getEnv(); + const sourceDatabaseUrl = env.POSTGRES_URL; if (!sourceDatabaseUrl) { throw new Error('Missing required environment variable: POSTGRES_URL'); } const keepDrillDatabase = args['keep-db'] === true || args['keep-db'] === 'true'; - const retentionDays = Number.parseInt(args['retention-days'] || process.env.BACKUP_RETENTION_DAYS || '14', 10); - const backupDir = path.resolve(args['backup-dir'] || process.env.BACKUP_DIR || path.join(process.cwd(), 'backups')); + const retentionDays = Number.parseInt(String(args['retention-days'] || env.BACKUP_RETENTION_DAYS || '14'), 10); + const backupDir = path.resolve(String(args['backup-dir'] || env.BACKUP_DIR || path.join(process.cwd(), 'backups'))); ensureCommand('pg_dump'); ensureCommand('pg_restore'); @@ -156,11 +160,11 @@ async function run() { const verifyPool = new Pool({ connectionString: drillDatabaseUrl }); try { - const tableCount = await verifyPool.query( + const tableCount = await verifyPool.query<{ value: number }>( `SELECT COUNT(*)::int AS value FROM information_schema.tables WHERE table_schema = 'public'` ); - const migrationTableCount = await verifyPool.query( + const migrationTableCount = await verifyPool.query<{ value: number }>( `SELECT COUNT(*)::int AS value FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'schema_migrations'` ); @@ -202,7 +206,7 @@ async function run() { run().catch((error) => { logger.error('Restore drill failed', { event: 'restore_drill.failed', - error: error.message + error: error instanceof Error ? error.message : String(error) }); process.exit(1); }); \ No newline at end of file diff --git a/src/scripts/restore.js b/src/scripts/restore.ts similarity index 78% rename from src/scripts/restore.js rename to src/scripts/restore.ts index f27cc7513a..088e1634e4 100644 --- a/src/scripts/restore.js +++ b/src/scripts/restore.ts @@ -4,12 +4,15 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import dotenv from 'dotenv'; import { logger } from '../utils/logger.js'; +import { getEnv } from '../config/env.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); dotenv.config({ path: path.join(__dirname, '..', '..', '.env') }); -function parseArgs(argv) { - const args = {}; +type CliArgs = Record; + +function parseArgs(argv: string[]): CliArgs { + const args: CliArgs = {}; for (let index = 0; index < argv.length; index += 1) { const token = argv[index]; @@ -36,7 +39,7 @@ function parseArgs(argv) { return args; } -function ensureCommand(command) { +function ensureCommand(command: string): void { const result = spawnSync(command, ['--version'], { encoding: 'utf8', stdio: 'pipe', @@ -48,7 +51,7 @@ function ensureCommand(command) { } } -async function resolveLatestBackup(backupDir) { +async function resolveLatestBackup(backupDir: string): Promise { const entries = await readdir(backupDir, { withFileTypes: true }); const dumpFiles = entries .filter((entry) => entry.isFile() && entry.name.endsWith('.dump')) @@ -62,7 +65,7 @@ async function resolveLatestBackup(backupDir) { return path.join(backupDir, dumpFiles[0]); } -function runCommand(command, args) { +function runCommand(command: string, args: string[]): void { const result = spawnSync(command, args, { encoding: 'utf8', stdio: 'pipe', @@ -76,8 +79,9 @@ function runCommand(command, args) { async function run() { const args = parseArgs(process.argv.slice(2)); - const backupDir = path.resolve(args['backup-dir'] || process.env.BACKUP_DIR || path.join(process.cwd(), 'backups')); - const targetUrl = args['target-url'] || process.env.POSTGRES_RESTORE_URL || process.env.POSTGRES_URL; + const env = getEnv(); + const backupDir = path.resolve(String(args['backup-dir'] || env.BACKUP_DIR || path.join(process.cwd(), 'backups'))); + const targetUrl = args['target-url'] || env.POSTGRES_RESTORE_URL || env.POSTGRES_URL; if (!targetUrl) { throw new Error('Missing target database URL. Set POSTGRES_RESTORE_URL or POSTGRES_URL.'); @@ -90,7 +94,7 @@ async function run() { ensureCommand('pg_restore'); ensureCommand('psql'); - const inputPath = args.input ? path.resolve(args.input) : await resolveLatestBackup(backupDir); + const inputPath = args.input ? path.resolve(String(args.input)) : await resolveLatestBackup(backupDir); const dropSchema = args['drop-schema'] === true || args['drop-schema'] === 'true'; logger.warn('Starting database restore', { @@ -103,7 +107,7 @@ async function run() { if (dropSchema) { runCommand('psql', [ '--dbname', - targetUrl, + String(targetUrl), '-v', 'ON_ERROR_STOP=1', '-c', @@ -117,7 +121,7 @@ async function run() { '--no-owner', '--no-privileges', '--dbname', - targetUrl, + String(targetUrl), inputPath ]); @@ -131,7 +135,7 @@ async function run() { run().catch((error) => { logger.error('Restore command failed', { event: 'restore.failed', - error: error.message + error: error instanceof Error ? error.message : String(error) }); process.exit(1); }); \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000000..5a93b9481e --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,118 @@ +import type { Server } from 'node:http'; +import type { + AutocompleteInteraction, + ButtonInteraction, + ChatInputCommandInteraction, + Client, + ClientEvents, + Collection, + ModalSubmitInteraction, + RESTPostAPIChatInputApplicationCommandsJSONBody, + StringSelectMenuInteraction, +} from 'discord.js'; +import type { Riffy } from 'riffy'; +import type { z } from 'zod'; +import type appConfig from '../config/application.js'; +import type { db } from '../utils/database/wrapper.js'; +import type { GuildConfigSchema } from '../utils/schemas.js'; + +/** The bot client once it is logged in. All handlers run after `ready`. */ +export type NexoClient = Client; + +export type AppConfig = typeof appConfig; +export type DatabaseWrapper = typeof db; + +/** Guild config keys that are stored but not part of the zod schema (passthrough). */ +export interface GuildConfigExtras { + enabledCommands?: Record; + setupWizardCompleted?: boolean; + ticketPanelChannelId?: string | null; + ticketPanelMessageId?: string | null; + ticketPanelMessage?: string | null; + ticketButtonLabel?: string | null; + ticketStaffRoleId?: string | null; + ticketCategoryId?: string | null; + ticketClosedCategoryId?: string | null; + ticketLogsChannelId?: string | null; + ticketTranscriptChannelId?: string | null; + maxTicketsPerUser?: number; +} + +/** Stored per-guild configuration (passthrough: unknown keys are kept). */ +export type GuildConfig = NonNullable> & GuildConfigExtras; + +/** Anything that serializes to a chat-input command (all SlashCommandBuilder variants). */ +export interface CommandData { + readonly name: string; + toJSON(): RESTPostAPIChatInputApplicationCommandsJSONBody; +} + +export interface AbuseProtectionPolicy { + enabled?: boolean; + maxAttempts?: number; + windowMs?: number; + anomaly?: { threshold?: number; windowMs?: number }; +} + +export interface Command { + data: CommandData; + execute( + interaction: ChatInputCommandInteraction, + guildConfig: GuildConfig | null, + client: NexoClient, + ): Promise; + /** Optional dedicated handler for prefix (message) invocations. */ + prefixExecute?( + interaction: ChatInputCommandInteraction, + guildConfig: GuildConfig | null, + client: NexoClient, + ): Promise; + autocomplete?(interaction: AutocompleteInteraction, client: NexoClient): Promise; + slashOnly?: boolean; + prefixOnly?: boolean; + abuseProtection?: AbuseProtectionPolicy; + /** Set by the command loader. */ + category?: string; + /** Set by the command loader. */ + filePath?: string; +} + +/** A subcommand implementation called by a router command. */ +export interface SubcommandModule { + execute( + interaction: ChatInputCommandInteraction, + guildConfig: GuildConfig | null, + client: NexoClient, + ): Promise; +} + +export type EventName = keyof ClientEvents; + +export interface BotEvent { + name: K; + once?: boolean; + execute(...args: [...ClientEvents[K], NexoClient]): unknown; +} + +export interface ComponentHandler { + name: string; + execute(interaction: I, client: NexoClient, args: string[]): Promise; +} + +export type ButtonHandler = ComponentHandler; +export type SelectMenuHandler = ComponentHandler; +export type ModalHandler = ComponentHandler; + +declare module 'discord.js' { + interface Client { + config: AppConfig; + commands: Collection; + buttons: Collection; + selectMenus: Collection; + modals: Collection; + cooldowns: Collection; + db: DatabaseWrapper; + riffy?: Riffy; + webServer?: Server; + } +} diff --git a/src/utils/abuseProtection.js b/src/utils/abuseProtection.ts similarity index 68% rename from src/utils/abuseProtection.js rename to src/utils/abuseProtection.ts index affd6f5f06..ab40777888 100644 --- a/src/utils/abuseProtection.js +++ b/src/utils/abuseProtection.ts @@ -2,6 +2,24 @@ import { checkRateLimit, getRateLimitStatus, clearAllRateLimits } from './rateLimiter.js'; import { logger } from './logger.js'; +import type { ChatInputCommandInteraction } from 'discord.js'; +import type { AbuseProtectionPolicy, Command } from '../types/index.js'; + +type ProtectedCommand = Pick | null | undefined; +type ProtectedInteraction = Pick; + +interface BlockedAttemptEntry { + count: number; + windowStart: number; + thresholdReachedAt: number | null; +} + +export interface AbuseProtectionResult { + allowed: boolean; + risky: boolean; + remainingMs: number; + policy: { maxAttempts: number; windowMs: number } | null; +} const DEFAULT_PROTECTION_POLICY = Object.freeze({ maxAttempts: 2, @@ -37,47 +55,53 @@ const RISKY_COMMAND_NAMES = new Set([ 'reactroles' ]); -const blockedAttemptStore = new Map(); +const blockedAttemptStore = new Map(); -function normalizeCommandCategory(command) { +function normalizeCommandCategory(command: ProtectedCommand): string { return String(command?.category || '').trim().toLowerCase(); } -function normalizeCommandName(commandName) { +function normalizeCommandName(commandName: string | null | undefined): string { return String(commandName || '').trim().toLowerCase(); } -function getCommandPolicy(command) { - const protection = command?.abuseProtection || {}; - const maxAttempts = Number.isInteger(protection.maxAttempts) && protection.maxAttempts > 0 - ? protection.maxAttempts +function getCommandPolicy(command: ProtectedCommand) { + const protection: AbuseProtectionPolicy = command?.abuseProtection || {}; + const maxAttempts = Number.isInteger(protection.maxAttempts) && protection.maxAttempts! > 0 + ? protection.maxAttempts! : DEFAULT_PROTECTION_POLICY.maxAttempts; - const windowMs = Number.isInteger(protection.windowMs) && protection.windowMs > 0 - ? protection.windowMs + const windowMs = Number.isInteger(protection.windowMs) && protection.windowMs! > 0 + ? protection.windowMs! : DEFAULT_PROTECTION_POLICY.windowMs; return { maxAttempts, windowMs }; } -function getAnomalyPolicy(command) { - const anomaly = command?.abuseProtection?.anomaly || {}; - const threshold = Number.isInteger(anomaly.threshold) && anomaly.threshold > 0 - ? anomaly.threshold +function getAnomalyPolicy(command: ProtectedCommand) { + const anomaly: NonNullable = command?.abuseProtection?.anomaly || {}; + const threshold = Number.isInteger(anomaly.threshold) && anomaly.threshold! > 0 + ? anomaly.threshold! : DEFAULT_ANOMALY_POLICY.threshold; - const windowMs = Number.isInteger(anomaly.windowMs) && anomaly.windowMs > 0 - ? anomaly.windowMs + const windowMs = Number.isInteger(anomaly.windowMs) && anomaly.windowMs! > 0 + ? anomaly.windowMs! : DEFAULT_ANOMALY_POLICY.windowMs; return { threshold, windowMs }; } -function getProtectionKey(interaction, commandName) { +function getProtectionKey(interaction: ProtectedInteraction, commandName: string): string { const guildScope = interaction.guildId || 'dm'; const userId = interaction.user?.id || 'unknown_user'; return `${guildScope}:${userId}:${normalizeCommandName(commandName)}`; } -function recordBlockedAttempt(key, commandName, interaction, command, remainingMs) { +function recordBlockedAttempt( + key: string, + commandName: string, + interaction: ProtectedInteraction, + command: ProtectedCommand, + remainingMs: number +): void { const now = Date.now(); const anomalyPolicy = getAnomalyPolicy(command); const current = blockedAttemptStore.get(key); @@ -107,7 +131,7 @@ function recordBlockedAttempt(key, commandName, interaction, command, remainingM } } -export function formatCooldownDuration(ms) { +export function formatCooldownDuration(ms: number): string { if (!Number.isFinite(ms) || ms <= 0) { return 'a moment'; } @@ -127,7 +151,7 @@ export function formatCooldownDuration(ms) { return `${seconds}s`; } -export function isRiskyCommand(command, commandName) { +export function isRiskyCommand(command: ProtectedCommand, commandName: string): boolean { const protectionEnabled = command?.abuseProtection?.enabled; if (protectionEnabled === false) { return false; @@ -146,7 +170,11 @@ export function isRiskyCommand(command, commandName) { return RISKY_COMMAND_CATEGORIES.has(normalizedCategory); } -export async function enforceAbuseProtection(interaction, command, commandName) { +export async function enforceAbuseProtection( + interaction: ProtectedInteraction, + command: ProtectedCommand, + commandName: string +): Promise { if (!isRiskyCommand(command, commandName)) { return { allowed: true, @@ -193,7 +221,7 @@ export async function enforceAbuseProtection(interaction, command, commandName) }; } -export function resetAbuseProtectionState() { +export function resetAbuseProtectionState(): void { blockedAttemptStore.clear(); clearAllRateLimits(); } \ No newline at end of file diff --git a/src/utils/collectorComponents.js b/src/utils/collectorComponents.ts similarity index 77% rename from src/utils/collectorComponents.js rename to src/utils/collectorComponents.ts index e363a0d243..bf396cb905 100644 --- a/src/utils/collectorComponents.js +++ b/src/utils/collectorComponents.ts @@ -5,6 +5,6 @@ const COLLECTOR_MANAGED_PREFIXES = [ 'cmdaccess_', ]; -export function isCollectorManagedComponent(customId = '') { +export function isCollectorManagedComponent(customId: string = ''): boolean { return COLLECTOR_MANAGED_PREFIXES.some((prefix) => customId.startsWith(prefix)); } diff --git a/src/utils/commandInputValidation.js b/src/utils/commandInputValidation.ts similarity index 65% rename from src/utils/commandInputValidation.js rename to src/utils/commandInputValidation.ts index 3dd278a065..d65f023c53 100644 --- a/src/utils/commandInputValidation.js +++ b/src/utils/commandInputValidation.ts @@ -10,7 +10,14 @@ const OptionValueSchema = z.union([ z.null() ]); -const CommandOptionSchema = z.lazy(() => +interface CommandOptionInput { + name: string; + type: number; + value?: z.infer; + options?: CommandOptionInput[]; +} + +const CommandOptionSchema: z.ZodType = z.lazy(() => z.object({ name: z.string().min(1).max(32), type: z.number().int().min(1).max(20), @@ -24,7 +31,18 @@ const CommandInputSchema = z.object({ options: z.array(CommandOptionSchema).max(25) }); -export function validateChatInputPayloadOrThrow(interaction, context = {}) { +export type CommandInputPayload = z.infer; + +/** Structural view of what is read from a (chat input or mock) interaction. */ +interface ChatInputPayloadSource { + commandName?: string | null; + options?: { data?: readonly unknown[] } | null; +} + +export function validateChatInputPayloadOrThrow( + interaction: ChatInputPayloadSource | null | undefined, + context: Record = {} +): CommandInputPayload { const payload = { commandName: interaction?.commandName, options: Array.isArray(interaction?.options?.data) ? interaction.options.data : [] @@ -49,4 +67,4 @@ export function validateChatInputPayloadOrThrow(interaction, context = {}) { })) } ); -} \ No newline at end of file +} diff --git a/src/utils/commandPipeline.js b/src/utils/commandPipeline.ts similarity index 83% rename from src/utils/commandPipeline.js rename to src/utils/commandPipeline.ts index ecb15276bb..3b188ecadf 100644 --- a/src/utils/commandPipeline.js +++ b/src/utils/commandPipeline.ts @@ -13,7 +13,9 @@ * }); */ -export function defineSlashCommand(command) { +import type { Command } from '../types/index.js'; + +export function defineSlashCommand(command: T): T { if (!command?.data || typeof command.execute !== 'function') { throw new Error('defineSlashCommand requires { data, execute }'); } diff --git a/src/utils/components.js b/src/utils/components.ts similarity index 70% rename from src/utils/components.js rename to src/utils/components.ts index c10924efe5..b0d63da87b 100644 --- a/src/utils/components.js +++ b/src/utils/components.ts @@ -1,10 +1,11 @@ // components.js import { ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder } from 'discord.js'; +import type { APISelectMenuOption, SelectMenuComponentOptionData, StringSelectMenuOptionBuilder } from 'discord.js'; import { getColor } from '../config/bot.js'; -export function getConfirmationButtons(customIdPrefix = 'confirm') { - return new ActionRowBuilder().addComponents( +export function getConfirmationButtons(customIdPrefix: string = 'confirm'): ActionRowBuilder { + return new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`${customIdPrefix}_yes`) .setLabel('Confirm') @@ -18,8 +19,8 @@ export function getConfirmationButtons(customIdPrefix = 'confirm') { ); } -export function getPaginationRow(customIdPrefix = 'page', currentPage = 1, totalPages = 1) { - return new ActionRowBuilder().addComponents( +export function getPaginationRow(customIdPrefix: string = 'page', currentPage: number = 1, totalPages: number = 1): ActionRowBuilder { + return new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`${customIdPrefix}_first`) .setLabel('⏮️') @@ -48,8 +49,16 @@ export function getPaginationRow(customIdPrefix = 'page', currentPage = 1, total ); } -export function createSelectMenu(customId, placeholder, options = [], min = 1, max = 1) { - return new ActionRowBuilder().addComponents( +export type SelectMenuOptionInput = StringSelectMenuOptionBuilder | SelectMenuComponentOptionData | APISelectMenuOption; + +export function createSelectMenu( + customId: string, + placeholder: string, + options: SelectMenuOptionInput[] = [], + min: number = 1, + max: number = 1, +): ActionRowBuilder { + return new ActionRowBuilder().addComponents( new StringSelectMenuBuilder() .setCustomId(customId) .setPlaceholder(placeholder) @@ -59,7 +68,7 @@ export function createSelectMenu(customId, placeholder, options = [], min = 1, m ); } -export function createButton(customId, label, style = 'primary', emoji = null, disabled = false) { +export function createButton(customId: string, label: string, style: string = 'primary', emoji: string | null = null, disabled: boolean = false): ButtonBuilder { if (!customId || typeof customId !== 'string' || customId.length === 0) { throw new Error('customId must be a non-empty string'); @@ -72,7 +81,7 @@ export function createButton(customId, label, style = 'primary', emoji = null, d const validLabel = label.substring(0, 80); const normalizedStyle = style.charAt(0).toUpperCase() + style.slice(1).toLowerCase(); - const buttonStyle = ButtonStyle[normalizedStyle] || ButtonStyle.Primary; + const buttonStyle = ButtonStyle[normalizedStyle as keyof typeof ButtonStyle] || ButtonStyle.Primary; const button = new ButtonBuilder() .setCustomId(validCustomId) @@ -91,7 +100,7 @@ export function createButton(customId, label, style = 'primary', emoji = null, d return button; } -export function createLinkButton(label, url, emoji = null) { +export function createLinkButton(label: string, url: string, emoji: string | null = null): ButtonBuilder { if (!label || typeof label !== 'string') { throw new Error('label must be a non-empty string'); @@ -118,8 +127,17 @@ export function createLinkButton(label, url, emoji = null) { return button; } -export function createButtonRow(buttons) { - const row = new ActionRowBuilder(); +export interface ButtonRowItem { + customId?: string; + label: string; + url?: string; + style?: string; + emoji?: string | null; + disabled?: boolean; +} + +export function createButtonRow(buttons: (ButtonRowItem | null | undefined)[]): ActionRowBuilder { + const row = new ActionRowBuilder(); if (!Array.isArray(buttons) || buttons.length === 0) { return row; @@ -133,7 +151,7 @@ export function createButtonRow(buttons) { row.addComponents(createLinkButton(button.label, button.url, button.emoji)); } else { row.addComponents(createButton( - button.customId, + button.customId!, button.label, button.style || 'primary', button.emoji, diff --git a/src/utils/constants.js b/src/utils/constants.ts similarity index 100% rename from src/utils/constants.js rename to src/utils/constants.ts diff --git a/src/utils/dashboardSession.js b/src/utils/dashboardSession.ts similarity index 66% rename from src/utils/dashboardSession.js rename to src/utils/dashboardSession.ts index 6ed51698dc..dcef83ee5a 100644 --- a/src/utils/dashboardSession.js +++ b/src/utils/dashboardSession.ts @@ -1,21 +1,53 @@ import { ComponentType, EmbedBuilder } from 'discord.js'; +import type { + ButtonInteraction, + ChatInputCommandInteraction, + InteractionCollector, + InteractionEditReplyOptions, + InteractionReplyOptions, + MessageComponentInteraction, + StringSelectMenuInteraction, +} from 'discord.js'; import { getColor } from '../config/bot.js'; import { NexoBotError, ErrorTypes, replyUserError } from './errorHandler.js'; import { InteractionHelper } from './interactionHelper.js'; import { logger } from './logger.js'; -function matchesCustomId(customId, matcher) { +export type CustomIdMatcher = string | string[] | ((customId: string) => boolean); + +export interface DashboardSessionOptions { + interaction: ChatInputCommandInteraction; + embeds?: InteractionEditReplyOptions['embeds']; + components?: InteractionEditReplyOptions['components']; + flags?: InteractionReplyOptions['flags']; + timeoutMs?: number; + selectMenuId?: string; + buttonMatcher?: CustomIdMatcher; + onSelect?: (selectInteraction: StringSelectMenuInteraction) => Promise; + onButton?: (buttonInteraction: ButtonInteraction) => Promise; + onTimeout?: (interaction: ChatInputCommandInteraction) => Promise; +} + +export interface DashboardSession { + stop: () => void; + replyMessageId: string | undefined; +} + +function matchesCustomId(customId: string, matcher: CustomIdMatcher): boolean { if (typeof matcher === 'function') return matcher(customId); if (Array.isArray(matcher)) return matcher.includes(customId); return customId === matcher; } -function wrapHandler(handler, interactionLabel = 'dashboard') { - return async (componentInteraction) => { +function wrapHandler( + handler: (componentInteraction: I) => Promise, + interactionLabel = 'dashboard', +): (componentInteraction: I) => Promise { + return async (componentInteraction: I) => { try { await handler(componentInteraction); } catch (error) { - if (error?.code === 40060) return; + if ((error as { code?: unknown } | null | undefined)?.code === 40060) return; if (error instanceof NexoBotError) { logger.debug(`${interactionLabel} error: ${error.message}`); @@ -54,20 +86,23 @@ export async function startDashboardSession({ onSelect, onButton, onTimeout, -}) { +}: DashboardSessionOptions): Promise { await InteractionHelper.safeEditReply(interaction, { embeds, components, flags }); const replyMessage = await interaction.fetchReply().catch(() => null); const replyMessageId = replyMessage?.id; - const belongsToDashboard = (componentInteraction) => + const belongsToDashboard = (componentInteraction: MessageComponentInteraction) => componentInteraction.user.id === interaction.user.id && (!replyMessageId || componentInteraction.message.id === replyMessageId); - const collectors = []; + const collectors: Array< + | InteractionCollector + | InteractionCollector + > = []; if (selectMenuId && onSelect) { - const selectCollector = interaction.channel.createMessageComponentCollector({ + const selectCollector = interaction.channel!.createMessageComponentCollector({ componentType: ComponentType.StringSelect, filter: (i) => belongsToDashboard(i) && i.customId === selectMenuId, time: timeoutMs, @@ -78,7 +113,7 @@ export async function startDashboardSession({ } if (buttonMatcher && onButton) { - const buttonCollector = interaction.channel.createMessageComponentCollector({ + const buttonCollector = interaction.channel!.createMessageComponentCollector({ componentType: ComponentType.Button, filter: (i) => belongsToDashboard(i) && matchesCustomId(i.customId, buttonMatcher), time: timeoutMs, diff --git a/src/utils/database.js b/src/utils/database.ts similarity index 71% rename from src/utils/database.js rename to src/utils/database.ts index 65a6f2696f..d194651951 100644 --- a/src/utils/database.js +++ b/src/utils/database.ts @@ -3,6 +3,9 @@ import { pgDb } from './postgresDatabase.js'; import { logger } from './logger.js'; import { BotConfig, getDefaultApplicationQuestions } from '../config/bot.js'; +import type { NexoClient } from '../types/index.js'; +import type { VerificationAuditRecord } from './postgresDatabase.js'; +import type { Giveaway } from './giveaways.js'; export { db, @@ -78,7 +81,7 @@ import { getUserLevelPrefix, } from './database/keys.js'; -export async function insertVerificationAudit(record) { +export async function insertVerificationAudit(record: VerificationAuditRecord): Promise { try { if (!db.initialized) { await db.initialize(); @@ -89,7 +92,7 @@ export async function insertVerificationAudit(record) { } const key = `verification:audit:${record.guildId}`; - const existing = await getFromDb(key, []); + const existing = await getFromDb(key, []); const auditEntries = Array.isArray(existing) ? existing : []; const maxInMemoryAuditEntries = BotConfig?.verification?.maxInMemoryAuditEntries ?? 1000; @@ -110,14 +113,21 @@ export async function insertVerificationAudit(record) { } } -export function unwrapReplitData(data) { +/** Legacy Replit DB envelope `{ ok, value }`. */ +interface ReplitEnvelope { + ok?: unknown; + value?: T; +} + +/** Unwraps legacy `{ ok, value }` envelopes; T is the caller's stored payload type. */ +export function unwrapReplitData(data: T): T { if ( typeof data === "object" && data !== null && - data.ok !== undefined && - data.value !== undefined + (data as ReplitEnvelope).ok !== undefined && + (data as ReplitEnvelope).value !== undefined ) { - return unwrapReplitData(data.value); + return unwrapReplitData((data as ReplitEnvelope).value as T); } return data; } @@ -127,30 +137,35 @@ export function unwrapReplitData(data) { export { pgDb }; -export const getMessage = (key, replacements = {}) => { - let message = BotConfig.messages[key] || key; +export const getMessage = (key: string, replacements: Record = {}): string => { + let message = (BotConfig.messages as Partial>)[key] || key; for (const [k, v] of Object.entries(replacements)) { - message = message.replace(new RegExp(`\\{${k}\\}`, "g"), v); + message = message.replace(new RegExp(`\\{${k}\\}`, "g"), String(v)); } return message; }; -export const getColor = (path, fallback = "#000000") => { +export const getColor = (path: string, fallback = "#000000"): string => { const parts = path.split("."); - let current = BotConfig.embeds.colors; + let current: unknown = BotConfig.embeds.colors; for (const part of parts) { - if (current[part] === undefined) { + if ((current as Record)[part] === undefined) { logger.warn(`Color path '${path}' not found in config, using fallback`); return fallback; } - current = current[part]; + current = (current as Record)[part]; } return typeof current === "string" ? current : fallback; }; -export async function getGuildBirthdays(client, guildId) { +export interface BirthdayEntry { + month: number; + day: number; +} + +export async function getGuildBirthdays(client: NexoClient, guildId: string): Promise> { const key = getGuildBirthdaysKey(guildId); try { if (!client.db || typeof client.db.get !== "function") { @@ -158,7 +173,7 @@ export async function getGuildBirthdays(client, guildId) { return {}; } - const rawData = await client.db.get(key, {}); + const rawData = await client.db.get>(key, {}); return unwrapReplitData(rawData) || {}; } catch (error) { logger.error(`Error retrieving birthdays for guild ${guildId}:`, error); @@ -166,7 +181,7 @@ export async function getGuildBirthdays(client, guildId) { } } -export async function setBirthday(client, guildId, userId, month, day) { +export async function setBirthday(client: NexoClient, guildId: string, userId: string, month: number, day: number): Promise { try { if (!client.db || typeof client.db.set !== "function") { logger.error("Database client is not available for setBirthday."); @@ -184,7 +199,7 @@ export async function setBirthday(client, guildId, userId, month, day) { } } -export async function deleteBirthday(client, guildId, userId) { +export async function deleteBirthday(client: NexoClient, guildId: string, userId: string): Promise { try { if (!client.db || typeof client.db.set !== "function") { logger.error("Database client is not available for deleteBirthday."); @@ -204,7 +219,7 @@ export async function deleteBirthday(client, guildId, userId) { } } -export function getMonthName(monthNum) { +export function getMonthName(monthNum: number): string { const months = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' @@ -213,7 +228,7 @@ export function getMonthName(monthNum) { return monthNum >= 1 && monthNum <= 12 ? months[index] : 'Invalid Month'; } -function isPostgresSqlReady(dbWrapper) { +function isPostgresSqlReady(dbWrapper: NexoClient['db'] | undefined): boolean { return Boolean( dbWrapper?.db?.pool && typeof dbWrapper.db.isAvailable === 'function' && @@ -221,14 +236,23 @@ function isPostgresSqlReady(dbWrapper) { ); } -async function getEndedGiveawaysFromKv(client) { +/** Row shape of an ended giveaway (giveaways table, or synthesized from the KV store). */ +export interface EndedGiveawayRow { + id: number | string; + guild_id: string; + message_id: string; + data: Giveaway; + ends_at: Date; +} + +async function getEndedGiveawaysFromKv(client: NexoClient): Promise { const wrapper = client?.db; if (!wrapper || typeof wrapper.list !== 'function' || typeof wrapper.get !== 'function') { return []; } const keys = await wrapper.list('guild:'); - const ended = []; + const ended: EndedGiveawayRow[] = []; const now = Date.now(); for (const key of keys) { @@ -241,7 +265,7 @@ async function getEndedGiveawaysFromKv(client) { continue; } - const rawGiveaways = await wrapper.get(key, {}); + const rawGiveaways = await wrapper.get | Giveaway[]>(key, {}); const unwrapped = unwrapReplitData(rawGiveaways) || {}; const giveaways = Array.isArray(unwrapped) ? unwrapped : Object.values(unwrapped); @@ -256,7 +280,7 @@ async function getEndedGiveawaysFromKv(client) { } ended.push({ - id: giveaway.id || giveaway.messageId, + id: (giveaway.id as string | undefined) || giveaway.messageId, guild_id: guildId, message_id: giveaway.messageId, data: giveaway, @@ -265,10 +289,10 @@ async function getEndedGiveawaysFromKv(client) { } } - return ended.sort((a, b) => new Date(a.ends_at) - new Date(b.ends_at)); + return ended.sort((a, b) => new Date(a.ends_at).getTime() - new Date(b.ends_at).getTime()); } -export async function getEndedGiveaways(client) { +export async function getEndedGiveaways(client: NexoClient): Promise { try { const wrapper = client?.db; if (!wrapper || typeof wrapper.get !== 'function') { @@ -278,7 +302,7 @@ export async function getEndedGiveaways(client) { if (isPostgresSqlReady(wrapper)) { const { pgConfig } = await import('../config/database/postgres.js'); - const result = await wrapper.db.pool.query( + const result = await wrapper.db!.pool!.query( `SELECT id, guild_id, message_id, data, ends_at FROM ${pgConfig.tables.giveaways} WHERE ends_at <= NOW() @@ -304,7 +328,7 @@ export async function getEndedGiveaways(client) { } } -export async function markGiveawayEnded(client, giveawayId, endedData) { +export async function markGiveawayEnded(client: NexoClient, giveawayId: number | string, endedData: Giveaway): Promise { try { const wrapper = client?.db; if (!wrapper || typeof wrapper.get !== 'function') { @@ -314,7 +338,7 @@ export async function markGiveawayEnded(client, giveawayId, endedData) { if (isPostgresSqlReady(wrapper)) { const { pgConfig } = await import('../config/database/postgres.js'); - await wrapper.db.pool.query( + await wrapper.db!.pool!.query( `UPDATE ${pgConfig.tables.giveaways} SET data = $1, updated_at = NOW() WHERE id = $2`, @@ -337,8 +361,43 @@ export async function markGiveawayEnded(client, giveawayId, endedData) { } } -function normalizeWelcomeConfig(raw = {}) { - const base = typeof raw === "object" && raw !== null ? raw : {}; +export interface WelcomeEmbedConfig { + title?: string; + description?: string; + color?: string; + thumbnail?: boolean; + footer?: string; + [key: string]: unknown; +} + +export interface WelcomeLogConfig { + enabled: boolean; + channelId: string | null; +} + +export interface WelcomeConfig { + enabled: boolean; + channelId: string | null; + welcomeMessage: string; + welcomeEmbed: WelcomeEmbedConfig; + welcomePing: boolean; + welcomeImage: string | null; + goodbyeEnabled: boolean; + goodbyeChannelId: string | null; + leaveMessage: string; + leaveEmbed: WelcomeEmbedConfig; + dmMessage: string; + goodbyePing: boolean; + roleIds: string[]; + autoRoleDelay: number; + joinLogs: WelcomeLogConfig; + leaveLogs: WelcomeLogConfig; + [key: string]: unknown; +} + +function normalizeWelcomeConfig(raw: unknown = {}): WelcomeConfig { + // Stored JSON of unknown shape; fields are normalized below. + const base = (typeof raw === "object" && raw !== null ? raw : {}) as Partial; const channelId = base.channelId ?? null; const goodbyeChannelId = base.goodbyeChannelId ?? null; @@ -385,7 +444,7 @@ function normalizeWelcomeConfig(raw = {}) { }; } -export async function getWelcomeConfig(client, guildId) { +export async function getWelcomeConfig(client: NexoClient, guildId: string): Promise { if (!client.db) { logger.warn('Database not available for getWelcomeConfig'); return normalizeWelcomeConfig(); @@ -402,7 +461,7 @@ export async function getWelcomeConfig(client, guildId) { } } -export async function saveWelcomeConfig(client, guildId, config) { +export async function saveWelcomeConfig(client: NexoClient, guildId: string, config: Partial): Promise { const key = getWelcomeConfigKey(guildId); try { if (!client.db || typeof client.db.set !== 'function') { @@ -421,7 +480,7 @@ export async function saveWelcomeConfig(client, guildId, config) { } } -export async function updateWelcomeConfig(client, guildId, updates) { +export async function updateWelcomeConfig(client: NexoClient, guildId: string, updates: Partial): Promise { try { const currentConfig = await getWelcomeConfig(client, guildId); const updatedConfig = { ...currentConfig, ...updates }; @@ -434,10 +493,23 @@ export async function updateWelcomeConfig(client, guildId, updates) { } } -export async function getLevelingConfig(client, guildId) { +export interface LevelingConfig { + enabled: boolean; + xpPerMessage: number; + xpPerMinute: number; + cooldownEnabled: boolean; + messageLengthMultiplier: boolean; + levelUpMessages: boolean; + levelUpChannel: string | null; + roles: Record; + milestones: Record; + [key: string]: unknown; +} + +export async function getLevelingConfig(client: NexoClient, guildId: string): Promise { const key = getLevelingKey(guildId); try { - const config = await getFromDb(key, { + const config = await getFromDb(key, { enabled: false, xpPerMessage: 10, xpPerMinute: 60, @@ -466,7 +538,7 @@ export async function getLevelingConfig(client, guildId) { } } -export async function saveLevelingConfig(client, guildId, config) { +export async function saveLevelingConfig(client: NexoClient, guildId: string, config: LevelingConfig): Promise { const key = getLevelingKey(guildId); try { await setInDb(key, config); @@ -477,10 +549,29 @@ export async function saveLevelingConfig(client, guildId, config) { } } -export async function getUserLevelData(client, guildId, userId) { +/** Stored per-user level record (`guild:{guildId}:leveling:users:{userId}`). */ +export interface UserLevelRecord { + xp?: number; + level?: number; + totalXp?: number; + lastMessage?: number; + rank?: number; + [key: string]: unknown; +} + +export interface UserLevelData { + xp: number; + level: number; + totalXp: number; + lastMessage: number; + rank: number; + xpToNextLevel: number; +} + +export async function getUserLevelData(client: NexoClient, guildId: string, userId: string): Promise { const key = getUserLevelKey(guildId, userId); try { - const data = await getFromDb(key, null); + const data = await getFromDb(key, null); if (!data) { return { xp: 0, @@ -515,7 +606,7 @@ export async function getUserLevelData(client, guildId, userId) { } } -export async function saveUserLevelData(client, guildId, userId, data) { +export async function saveUserLevelData(client: NexoClient, guildId: string, userId: string, data: UserLevelRecord): Promise { const key = getUserLevelKey(guildId, userId); try { const levelData = { @@ -536,11 +627,19 @@ export async function saveUserLevelData(client, guildId, userId, data) { } } -export function getXpForLevel(level) { +export function getXpForLevel(level: number): number { return 5 * Math.pow(level, 2) + 50 * level + 50; } -export async function getLeaderboard(client, guildId, limit = 10) { +export interface LeaderboardEntry { + userId: string; + xp: number; + level: number; + totalXp: number; + rank: number; +} + +export async function getLeaderboard(client: NexoClient, guildId: string, limit = 10): Promise { try { if (!client.db || typeof client.db.list !== "function") { logger.error("Database client is not available for getLeaderboard."); @@ -562,10 +661,10 @@ export async function getLeaderboard(client, guildId, limit = 10) { return []; } - const userDataPromises = keys.map(async (key) => { + const userDataPromises = keys.map(async (key): Promise => { try { const userId = key.replace(prefix, ''); - const data = await client.db.get(key); + const data = await client.db.get(key); if (!data) return null; const unwrapped = unwrapReplitData(data); @@ -582,7 +681,7 @@ rank: 0 } }); - let userData = (await Promise.all(userDataPromises)).filter(Boolean); + let userData = (await Promise.all(userDataPromises)).filter((entry): entry is LeaderboardEntry => Boolean(entry)); userData.sort((a, b) => (b.totalXp || 0) - (a.totalXp || 0)); @@ -598,7 +697,14 @@ rank: 0 } } -export async function getApplicationRoles(client, guildId) { +export interface ApplicationRole { + roleId: string; + name: string; + enabled?: boolean; + [key: string]: unknown; +} + +export async function getApplicationRoles(client: NexoClient, guildId: string): Promise { try { if (!client.db || typeof client.db.get !== "function") { logger.error("Database client is not available for getApplicationRoles."); @@ -606,7 +712,7 @@ export async function getApplicationRoles(client, guildId) { } const key = getApplicationRolesKey(guildId); - const roles = await client.db.get(key, []); + const roles = await client.db.get(key, []); const unwrappedRoles = unwrapReplitData(roles); return Array.isArray(unwrappedRoles) ? unwrappedRoles : []; } catch (error) { @@ -615,7 +721,7 @@ export async function getApplicationRoles(client, guildId) { } } -export async function saveApplicationRoles(client, guildId, roles) { +export async function saveApplicationRoles(client: NexoClient, guildId: string, roles: ApplicationRole[]): Promise { try { if (!client.db || typeof client.db.set !== "function") { logger.error("Database client is not available for saveApplicationRoles."); @@ -631,7 +737,31 @@ export async function saveApplicationRoles(client, guildId, roles) { } } -function buildApplicationSettingsDefaults() { +export interface ApplicationSettings { + enabled: boolean; + applicationChannelId: string | null; + logChannelId: string | null; + questions: string[]; + roles: { + admin: string | null; + reviewer: string | null; + accepted: string | null; + denied: string | null; + }; + requiredRoles: string[]; + deniedRoles: string[]; + minAccountAge: number; + maxApplications: number; + cooldown: number; + allowMultipleApplications: boolean; + requireVerification: boolean; + customWelcomeMessage: string; + pendingApplicationRetentionDays: number; + reviewedApplicationRetentionDays: number; + [key: string]: unknown; +} + +function buildApplicationSettingsDefaults(): ApplicationSettings { return { enabled: false, applicationChannelId: null, @@ -656,7 +786,7 @@ function buildApplicationSettingsDefaults() { }; } -export async function getApplicationSettings(client, guildId) { +export async function getApplicationSettings(client: NexoClient, guildId: string): Promise { if (!client.db) { logger.warn('Database not available for getApplicationSettings'); return buildApplicationSettingsDefaults(); @@ -664,7 +794,7 @@ export async function getApplicationSettings(client, guildId) { const key = getApplicationSettingsKey(guildId); try { - const settings = await client.db.get(key, {}); + const settings = await client.db.get>(key, {}); const unwrapped = unwrapReplitData(settings); const defaultSettings = buildApplicationSettingsDefaults(); @@ -676,7 +806,12 @@ export async function getApplicationSettings(client, guildId) { } } -function getApplicationRetentionDays(settings = {}) { +interface ApplicationRetentionDays { + pendingDays: number; + reviewedDays: number; +} + +function getApplicationRetentionDays(settings: Partial = {}): ApplicationRetentionDays { const pendingRaw = Number(settings.pendingApplicationRetentionDays); const reviewedRaw = Number(settings.reviewedApplicationRetentionDays); @@ -686,7 +821,7 @@ function getApplicationRetentionDays(settings = {}) { return { pendingDays, reviewedDays }; } -function isApplicationExpired(application, retentionDays, now = Date.now()) { +function isApplicationExpired(application: Application | null, retentionDays: ApplicationRetentionDays, now = Date.now()): boolean { if (!application || typeof application !== 'object') { return false; } @@ -712,18 +847,23 @@ function isApplicationExpired(application, retentionDays, now = Date.now()) { return ageMsFromCreated > pendingRetentionMs; } -export async function deleteApplication(client, guildId, applicationId, userIdHint = null) { +export async function deleteApplication( + client: NexoClient, + guildId: string, + applicationId: string, + userIdHint: string | null = null, +): Promise { const key = getApplicationKey(guildId, applicationId); try { - const existing = unwrapReplitData(await client.db.get(key, null)); + const existing = unwrapReplitData(await client.db.get(key, null)); const userId = userIdHint || existing?.userId || null; await client.db.delete(key); if (userId) { const userKey = getUserApplicationsKey(guildId, userId); - const userApplications = await client.db.get(userKey, []); + const userApplications = await client.db.get(userKey, []); const unwrapped = unwrapReplitData(userApplications); const ids = Array.isArray(unwrapped) ? unwrapped : []; const filtered = ids.filter(id => id !== applicationId); @@ -737,7 +877,7 @@ export async function deleteApplication(client, guildId, applicationId, userIdHi } } -export async function cleanupExpiredApplications(client, guildId) { +export async function cleanupExpiredApplications(client: NexoClient, guildId: string): Promise<{ removed: number; scanned: number }> { try { if (!client.db || typeof client.db.list !== 'function') { return { removed: 0, scanned: 0 }; @@ -763,7 +903,7 @@ export async function cleanupExpiredApplications(client, guildId) { let removed = 0; for (const key of applicationKeys) { - const app = unwrapReplitData(await client.db.get(key, null)); + const app = unwrapReplitData(await client.db.get(key, null)); if (!app) { continue; } @@ -783,7 +923,7 @@ export async function cleanupExpiredApplications(client, guildId) { } } -export async function saveApplicationSettings(client, guildId, settings) { +export async function saveApplicationSettings(client: NexoClient, guildId: string, settings: Partial): Promise { const key = getApplicationSettingsKey(guildId); try { const existingSettings = await getApplicationSettings(client, guildId); @@ -797,18 +937,24 @@ export async function saveApplicationSettings(client, guildId, settings) { } } -function getApplicationRoleSettingsKey(guildId, roleId) { +function getApplicationRoleSettingsKey(guildId: string, roleId: string): string { return `guild:${guildId}:applications:role:${roleId}:settings`; } -export async function getApplicationRoleSettings(client, guildId, roleId) { +export interface ApplicationRoleSettings { + questions?: string[] | null; + logChannelId?: string | null; + [key: string]: unknown; +} + +export async function getApplicationRoleSettings(client: NexoClient, guildId: string, roleId: string): Promise { try { if (!client.db || typeof client.db.get !== "function") { return { questions: null, logChannelId: null }; } const key = getApplicationRoleSettingsKey(guildId, roleId); - const settings = await client.db.get(key, {}); + const settings = await client.db.get(key, {}); return unwrapReplitData(settings) || { questions: null, logChannelId: null }; } catch (error) { logger.error(`Error getting application role settings for ${guildId}:${roleId}:`, error); @@ -816,7 +962,12 @@ export async function getApplicationRoleSettings(client, guildId, roleId) { } } -export async function saveApplicationRoleSettings(client, guildId, roleId, settings) { +export async function saveApplicationRoleSettings( + client: NexoClient, + guildId: string, + roleId: string, + settings: ApplicationRoleSettings, +): Promise { try { if (!client.db || typeof client.db.set !== "function") { logger.error("Database client is not available for saveApplicationRoleSettings."); @@ -832,7 +983,7 @@ export async function saveApplicationRoleSettings(client, guildId, roleId, setti } } -export async function deleteApplicationRoleSettings(client, guildId, roleId) { +export async function deleteApplicationRoleSettings(client: NexoClient, guildId: string, roleId: string): Promise { try { if (!client.db || typeof client.db.delete !== "function") { logger.error("Database client is not available for deleteApplicationRoleSettings."); @@ -848,12 +999,38 @@ export async function deleteApplicationRoleSettings(client, guildId, roleId) { } } -export async function createApplication(client, application) { +export interface ApplicationAnswer { + question: string; + answer: string; +} + +/** Fields supplied when a user submits an application. */ +export interface ApplicationInput { + guildId: string; + userId: string; + roleId?: string; + roleName?: string; + answers?: ApplicationAnswer[]; + [key: string]: unknown; +} + +/** Stored application record (`guild:{guildId}:applications:{applicationId}`). */ +export interface Application extends ApplicationInput { + id: string; + status: string; + createdAt: number; + updatedAt: number; + reviewedBy: string | null; + reviewedAt: string | null; + notes: unknown[]; +} + +export async function createApplication(client: NexoClient, application: ApplicationInput): Promise { const { guildId, userId } = application; const applicationId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const key = getApplicationKey(guildId, applicationId); - const newApplication = { + const newApplication: Application = { ...application, id: applicationId, status: 'pending', @@ -873,7 +1050,7 @@ status: 'pending', await client.db.set(key, newApplication); const userKey = getUserApplicationsKey(guildId, userId); - const userApplications = await client.db.get(userKey, []); + const userApplications = await client.db.get(userKey, []); const unwrappedApplications = unwrapReplitData(userApplications); const applicationsArray = Array.isArray(unwrappedApplications) ? unwrappedApplications : []; @@ -891,11 +1068,11 @@ status: 'pending', } } -export async function getApplication(client, guildId, applicationId) { +export async function getApplication(client: NexoClient, guildId: string, applicationId: string): Promise { const key = getApplicationKey(guildId, applicationId); try { await cleanupExpiredApplications(client, guildId); - const application = await client.db.get(key, null); + const application = await client.db.get(key, null); return unwrapReplitData(application); } catch (error) { logger.error(`Error getting application ${applicationId} in guild ${guildId}:`, error); @@ -903,7 +1080,12 @@ export async function getApplication(client, guildId, applicationId) { } } -export async function updateApplication(client, guildId, applicationId, updates) { +export async function updateApplication( + client: NexoClient, + guildId: string, + applicationId: string, + updates: Partial, +): Promise { const key = getApplicationKey(guildId, applicationId); try { const existingApplication = await getApplication(client, guildId, applicationId); @@ -911,7 +1093,7 @@ export async function updateApplication(client, guildId, applicationId, updates) throw new Error(`Application ${applicationId} not found`); } - const updatedApplication = { + const updatedApplication: Application = { ...existingApplication, ...updates, updatedAt: Date.now() @@ -925,7 +1107,7 @@ export async function updateApplication(client, guildId, applicationId, updates) } } -export async function getUserApplications(client, guildId, userId) { +export async function getUserApplications(client: NexoClient, guildId: string, userId: string): Promise { const userKey = getUserApplicationsKey(guildId, userId); try { if (!client.db || typeof client.db.get !== "function") { @@ -935,7 +1117,7 @@ export async function getUserApplications(client, guildId, userId) { await cleanupExpiredApplications(client, guildId); - const applicationIds = await client.db.get(userKey, []); + const applicationIds = await client.db.get(userKey, []); const unwrappedIds = unwrapReplitData(applicationIds); const idsArray = Array.isArray(unwrappedIds) ? unwrappedIds : []; @@ -945,14 +1127,21 @@ export async function getUserApplications(client, guildId, userId) { ); const applications = await Promise.all(applicationPromises); - return applications.filter(Boolean); + return applications.filter((app): app is Application => Boolean(app)); } catch (error) { logger.error(`Error getting applications for user ${userId} in guild ${guildId}:`, error); return []; } } -export async function getApplications(client, guildId, filters = {}) { +export interface ApplicationFilters { + status?: string; + userId?: string; + limit?: number; + offset?: number; +} + +export async function getApplications(client: NexoClient, guildId: string, filters: ApplicationFilters = {}): Promise { const { status, userId, @@ -983,10 +1172,10 @@ export async function getApplications(client, guildId, filters = {}) { const applicationKeyPattern = new RegExp(`^guild:${guildId}:applications:[^:]+$`); const applicationKeys = keys.filter(key => applicationKeyPattern.test(key)); - const applicationPromises = applicationKeys.map(key => client.db.get(key)); + const applicationPromises = applicationKeys.map(key => client.db.get(key)); let applications = (await Promise.all(applicationPromises)) .map(unwrapReplitData) - .filter(Boolean); + .filter((app): app is Application => Boolean(app)); if (status) { applications = applications.filter(app => app.status === status); @@ -1005,7 +1194,32 @@ export async function getApplications(client, guildId, filters = {}) { } } -export async function getJoinToCreateConfig(client, guildId) { +export interface JoinToCreateChannelOptions { + nameTemplate: string; + userLimit: number; + bitrate: number; +} + +export interface TemporaryChannelInfo { + ownerId: string; + triggerChannelId: string; + createdAt: number; + [key: string]: unknown; +} + +export interface JoinToCreateConfig { + enabled: boolean; + triggerChannels: string[]; + categoryId: string | null; + channelNameTemplate: string; + userLimit: number; + bitrate: number; + temporaryChannels: Record; + channelOptions?: Record; + [key: string]: unknown; +} + +export async function getJoinToCreateConfig(client: NexoClient, guildId: string): Promise { if (!client.db) { logger.warn('Database not available for getJoinToCreateConfig'); return { @@ -1021,7 +1235,7 @@ export async function getJoinToCreateConfig(client, guildId) { const key = getJoinToCreateConfigKey(guildId); try { - const config = await client.db.get(key, {}); + const config = await client.db.get>(key, {}); const unwrapped = unwrapReplitData(config); return { @@ -1048,7 +1262,7 @@ export async function getJoinToCreateConfig(client, guildId) { } } -export async function saveJoinToCreateConfig(client, guildId, config) { +export async function saveJoinToCreateConfig(client: NexoClient, guildId: string, config: Partial): Promise { const key = getJoinToCreateConfigKey(guildId); try { const existingConfig = await getJoinToCreateConfig(client, guildId); @@ -1062,7 +1276,11 @@ export async function saveJoinToCreateConfig(client, guildId, config) { } } -export async function updateJoinToCreateConfig(client, guildId, updates) { +export async function updateJoinToCreateConfig( + client: NexoClient, + guildId: string, + updates: Partial, +): Promise { try { const currentConfig = await getJoinToCreateConfig(client, guildId); const updatedConfig = { ...currentConfig, ...updates }; @@ -1075,7 +1293,12 @@ export async function updateJoinToCreateConfig(client, guildId, updates) { } } -export async function addJoinToCreateTrigger(client, guildId, channelId, options = {}) { +export async function addJoinToCreateTrigger( + client: NexoClient, + guildId: string, + channelId: string, + options: Partial = {}, +): Promise { try { const config = await getJoinToCreateConfig(client, guildId); @@ -1104,7 +1327,7 @@ export async function addJoinToCreateTrigger(client, guildId, channelId, options } } -export async function removeJoinToCreateTrigger(client, guildId, channelId) { +export async function removeJoinToCreateTrigger(client: NexoClient, guildId: string, channelId: string): Promise { try { const config = await getJoinToCreateConfig(client, guildId); @@ -1127,7 +1350,13 @@ export async function removeJoinToCreateTrigger(client, guildId, channelId) { } } -export async function registerTemporaryChannel(client, guildId, channelId, ownerId, triggerChannelId) { +export async function registerTemporaryChannel( + client: NexoClient, + guildId: string, + channelId: string, + ownerId: string, + triggerChannelId: string, +): Promise { try { const config = await getJoinToCreateConfig(client, guildId); @@ -1144,7 +1373,7 @@ export async function registerTemporaryChannel(client, guildId, channelId, owner } } -export async function unregisterTemporaryChannel(client, guildId, channelId) { +export async function unregisterTemporaryChannel(client: NexoClient, guildId: string, channelId: string): Promise { try { const config = await getJoinToCreateConfig(client, guildId); @@ -1160,7 +1389,11 @@ export async function unregisterTemporaryChannel(client, guildId, channelId) { } } -export async function getTemporaryChannelInfo(client, guildId, channelId) { +export async function getTemporaryChannelInfo( + client: NexoClient, + guildId: string, + channelId: string, +): Promise { try { const config = await getJoinToCreateConfig(client, guildId); return config.temporaryChannels[channelId] || null; @@ -1170,7 +1403,15 @@ export async function getTemporaryChannelInfo(client, guildId, channelId) { } } -export function formatChannelName(template, variables) { +export interface ChannelNameVariables { + username?: string; + userTag?: string; + displayName?: string; + guildName?: string; + channelName?: string; +} + +export function formatChannelName(template: string, variables: ChannelNameVariables): string { let formatted = template; const replacements = { @@ -1191,6 +1432,6 @@ formatted = formatted.substring(0, 100); return formatted || 'Voice Channel'; } -function generateCaseId() { +function generateCaseId(): string { return `${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 4)}`; } \ No newline at end of file diff --git a/src/utils/database/guildConfigStorage.js b/src/utils/database/guildConfigStorage.ts similarity index 72% rename from src/utils/database/guildConfigStorage.js rename to src/utils/database/guildConfigStorage.ts index f867a4d2da..3f058d533e 100644 --- a/src/utils/database/guildConfigStorage.js +++ b/src/utils/database/guildConfigStorage.ts @@ -1,17 +1,33 @@ import { logger } from '../logger.js'; +import type { GuildConfig, NexoClient } from '../../types/index.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) { +/** Optional tracing context passed through to logs and errors. */ +export interface GuildConfigContext { + traceId?: string; + userId?: string; + command?: string; + [key: string]: unknown; +} + +/** Legacy Replit DB envelope `{ ok, value }`. */ +interface ReplitEnvelope { + ok?: unknown; + value?: T; +} + +/** Unwraps legacy `{ ok, value }` envelopes; T is the caller's stored payload type. */ +export function unwrapReplitData(data: T): T { if ( typeof data === 'object' && data !== null && - data.ok !== undefined && - data.value !== undefined + (data as ReplitEnvelope).ok !== undefined && + (data as ReplitEnvelope).value !== undefined ) { - return unwrapReplitData(data.value); + return unwrapReplitData((data as ReplitEnvelope).value as T); } return data; } @@ -20,7 +36,11 @@ export function unwrapReplitData(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 = {}) { +export async function readGuildConfig( + client: NexoClient, + guildId: string, + context: GuildConfigContext = {}, +): Promise { try { if (!client?.db || typeof client.db.get !== 'function') { logger.warn(`Database unavailable for readGuildConfig in guild ${guildId}`); @@ -58,7 +78,12 @@ export async function readGuildConfig(client, guildId, context = {}) { /** * Low-level guild config write. Validates payload and throws on failure. */ -export async function writeGuildConfig(client, guildId, config, context = {}) { +export async function writeGuildConfig( + client: NexoClient, + guildId: string, + config: unknown, + context: GuildConfigContext = {}, +): Promise { if (!client?.db || typeof client.db.set !== 'function') { throw createError( 'Database client unavailable for guild config write', diff --git a/src/utils/database/keyMigration.js b/src/utils/database/keyMigration.ts similarity index 80% rename from src/utils/database/keyMigration.js rename to src/utils/database/keyMigration.ts index 97c7136bdc..b5efea2ebd 100644 --- a/src/utils/database/keyMigration.js +++ b/src/utils/database/keyMigration.ts @@ -1,3 +1,4 @@ +import type pg from 'pg'; import { pgConfig } from '../../config/database/postgres.js'; import { canonicalizeKey } from './keys.js'; import { parseKey } from './keyParser.js'; @@ -9,7 +10,7 @@ import { parseKey } from './keyParser.js'; export const KEY_MIGRATION_VERSION = 1; const KEY_MIGRATION_MARKER = `__meta:key_migration:v${KEY_MIGRATION_VERSION}`; -async function keyExists(client, canonicalKey) { +async function keyExists(client: pg.PoolClient, canonicalKey: string): Promise { const parsed = parseKey(canonicalKey); if (parsed.type === 'economy') { @@ -43,7 +44,11 @@ async function keyExists(client, canonicalKey) { return tempResult.rows.length > 0; } -async function ensureParentRows(client, guildId, userId) { +async function ensureParentRows( + client: pg.PoolClient, + guildId: string | null | undefined, + userId: string | null | undefined, +): Promise { if (guildId) { await client.query( `INSERT INTO ${pgConfig.tables.guilds} (id, created_at) @@ -60,7 +65,7 @@ async function ensureParentRows(client, guildId, userId) { } } -function resolveTimestampValue(input, fallback = new Date()) { +function resolveTimestampValue(input: unknown, fallback = new Date()): Date { if (input instanceof Date && !Number.isNaN(input.getTime())) { return input; } @@ -75,13 +80,13 @@ function resolveTimestampValue(input, fallback = new Date()) { return Number.isNaN(timestamp.getTime()) ? fallback : timestamp; } - const parsedDate = new Date(input); + const parsedDate = new Date(input as string | number | Date); return !Number.isNaN(parsedDate.getTime()) ? parsedDate : fallback; } -async function migrateEconomyFromTemp(client, legacyKey, value) { +async function migrateEconomyFromTemp(client: pg.PoolClient, legacyKey: string, value: unknown): Promise { const parsed = parseKey(canonicalizeKey(legacyKey)); - const payload = typeof value === 'string' ? JSON.parse(value) : value; + const payload: Record | null | undefined = typeof value === 'string' ? JSON.parse(value) : value; const wallet = payload?.wallet ?? payload?.balance ?? 0; const bank = payload?.bank ?? 0; @@ -98,9 +103,9 @@ async function migrateEconomyFromTemp(client, legacyKey, value) { ); } -async function migrateUserLevelFromTemp(client, legacyKey, value) { +async function migrateUserLevelFromTemp(client: pg.PoolClient, legacyKey: string, value: unknown): Promise { const parsed = parseKey(canonicalizeKey(legacyKey)); - const payload = typeof value === 'string' ? JSON.parse(value) : value; + const payload: Record | null | undefined = typeof value === 'string' ? JSON.parse(value) : value; const lastMessageValue = payload?.lastMessage ?? payload?.last_message; await ensureParentRows(client, parsed.guildId, parsed.userId); @@ -127,9 +132,9 @@ async function migrateUserLevelFromTemp(client, legacyKey, value) { ); } -async function migrateCountersFromTemp(client, legacyKey, value) { +async function migrateCountersFromTemp(client: pg.PoolClient, legacyKey: string, value: unknown): Promise { const parsed = parseKey(canonicalizeKey(legacyKey)); - const payload = typeof value === 'string' ? JSON.parse(value) : value; + const payload: unknown = typeof value === 'string' ? JSON.parse(value) : value; const counters = Array.isArray(payload) ? payload : []; await ensureParentRows(client, parsed.guildId, null); @@ -141,7 +146,7 @@ async function migrateCountersFromTemp(client, legacyKey, value) { ); } -async function migrateTempKeyRename(client, legacyKey, canonicalKey) { +async function migrateTempKeyRename(client: pg.PoolClient, legacyKey: string, canonicalKey: string): Promise { await client.query( `INSERT INTO ${pgConfig.tables.temp_data} (key, value, expires_at, created_at) SELECT $1, value, expires_at, created_at @@ -153,7 +158,7 @@ async function migrateTempKeyRename(client, legacyKey, canonicalKey) { await client.query(`DELETE FROM ${pgConfig.tables.temp_data} WHERE key = $1`, [legacyKey]); } -async function hasCompletedMarker(client) { +async function hasCompletedMarker(client: pg.PoolClient): Promise { const result = await client.query( `SELECT 1 FROM ${pgConfig.tables.temp_data} WHERE key = $1 LIMIT 1`, [KEY_MIGRATION_MARKER], @@ -161,7 +166,7 @@ async function hasCompletedMarker(client) { return result.rows.length > 0; } -async function writeCompletedMarker(client, summary) { +async function writeCompletedMarker(client: pg.PoolClient, summary: KeyMigrationSummary): Promise { await client.query( `INSERT INTO ${pgConfig.tables.temp_data} (key, value, expires_at, created_at) VALUES ($1, $2::jsonb, NULL, CURRENT_TIMESTAMP) @@ -170,6 +175,26 @@ async function writeCompletedMarker(client, summary) { ); } +/** Minimal logger surface used by the migration (winston logger or console). */ +export interface MigrationLogger { + info(message: string, ...meta: unknown[]): unknown; + error(message: string, ...meta: unknown[]): unknown; +} + +export interface KeyMigrationOptions { + pool?: pg.Pool | null; + dryRun?: boolean; + force?: boolean; + logger?: MigrationLogger; +} + +export interface KeyMigrationSummary { + migrated: number; + skipped: number; + errors: number; + alreadyDone?: boolean; +} + /** * Migrate legacy database keys to their canonical form. * @@ -180,13 +205,15 @@ async function writeCompletedMarker(client, summary) { * @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 } = {}) { +export async function runKeyMigration( + { pool, dryRun = false, force = false, logger = console }: KeyMigrationOptions = {}, +): Promise { if (!pool) { throw new Error('runKeyMigration requires a connected pg pool'); } const client = await pool.connect(); - const summary = { migrated: 0, skipped: 0, errors: 0 }; + const summary: KeyMigrationSummary = { migrated: 0, skipped: 0, errors: 0 }; try { if (!force && !dryRun && (await hasCompletedMarker(client))) { @@ -195,7 +222,7 @@ export async function runKeyMigration({ pool, dryRun = false, force = false, log logger.info(`Starting key migration${dryRun ? ' (dry run)' : ''}...`); - const rows = await client.query( + const rows = await client.query<{ key: string; value: unknown }>( `SELECT key, value FROM ${pgConfig.tables.temp_data} ORDER BY key`, ); diff --git a/src/utils/database/keyParser.js b/src/utils/database/keyParser.ts similarity index 89% rename from src/utils/database/keyParser.js rename to src/utils/database/keyParser.ts index 1d2a424cbe..482068f678 100644 --- a/src/utils/database/keyParser.js +++ b/src/utils/database/keyParser.ts @@ -13,7 +13,7 @@ import { getLevelingKey, } from './keys.js'; -const TEMP_BACKED_TYPES = new Set([ +const TEMP_BACKED_TYPES = new Set([ 'warnings', 'usernotes', 'usernotes_list', @@ -32,14 +32,39 @@ const TEMP_BACKED_TYPES = new Set([ 'ticket_counter', ]); -export function isTempBackedType(type) { +export function isTempBackedType(type: string): boolean { return type === 'temp' || TEMP_BACKED_TYPES.has(type); } +/** Routing metadata for a storage key. `type` selects the backing table. */ +export interface ParsedKey { + type: string; + fullKey: string; + guildId?: string; + userId?: string; + channelId?: string; + messageId?: string; + applicationId?: string; + inviteCode?: string; + giveawayId?: string; +} + +export interface StructuredListQuery { + sql: string; + params: string[]; + mapKey: (row: Record) => string; +} + +export interface StructuredListPlan { + queries: StructuredListQuery[]; + staticKeys: string[]; + tempPrefixes?: string[]; +} + /** * Parse a storage key into routing metadata for the database layer. */ -export function parseKey(key) { +export function parseKey(key: string): ParsedKey { const fullKey = canonicalizeKey(key); if (fullKey.startsWith('temp:')) { @@ -162,12 +187,15 @@ export function parseKey(key) { * 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: [] }; +export function getStructuredListPlan( + prefix: string, + tables: { economy: string; user_levels: string; tickets: string }, +): StructuredListPlan { + const plan: StructuredListPlan = { queries: [], staticKeys: [] }; const canonicalPrefix = canonicalizeKey(prefix.endsWith(':') ? prefix.slice(0, -1) : prefix); const normalizedPrefix = prefix.endsWith(':') ? prefix : `${prefix}:`; - const addEconomy = (guildId) => { + const addEconomy = (guildId: string) => { plan.queries.push({ sql: `SELECT user_id FROM ${tables.economy} WHERE guild_id = $1`, params: [guildId], @@ -175,7 +203,7 @@ export function getStructuredListPlan(prefix, tables) { }); }; - const addUserLevels = (guildId) => { + const addUserLevels = (guildId: string) => { plan.queries.push({ sql: `SELECT user_id FROM ${tables.user_levels} WHERE guild_id = $1`, params: [guildId], @@ -183,7 +211,7 @@ export function getStructuredListPlan(prefix, tables) { }); }; - const addTickets = (guildId) => { + const addTickets = (guildId: string) => { plan.queries.push({ sql: `SELECT channel_id FROM ${tables.tickets} WHERE guild_id = $1`, params: [guildId], @@ -192,7 +220,7 @@ export function getStructuredListPlan(prefix, tables) { plan.staticKeys.push(getTicketCounterKey(guildId)); }; - const addGuildScopedSingletons = (guildId) => { + const addGuildScopedSingletons = (guildId: string) => { plan.staticKeys.push( getGuildConfigKey(guildId), getGuildBirthdaysKey(guildId), diff --git a/src/utils/database/keys.js b/src/utils/database/keys.ts similarity index 68% rename from src/utils/database/keys.js rename to src/utils/database/keys.ts index 139922dee8..398dc70ded 100644 --- a/src/utils/database/keys.js +++ b/src/utils/database/keys.ts @@ -3,124 +3,124 @@ * 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 const getGuildConfigKey = (guildId: string): string => `guild:${guildId}:config`; +export const getGuildBirthdaysKey = (guildId: string): string => `guild:${guildId}:birthdays`; +export const getBirthdayLeftBackupKey = (guildId: string): string => `guild:${guildId}:birthdays:left`; +export const getBirthdayTrackingKey = (guildId: string): string => `guild:${guildId}:birthdays:tracking`; -export function getTicketKey(guildId, channelId) { +export function getTicketKey(guildId: string, channelId: string): string { return `guild:${guildId}:ticket:${channelId}`; } -export function getTicketCounterKey(guildId) { +export function getTicketCounterKey(guildId: string): string { return `guild:${guildId}:ticket:counter`; } -export function getInviteTrackingKey(guildId) { +export function getInviteTrackingKey(guildId: string): string { return `guild:${guildId}:invites`; } -export function getMemberInvitesKey(guildId, userId) { +export function getMemberInvitesKey(guildId: string, userId: string): string { return `guild:${guildId}:invites:${userId}`; } -export function getInviteUsesKey(guildId, inviteCode) { +export function getInviteUsesKey(guildId: string, inviteCode: string): string { return `guild:${guildId}:invite_uses:${inviteCode}`; } -export function getFakeAccountKey(guildId, userId) { +export function getFakeAccountKey(guildId: string, userId: string): string { return `guild:${guildId}:fake_account:${userId}`; } -export function getEconomyKey(guildId, userId) { +export function getEconomyKey(guildId: string, userId: string): string { return `guild:${guildId}:economy:${userId}`; } -export function getEconomyPrefix(guildId) { +export function getEconomyPrefix(guildId: string): string { return `guild:${guildId}:economy:`; } -export function getAFKKey(guildId, userId) { +export function getAFKKey(guildId: string, userId: string): string { return `guild:${guildId}:afk:${userId}`; } -export function getWelcomeConfigKey(guildId) { +export function getWelcomeConfigKey(guildId: string): string { return `guild:${guildId}:welcome`; } -export function getLevelingKey(guildId) { +export function getLevelingKey(guildId: string): string { return `guild:${guildId}:leveling:config`; } -export function getUserLevelKey(guildId, userId) { +export function getUserLevelKey(guildId: string, userId: string): string { return `guild:${guildId}:leveling:users:${userId}`; } -export function getUserLevelPrefix(guildId) { +export function getUserLevelPrefix(guildId: string): string { return `guild:${guildId}:leveling:users:`; } -export function getApplicationRolesKey(guildId) { +export function getApplicationRolesKey(guildId: string): string { return `guild:${guildId}:applications:roles`; } -export function getApplicationSettingsKey(guildId) { +export function getApplicationSettingsKey(guildId: string): string { return `guild:${guildId}:applications:settings`; } -export function getUserApplicationsKey(guildId, userId) { +export function getUserApplicationsKey(guildId: string, userId: string): string { return `guild:${guildId}:applications:users:${userId}`; } -export function getApplicationKey(guildId, applicationId) { +export function getApplicationKey(guildId: string, applicationId: string): string { return `guild:${guildId}:applications:${applicationId}`; } -export function getApplicationsPrefix(guildId) { +export function getApplicationsPrefix(guildId: string): string { return `guild:${guildId}:applications:`; } -export function getJoinToCreateConfigKey(guildId) { +export function getJoinToCreateConfigKey(guildId: string): string { return `guild:${guildId}:jointocreate`; } -export function getJoinToCreateChannelsKey(guildId) { +export function getJoinToCreateChannelsKey(guildId: string): string { return `guild:${guildId}:jointocreate:channels`; } -export function getWarningsKey(guildId, userId) { +export function getWarningsKey(guildId: string, userId: string): string { return `guild:${guildId}:warnings:${userId}`; } -export function getWarningsPrefix(guildId) { +export function getWarningsPrefix(guildId: string): string { return `guild:${guildId}:warnings:`; } -export function getUserNotesKey(guildId, userId) { +export function getUserNotesKey(guildId: string, userId: string): string { return `guild:${guildId}:usernotes:${userId}`; } -export function getUserNotesListKey(guildId) { +export function getUserNotesListKey(guildId: string): string { return `guild:${guildId}:usernotes:list`; } -export function getReactionRoleKey(guildId, messageId) { +export function getReactionRoleKey(guildId: string, messageId: string): string { return `guild:${guildId}:reaction_roles:${messageId}`; } -export function getReactionRolesPrefix(guildId) { +export function getReactionRolesPrefix(guildId: string): string { return `guild:${guildId}:reaction_roles:`; } -export function getServerCountersKey(guildId) { +export function getServerCountersKey(guildId: string): string { return `guild:${guildId}:counters`; } -export function getGiveawayEntryKey(userId, giveawayId) { +export function getGiveawayEntryKey(userId: string, giveawayId: string): string { return `giveaway:${userId}:${giveawayId}`; } -export function getGiveawayLockKey(messageId) { +export function getGiveawayLockKey(messageId: string): string { return `giveaway:lock:${messageId}`; } @@ -128,7 +128,13 @@ export function getGiveawayLockKey(messageId) { * Legacy key patterns mapped to canonical builders. * Used by migration script and read-time fallback. */ -export const LEGACY_KEY_RESOLVERS = [ +interface LegacyKeyResolver { + pattern: RegExp; + toCanonical: (match: readonly string[]) => string; + skipIf?: (guildId: string) => boolean; +} + +export const LEGACY_KEY_RESOLVERS: LegacyKeyResolver[] = [ { pattern: /^economy:([^:]+):([^:]+)$/, toCanonical: ([, guildId, userId]) => getEconomyKey(guildId, userId), @@ -171,7 +177,7 @@ export const LEGACY_KEY_RESOLVERS = [ /** * Returns the canonical key for a legacy or already-canonical key. */ -export function canonicalizeKey(key) { +export function canonicalizeKey(key: string): string { if (typeof key !== 'string' || !key) { return key; } @@ -189,8 +195,8 @@ export function canonicalizeKey(key) { /** * Returns legacy key variants that may still hold data for a canonical key. */ -export function getLegacyVariantsForCanonical(canonicalKey) { - const variants = []; +export function getLegacyVariantsForCanonical(canonicalKey: string): string[] { + const variants: string[] = []; for (const { pattern, toCanonical } of LEGACY_KEY_RESOLVERS) { const sample = canonicalKey; diff --git a/src/utils/database/schema.js b/src/utils/database/schema.ts similarity index 100% rename from src/utils/database/schema.js rename to src/utils/database/schema.ts diff --git a/src/utils/database/tickets.js b/src/utils/database/tickets.ts similarity index 67% rename from src/utils/database/tickets.js rename to src/utils/database/tickets.ts index 58384f57be..85e2990be3 100644 --- a/src/utils/database/tickets.js +++ b/src/utils/database/tickets.ts @@ -4,16 +4,49 @@ import { getTicketCounterKey, getTicketKey } from './keys.js'; export { getTicketKey, getTicketCounterKey } from './keys.js'; -export async function getTicketData(guildId, channelId) { +/** Stored ticket record (`guild:{guildId}:ticket:{channelId}`). */ +export interface TicketData { + id: string; + userId: string; + guildId: string; + createdAt: string; + status: string; + claimedBy: string | null; + priority: string; + reason?: string; + claimedAt?: string; + closedBy?: string | null; + closedAt?: string | null; + closeReason?: string | null; + priorityUpdatedAt?: string; + priorityUpdatedBy?: string; + feedback?: { + rating?: number; + submittedAt?: string; + comment?: string; + commentSubmittedAt?: string; + }; + [key: string]: unknown; +} + +export interface GuildTicketStats { + openCount: number; + closedCount: number; + avgCloseTimeMs: number | null; + feedbackCount: number; + avgRating: number | null; +} + +export async function getTicketData(guildId: string, channelId: string): Promise { if (!db.initialized) { await db.initialize(); } const key = getTicketKey(guildId, channelId); - return await db.get(key); + return await db.get(key); } -export async function getOpenTicketCountForUser(guildId, userId) { +export async function getOpenTicketCountForUser(guildId: string, userId: string): Promise { try { if (!db.initialized) { await db.initialize(); @@ -21,7 +54,7 @@ export async function getOpenTicketCountForUser(guildId, userId) { if (db.db?.pool && typeof db.db.isAvailable === 'function' && db.db.isAvailable()) { const { pgConfig } = await import('../../config/database/postgres.js'); - const result = await db.db.pool.query( + const result = await db.db.pool.query<{ count: number }>( `SELECT COUNT(*)::int AS count FROM ${pgConfig.tables.tickets} WHERE guild_id = $1 AND data->>'userId' = $2 @@ -38,7 +71,7 @@ export async function getOpenTicketCountForUser(guildId, userId) { for (const key of ticketKeys) { if (key.endsWith(':counter')) continue; - const ticket = await getFromDb(key, null); + const ticket = await getFromDb(key, null); if (ticket && ticket.userId === userId && ticket.status === 'open') { count += 1; } @@ -54,7 +87,7 @@ export async function getOpenTicketCountForUser(guildId, userId) { } } -export async function saveTicketData(guildId, channelId, data) { +export async function saveTicketData(guildId: string, channelId: string, data: TicketData): Promise { if (!db.initialized) { await db.initialize(); } @@ -63,7 +96,7 @@ export async function saveTicketData(guildId, channelId, data) { await db.set(key, data); } -export async function deleteTicketData(guildId, channelId) { +export async function deleteTicketData(guildId: string, channelId: string): Promise { if (!db.initialized) { await db.initialize(); } @@ -72,17 +105,17 @@ export async function deleteTicketData(guildId, channelId) { await db.delete(key); } -export async function getTicketCounter(guildId) { +export async function getTicketCounter(guildId: string): Promise { if (!db.initialized) { await db.initialize(); } const key = getTicketCounterKey(guildId); - const counter = await db.get(key); + const counter = await db.get(key); return counter || 0; } -export async function incrementTicketCounter(guildId) { +export async function incrementTicketCounter(guildId: string): Promise { if (!db.initialized) { await db.initialize(); } @@ -96,18 +129,18 @@ export async function incrementTicketCounter(guildId) { return nextCounter.toString().padStart(3, '0'); } -async function listGuildTickets(guildId) { +async function listGuildTickets(guildId: string): Promise { if (!db.initialized) { await db.initialize(); } if (db.db?.pool && typeof db.db.isAvailable === 'function' && db.db.isAvailable()) { const { pgConfig } = await import('../../config/database/postgres.js'); - const result = await db.db.pool.query( + const result = await db.db.pool.query<{ data: TicketData | null }>( `SELECT data FROM ${pgConfig.tables.tickets} WHERE guild_id = $1`, [guildId], ); - return result.rows.map((row) => row.data).filter(Boolean); + return result.rows.map((row) => row.data).filter((data): data is TicketData => Boolean(data)); } if (typeof db.list !== 'function') { @@ -115,18 +148,18 @@ async function listGuildTickets(guildId) { } const ticketKeys = await db.list(`guild:${guildId}:ticket:`); - const tickets = []; + const tickets: TicketData[] = []; for (const key of ticketKeys) { if (key.endsWith(':counter')) continue; - const ticket = await getFromDb(key, null); + const ticket = await getFromDb(key, null); if (ticket) tickets.push(ticket); } return tickets; } -export async function getGuildTicketStats(guildId) { +export async function getGuildTicketStats(guildId: string): Promise { try { const tickets = await listGuildTickets(guildId); let openCount = 0; @@ -142,7 +175,7 @@ export async function getGuildTicketStats(guildId) { } else if (ticket.status === 'closed') { closedCount += 1; if (ticket.createdAt && ticket.closedAt) { - const duration = new Date(ticket.closedAt) - new Date(ticket.createdAt); + const duration = new Date(ticket.closedAt).getTime() - new Date(ticket.createdAt).getTime(); if (Number.isFinite(duration) && duration >= 0) { totalCloseMs += duration; closeSamples += 1; diff --git a/src/utils/database/wrapper.js b/src/utils/database/wrapper.ts similarity index 56% rename from src/utils/database/wrapper.js rename to src/utils/database/wrapper.ts index b28b60931b..44abe6652a 100644 --- a/src/utils/database/wrapper.js +++ b/src/utils/database/wrapper.ts @@ -1,9 +1,39 @@ +import type pg from 'pg'; import { pgDb } from '../postgresDatabase.js'; import { MemoryStorage } from '../memoryStorage.js'; import { logger } from '../logger.js'; import { validateGuildConfigOrThrow } from '../schemas.js'; +/** + * Key-value store contract shared by the PostgreSQL store and the in-memory fallback. + * Values are JSON; `get` lets callers state the stored shape (unchecked). + */ +export interface KeyValueStore { + get(key: string, defaultValue?: null): Promise; + get(key: string, defaultValue: T): Promise; + get(key: string, defaultValue?: T | null): Promise; + set(key: string, value: unknown, ttl?: number | null): Promise; + delete(key: string): Promise; + list(prefix: string): Promise; + // Optional: the wrapper falls back to get/set when a store lacks these. + exists?(key: string): Promise; + increment?(key: string, amount?: number): Promise; + decrement?(key: string, amount?: number): Promise; + /** Only the PostgreSQL store has a pool. */ + pool?: pg.Pool | null; + isAvailable?(): unknown; +} + +type ConnectionType = 'none' | 'postgresql' | 'memory'; + class DatabaseWrapper { + initialized: boolean; + db: KeyValueStore | null; + useFallback: boolean; + connectionType: ConnectionType; + degradedModeWarningShown: boolean; + degradedReason: string | null; + constructor() { this.initialized = false; this.db = null; @@ -13,7 +43,7 @@ class DatabaseWrapper { this.degradedReason = null; } - async initialize() { + async initialize(): Promise { if (this.initialized) { return; } @@ -32,16 +62,16 @@ class DatabaseWrapper { const pgFailure = pgDb.getLastFailure?.(); if (pgFailure?.reason === 'SCHEMA_VERSION_MISMATCH') { - const schemaError = new Error( + const schemaError: Error & { code?: string } = new Error( `Schema version mismatch detected (${pgFailure.message}). Run migrations before startup.`, ); schemaError.code = 'SCHEMA_VERSION_MISMATCH'; throw schemaError; } } catch (error) { - logger.warn('PostgreSQL connection failed:', error.message); + logger.warn('PostgreSQL connection failed:', error instanceof Error ? error.message : String(error)); - if (error.code === 'SCHEMA_VERSION_MISMATCH') { + if ((error as { code?: string }).code === 'SCHEMA_VERSION_MISMATCH') { throw error; } } @@ -56,7 +86,7 @@ class DatabaseWrapper { this.degradedModeWarningShown = true; } - async set(key, value, ttl = null) { + async set(key: string, value: unknown, ttl: number | null = null): Promise { if (this.useFallback) { logger.debug(`[DEGRADED] Writing to memory: ${key}`); } @@ -69,55 +99,58 @@ class DatabaseWrapper { }); } - return this.db.set(key, value, ttl); + return this.db!.set(key, value, ttl); } - async get(key, defaultValue = null) { - return this.db.get(key, defaultValue); + get(key: string, defaultValue?: null): Promise; + get(key: string, defaultValue: T): Promise; + get(key: string, defaultValue?: T | null): Promise; + async get(key: string, defaultValue: unknown = null): Promise { + return this.db!.get(key, defaultValue); } - async delete(key) { + async delete(key: string): Promise { if (this.useFallback) { logger.debug(`[DEGRADED] Deleting from memory: ${key}`); } - return this.db.delete(key); + return this.db!.delete(key); } - async list(prefix) { - return this.db.list(prefix); + async list(prefix: string): Promise { + return this.db!.list(prefix); } - async exists(key) { - if (this.db.exists) { - return this.db.exists(key); + async exists(key: string): Promise { + if (this.db!.exists) { + return this.db!.exists(key); } - const value = await this.db.get(key); + const value = await this.db!.get(key); return value !== null; } - async increment(key, amount = 1) { + async increment(key: string, amount = 1): Promise { if (this.useFallback) { logger.debug(`[DEGRADED] Incrementing in memory: ${key}`); } - if (this.db.increment) { - return this.db.increment(key, amount); + if (this.db!.increment) { + return this.db!.increment(key, amount); } - const current = await this.db.get(key, 0); + const current = await this.db!.get(key, 0); const newValue = current + amount; - await this.db.set(key, newValue); + await this.db!.set(key, newValue); return newValue; } - async decrement(key, amount = 1) { + async decrement(key: string, amount = 1): Promise { if (this.useFallback) { logger.debug(`[DEGRADED] Decrementing in memory: ${key}`); } - if (this.db.decrement) { - return this.db.decrement(key, amount); + if (this.db!.decrement) { + return this.db!.decrement(key, amount); } - const current = await this.db.get(key, 0); + const current = await this.db!.get(key, 0); const newValue = current - amount; - await this.db.set(key, newValue); + await this.db!.set(key, newValue); return newValue; } @@ -155,7 +188,7 @@ export async function initializeDatabase() { } catch (error) { logger.error('❌ Database Initialization Error:', error); - if (error.code === 'SCHEMA_VERSION_MISMATCH') { + if ((error as { code?: string }).code === 'SCHEMA_VERSION_MISMATCH') { throw error; } @@ -163,7 +196,10 @@ export async function initializeDatabase() { } } -export async function getFromDb(key, defaultValue = null) { +export async function getFromDb(key: string, defaultValue?: null): Promise; +export async function getFromDb(key: string, defaultValue: T): Promise; +export async function getFromDb(key: string, defaultValue?: T | null): Promise; +export async function getFromDb(key: string, defaultValue: unknown = null): Promise { try { const value = await db.get(key); return value === null ? defaultValue : value; @@ -173,7 +209,7 @@ export async function getFromDb(key, defaultValue = null) { } } -export async function setInDb(key, value, ttl = null) { +export async function setInDb(key: string, value: unknown, ttl: number | null = null): Promise { try { await db.set(key, value, ttl); return true; @@ -183,7 +219,7 @@ export async function setInDb(key, value, ttl = null) { } } -export async function deleteFromDb(key) { +export async function deleteFromDb(key: string): Promise { try { await db.delete(key); return true; diff --git a/src/utils/economy.js b/src/utils/economy.ts similarity index 80% rename from src/utils/economy.js rename to src/utils/economy.ts index c50e9ab1f3..e1139181e2 100644 --- a/src/utils/economy.js +++ b/src/utils/economy.ts @@ -7,21 +7,81 @@ 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'; +import type { NexoClient } from '../types/index.js'; + +/** Stored per-user economy record (`EconomyDataSchema`, passthrough). */ +export interface EconomyData { + wallet: number; + bank: number; + bankLevel: number; + xp: number; + level: number; + dailyStreak: number; + lastDaily: number; + lastWeekly: number; + lastWork: number; + lastCrime: number; + lastRob: number; + lastDeposit: number; + lastWithdraw: number; + inventory: Record; + upgrades?: Record; + cooldowns: Record; + leveledUp?: boolean; + [key: string]: unknown; +} + +export interface ShopItem { + id: string; + name: string; + emoji: string; + price: number; + description: string; + type: string; + workMultiplier?: number; + effect?: string; + value?: number; + use?: string; +} + +export interface BalanceChange { + wallet?: number; + bank?: number; + xp?: number; +} + +export interface CooldownStatus { + onCooldown: boolean; + remaining: number; + formatted: string; +} + +/** Economy settings read from BotConfig.economy (all optional, with fallbacks below). */ +interface EconomyConfig { + baseBankCapacity?: number; + bankCapacityPerLevel?: number; + dailyAmount?: number; + workMin?: number; + workMax?: number; + startingBalance?: number; + currency?: { name?: string }; + cooldowns?: Record; +} -const ECONOMY_CONFIG = BotConfig.economy || {}; +const ECONOMY_CONFIG: EconomyConfig = BotConfig.economy || {}; const BASE_BANK_CAPACITY = ECONOMY_CONFIG.baseBankCapacity || 10000; const BANK_CAPACITY_PER_LEVEL = ECONOMY_CONFIG.bankCapacityPerLevel || 5000; const DAILY_AMOUNT = ECONOMY_CONFIG.dailyAmount || 100; const WORK_MIN = ECONOMY_CONFIG.workMin || 10; const WORK_MAX = ECONOMY_CONFIG.workMax || 100; -const COOLDOWNS = ECONOMY_CONFIG.cooldowns || { +const COOLDOWNS: Record = ECONOMY_CONFIG.cooldowns || { daily: 24 * 60 * 60 * 1000, work: 60 * 60 * 1000, crime: 2 * 60 * 60 * 1000, rob: 4 * 60 * 60 * 1000, }; -export function getEconomyKey(guildId, userId) { +export function getEconomyKey(guildId: string, userId: string): string { const validGuildId = validateDiscordId(guildId, 'guildId'); const validUserId = validateDiscordId(userId, 'userId'); @@ -32,7 +92,7 @@ export function getEconomyKey(guildId, userId) { return getEconomyStorageKey(validGuildId, validUserId); } -export function getMaxBankCapacity(userData) { +export function getMaxBankCapacity(userData: EconomyData | null | undefined): number { if (!userData) return BASE_BANK_CAPACITY; const bankLevel = userData.bankLevel || 0; @@ -51,12 +111,12 @@ export function getMaxBankCapacity(userData) { return capacity; } -export function formatCurrency(amount) { +export function formatCurrency(amount: number): string { const currencyName = ECONOMY_CONFIG.currency?.name || 'coins'; return `${amount.toLocaleString()} ${currencyName}`; } -export async function getEconomyData(client, guildId, userId) { +export async function getEconomyData(client: NexoClient, guildId: string, userId: string): Promise { try { if (!client.db || typeof client.db.get !== 'function') { throw new Error('Database not available'); @@ -69,14 +129,14 @@ export async function getEconomyData(client, guildId, userId) { wallet: ECONOMY_CONFIG.startingBalance ?? DEFAULT_ECONOMY_DATA.wallet, }; - return normalizeEconomyData(data, defaults); + return normalizeEconomyData(data, defaults) as EconomyData; } catch (error) { logger.error(`Error getting economy data for user ${userId}`, error); - return normalizeEconomyData({}, DEFAULT_ECONOMY_DATA); + return normalizeEconomyData({}, DEFAULT_ECONOMY_DATA) as EconomyData; } } -export async function setEconomyData(client, guildId, userId, data) { +export async function setEconomyData(client: NexoClient, guildId: string, userId: string, data: EconomyData): Promise { try { if (!client.db || typeof client.db.set !== 'function') { throw new Error('Database not available'); @@ -92,7 +152,12 @@ export async function setEconomyData(client, guildId, userId, data) { } } -export async function updateBalance(client, guildId, userId, options = {}) { +export async function updateBalance( + client: NexoClient, + guildId: string, + userId: string, + options: BalanceChange = {} +): Promise { const data = await getEconomyData(client, guildId, userId); if (options.wallet !== undefined) { @@ -119,9 +184,9 @@ export async function updateBalance(client, guildId, userId, options = {}) { return data; } -export function checkCooldown(userData, action) { +export function checkCooldown(userData: EconomyData, action: string): CooldownStatus { const cooldownTime = COOLDOWNS[action] || 0; - const lastUsed = userData[`last${action.charAt(0).toUpperCase() + action.slice(1)}`] || 0; + const lastUsed = (userData[`last${action.charAt(0).toUpperCase() + action.slice(1)}`] as number | undefined) || 0; const now = Date.now(); const remaining = Math.max(0, (lastUsed + cooldownTime) - now); @@ -132,7 +197,7 @@ export function checkCooldown(userData, action) { }; } -function formatCooldown(ms) { +function formatCooldown(ms: number): string { if (ms < 1000) return 'now'; const seconds = Math.floor(ms / 1000); @@ -207,7 +272,7 @@ export function getCrimeOutcome() { return outcomes[Math.floor(Math.random() * outcomes.length)]; } -export function getRobOutcome(targetBalance) { +export function getRobOutcome(targetBalance: number) { if (targetBalance <= 0) { return { success: false, @@ -241,11 +306,11 @@ Math.floor(Math.random() * (targetBalance * 0.3)) + 1, } } -export function formatShopItem(item, index) { +export function formatShopItem(item: Pick, index: number): string { return `**${index + 1}.** ${item.emoji} **${item.name}** - ${formatCurrency(item.price)}\n${item.description}\n`; } -export const addMoney = wrapServiceBoundary(async function addMoney(client, guildId, userId, amount, type = 'wallet') { +export const addMoney = wrapServiceBoundary(async function addMoney(client: NexoClient, guildId: string, userId: string, amount: number, type = 'wallet') { const validAmount = validateNumber(amount, 'amount'); if (validAmount === null || validAmount <= 0) { throw createError( @@ -294,7 +359,7 @@ export const addMoney = wrapServiceBoundary(async function addMoney(client, guil userMessage: 'Failed to add money. Please try again.', }); -export const removeMoney = wrapServiceBoundary(async function removeMoney(client, guildId, userId, amount, type = 'wallet') { +export const removeMoney = wrapServiceBoundary(async function removeMoney(client: NexoClient, guildId: string, userId: string, amount: number, type = 'wallet') { const validAmount = validateNumber(amount, 'amount'); if (validAmount === null || validAmount <= 0) { throw createError( @@ -349,7 +414,7 @@ export const removeMoney = wrapServiceBoundary(async function removeMoney(client userMessage: 'Failed to remove money. Please try again.', }); -export function getShopInventory() { +export function getShopInventory(): ShopItem[] { return [ { id: 'fishing_rod', diff --git a/src/utils/embeds.js b/src/utils/embeds.ts similarity index 67% rename from src/utils/embeds.js rename to src/utils/embeds.ts index 42012ce86a..128818ce0f 100644 --- a/src/utils/embeds.js +++ b/src/utils/embeds.ts @@ -1,13 +1,22 @@ // embeds.js import { EmbedBuilder } from 'discord.js'; +import type { APIEmbedField, EmbedAuthorOptions, EmbedFooterOptions, RestOrArray, User } from 'discord.js'; import { getColor, botConfig } from '../config/bot.js'; const EMOJI_REGEX = /[\p{Extended_Pictographic}\uFE0F]/gu; const EMBED_FOOTER_SYMBOL = Symbol('nexobotFooterText'); const EMBED_BASE_DESCRIPTION_SYMBOL = Symbol('nexobotBaseDescription'); -function sanitizeEmbedText(text = '') { +/** EmbedBuilder carrying the private bookkeeping set by the patched setters below. */ +type TaggedEmbedBuilder = EmbedBuilder & { + [EMBED_FOOTER_SYMBOL]?: string; + [EMBED_BASE_DESCRIPTION_SYMBOL]?: string; +}; + +function sanitizeEmbedText(text?: string): string; +function sanitizeEmbedText(text: T): T | string; +function sanitizeEmbedText(text: unknown = ''): unknown { if (typeof text !== 'string') { return text; } @@ -21,7 +30,7 @@ function sanitizeEmbedText(text = '') { .trim(); } -function sanitizeEmbedField(field) { +function sanitizeEmbedField(field: APIEmbedField): APIEmbedField { if (!field || typeof field !== 'object') { return field; } @@ -33,15 +42,20 @@ function sanitizeEmbedField(field) { }; } +// The patched setters also accept the legacy string forms the JS callers used. +// Method syntax keeps the parameter check bivariant so the originals can be stored under the wider type. +type LegacySetter = { set(this: EmbedBuilder, value: T): EmbedBuilder }['set']; +type FooterInput = EmbedFooterOptions | string | null | undefined; + const originalSetTitle = EmbedBuilder.prototype.setTitle; -const originalSetAuthor = EmbedBuilder.prototype.setAuthor; +const originalSetAuthor: LegacySetter = EmbedBuilder.prototype.setAuthor; const originalAddFields = EmbedBuilder.prototype.addFields; -EmbedBuilder.prototype.setTitle = function setSanitizedTitle(title) { +EmbedBuilder.prototype.setTitle = function setSanitizedTitle(this: EmbedBuilder, title: string | null) { return originalSetTitle.call(this, sanitizeEmbedText(title)); }; -EmbedBuilder.prototype.setAuthor = function setSanitizedAuthor(author) { +EmbedBuilder.prototype.setAuthor = function setSanitizedAuthor(this: EmbedBuilder, author: EmbedAuthorOptions | string | null) { if (typeof author === 'string') { return originalSetAuthor.call(this, sanitizeEmbedText(author)); } @@ -56,13 +70,13 @@ EmbedBuilder.prototype.setAuthor = function setSanitizedAuthor(author) { return originalSetAuthor.call(this, author); }; -EmbedBuilder.prototype.addFields = function addSanitizedFields(...fields) { +EmbedBuilder.prototype.addFields = function addSanitizedFields(this: EmbedBuilder, ...fields: RestOrArray) { const normalized = fields.flatMap((field) => (Array.isArray(field) ? field : [field])); const sanitized = normalized.map(sanitizeEmbedField); return originalAddFields.call(this, sanitized); }; -function normalizeFooterText(footer) { +function normalizeFooterText(footer: FooterInput): string { if (!footer) { return ''; } @@ -78,7 +92,7 @@ function normalizeFooterText(footer) { return ''; } -function isImportantFooter(footerText) { +function isImportantFooter(footerText: string): boolean { if (!footerText) { return false; } @@ -88,16 +102,16 @@ function isImportantFooter(footerText) { } const originalSetDescription = EmbedBuilder.prototype.setDescription; -const originalSetFooter = EmbedBuilder.prototype.setFooter; +const originalSetFooter: LegacySetter = EmbedBuilder.prototype.setFooter; const originalSetTimestamp = EmbedBuilder.prototype.setTimestamp; -EmbedBuilder.prototype.setDescription = function(description = '') { +EmbedBuilder.prototype.setDescription = function(this: TaggedEmbedBuilder, description: string | null = '') { const descString = sanitizeEmbedText(description || ''); this[EMBED_BASE_DESCRIPTION_SYMBOL] = descString; return originalSetDescription.call(this, descString); }; -EmbedBuilder.prototype.setFooter = function(footer) { +EmbedBuilder.prototype.setFooter = function(this: TaggedEmbedBuilder, footer: FooterInput) { const footerText = sanitizeEmbedText(normalizeFooterText(footer)); if (!footerText || !isImportantFooter(footerText)) { return this; @@ -107,10 +121,23 @@ EmbedBuilder.prototype.setFooter = function(footer) { return originalSetFooter.call(this, { text: footerText }); }; -EmbedBuilder.prototype.setTimestamp = function() { +EmbedBuilder.prototype.setTimestamp = function(this: EmbedBuilder) { return this; }; +export interface CreateEmbedOptions { + title?: string | null; + description?: string | null; + color?: string | number; + fields?: APIEmbedField[]; + author?: string | EmbedAuthorOptions | null; + footer?: string | EmbedFooterOptions | null; + thumbnail?: string | { url: string } | null; + image?: string | { url: string } | null; + timestamp?: boolean | Date; + url?: string | null; +} + export function createEmbed({ title = '', description = '', @@ -122,7 +149,7 @@ export function createEmbed({ image = null, timestamp = false, url = null -} = {}) { +}: CreateEmbedOptions = {}): EmbedBuilder { const embed = new EmbedBuilder(); if (title && typeof title === 'string' && title.length > 0) { @@ -151,7 +178,7 @@ export function createEmbed({ try { if (typeof author === 'string' && author.length > 0) { embed.setAuthor({ name: author.substring(0, 256) }); - } else if (author && typeof author.name === 'string') { + } else if (author && typeof author !== 'string' && typeof author.name === 'string') { embed.setAuthor(author); } } catch (error) { @@ -169,7 +196,7 @@ export function createEmbed({ try { if (typeof footer === 'string' && footer.length > 0) { embed.setFooter({ text: footer.substring(0, 2048) }); - } else if (footer && typeof footer.text === 'string') { + } else if (footer && typeof footer !== 'string' && typeof footer.text === 'string') { embed.setFooter(footer); } } catch (error) { @@ -187,7 +214,7 @@ export function createEmbed({ try { if (typeof thumbnail === 'string' && thumbnail.length > 0) { embed.setThumbnail(thumbnail); - } else if (thumbnail && typeof thumbnail.url === 'string') { + } else if (thumbnail && typeof thumbnail !== 'string' && typeof thumbnail.url === 'string') { embed.setThumbnail(thumbnail.url); } } catch (error) { @@ -201,7 +228,7 @@ export function createEmbed({ try { if (typeof image === 'string' && image.length > 0) { embed.setImage(image); - } else if (image && typeof image.url === 'string') { + } else if (image && typeof image !== 'string' && typeof image.url === 'string') { embed.setImage(image.url); } } catch (error) { @@ -226,7 +253,7 @@ export function createEmbed({ return embed; } -const NOTIFICATION_DEFAULT_TITLES = { +const NOTIFICATION_DEFAULT_TITLES: Record = { success: 'Success', error: 'Error', info: 'Information', @@ -234,7 +261,7 @@ const NOTIFICATION_DEFAULT_TITLES = { primary: 'Notice', }; -export const USER_ERROR_TITLES = { +export const USER_ERROR_TITLES: Record = { validation: 'Invalid Input', permission: 'Permission Denied', configuration: 'Configuration Error', @@ -246,7 +273,7 @@ export const USER_ERROR_TITLES = { unknown: 'Something Went Wrong', }; -const USER_ERROR_COLORS = { +const USER_ERROR_COLORS: Record = { rate_limit: 'warning', }; @@ -256,7 +283,7 @@ const USER_ERROR_COLORS = { * @param {string} [description] - Specific, actionable message for the user * @param {{ titleOverride?: string }} [options] */ -export function buildUserErrorEmbed(errorType, description = '', options = {}) { +export function buildUserErrorEmbed(errorType: string | null | undefined, description: string | null = '', options: { titleOverride?: string } = {}): EmbedBuilder { const type = errorType || 'unknown'; const title = options.titleOverride || USER_ERROR_TITLES[type] || USER_ERROR_TITLES.unknown; const color = USER_ERROR_COLORS[type] || 'error'; @@ -269,11 +296,11 @@ export function buildUserErrorEmbed(errorType, description = '', options = {}) { }); } -function containsDiscordRenderable(content = '') { +function containsDiscordRenderable(content: unknown = ''): boolean { return /<@!?&?\d+>|<#\d+>|\b\d{17,19}\b/.test(String(content)); } -function buildNotificationEmbed(title, body = '', color = 'primary') { +function buildNotificationEmbed(title: string | null | undefined, body: string | null | undefined = '', color: string = 'primary'): EmbedBuilder { const defaultTitle = NOTIFICATION_DEFAULT_TITLES[color] || NOTIFICATION_DEFAULT_TITLES.primary; let titleText = String(title || '').trim(); let bodyText = body ? String(body).trim() : ''; @@ -293,12 +320,12 @@ function buildNotificationEmbed(title, body = '', color = 'primary') { /** * @deprecated Prefer buildUserErrorEmbed or replyUserError from errorHandler.js. */ -export function errorEmbed(title, detail = null, options = {}) { +export function errorEmbed(title?: string | null, detail: unknown = null, options: { showDetails?: boolean } = {}): EmbedBuilder { const { showDetails = process.env.NODE_ENV !== 'production' } = options; let body = detail; if (detail && showDetails && typeof detail !== 'string') { - const detailText = detail.message || String(detail); + const detailText = (detail as { message?: unknown }).message || String(detail); body = formatCodeBlock(detailText); } @@ -309,7 +336,7 @@ export function errorEmbed(title, detail = null, options = {}) { } /** @param {string} titleOrBody - With one arg: body text. With two args: title and body. */ -export function successEmbed(title, body = '') { +export function successEmbed(title?: string | null, body: string | null = ''): EmbedBuilder { if (arguments.length === 1) { return buildNotificationEmbed('Success', title, 'success'); } @@ -318,7 +345,7 @@ export function successEmbed(title, body = '') { } /** @param {string} titleOrBody - With one arg: body text. With two args: title and body. */ -export function infoEmbed(title, body = '') { +export function infoEmbed(title?: string | null, body: string | null = ''): EmbedBuilder { if (arguments.length === 1) { return buildNotificationEmbed('Information', title, 'info'); } @@ -327,7 +354,7 @@ export function infoEmbed(title, body = '') { } /** @param {string} titleOrBody - With one arg: body text. With two args: title and body. */ -export function warningEmbed(title, body = '') { +export function warningEmbed(title?: string | null, body: string | null = ''): EmbedBuilder { if (arguments.length === 1) { return buildNotificationEmbed('Warning', title, 'warning'); } @@ -335,57 +362,57 @@ export function warningEmbed(title, body = '') { return buildNotificationEmbed(title || 'Warning', body, 'warning'); } -export function formatUser(user) { +export function formatUser(user: User): string { return `${user} (${user.tag} | ${user.id})`; } -export function formatDate(date) { +export function formatDate(date: Date): string { return ``; } -export function formatRelativeTime(date) { +export function formatRelativeTime(date: Date): string { return ``; } -export function formatCodeBlock(content, language = '') { +export function formatCodeBlock(content: unknown, language: string = ''): string { return `\`\`\`${language}\n${content}\n\`\`\``; } -export function formatInlineCode(content) { +export function formatInlineCode(content: unknown): string { return `\`${content}\``; } -export function formatBold(content) { +export function formatBold(content: unknown): string { return `**${content}**`; } -export function formatItalic(content) { +export function formatItalic(content: unknown): string { return `*${content}*`; } -export function formatUnderline(content) { +export function formatUnderline(content: unknown): string { return `__${content}__`; } -export function formatStrikethrough(content) { +export function formatStrikethrough(content: unknown): string { return `~~${content}~~`; } -export function formatSpoiler(content) { +export function formatSpoiler(content: unknown): string { return `||${content}||`; } -export function formatQuote(content) { +export function formatQuote(content: unknown): string { return `> ${content}`; } -export function formatList(items, ordered = false) { +export function formatList(items: unknown[], ordered: boolean = false): string { return items .map((item, index) => (ordered ? `${index + 1}.` : '•') + `${item}`) .join('\n'); } -export function formatDuration(ms) { +export function formatDuration(ms: number): string { if (ms < 0) return '0s'; const seconds = Math.floor(ms / 1000) % 60; @@ -393,7 +420,7 @@ export function formatDuration(ms) { const hours = Math.floor(ms / (1000 * 60 * 60)) % 24; const days = Math.floor(ms / (1000 * 60 * 60 * 24)); - const parts = []; + const parts: string[] = []; if (days > 0) parts.push(`${days}d`); if (hours > 0) parts.push(`${hours}h`); if (minutes > 0) parts.push(`${minutes}m`); @@ -402,7 +429,7 @@ export function formatDuration(ms) { return parts.join(''); } -export function formatProgressBar(current, max, size = 10) { +export function formatProgressBar(current: number, max: number, size: number = 10): string { const progress = Math.min(Math.max(0, current / max), 1); const filled = Math.round(size * progress); const empty = size - filled; diff --git a/src/utils/errorHandler.js b/src/utils/errorHandler.ts similarity index 77% rename from src/utils/errorHandler.js rename to src/utils/errorHandler.ts index 4d13c3bf86..dc62a87e2e 100644 --- a/src/utils/errorHandler.js +++ b/src/utils/errorHandler.ts @@ -19,8 +19,10 @@ import { logger } from './logger.js'; import { buildUserErrorEmbed } from './embeds.js'; import { MessageFlags } from 'discord.js'; +import type { EmbedBuilder, InteractionReplyOptions } from 'discord.js'; import { getErrorMetadata, getDefaultErrorCodeByType, resolveErrorCode, ErrorCodes } from './errorRegistry.js'; import { InteractionHelper } from './interactionHelper.js'; +import type { NexoInteraction } from './interactionHelper.js'; // Re-export so consumers only ever need to import from errorHandler.js export { ErrorCodes, getErrorMetadata, resolveErrorCode, getDefaultErrorCodeByType } from './errorRegistry.js'; @@ -36,10 +38,41 @@ export const ErrorTypes = { USER_INPUT: 'user_input', RATE_LIMIT: 'rate_limit', UNKNOWN: 'unknown' +} as const; + +export type ErrorType = typeof ErrorTypes[keyof typeof ErrorTypes]; + +/** Structured context attached to errors and log lines. Unknown keys are passed through to the log. */ +export interface ErrorContext { + errorCode?: string; + expected?: boolean; + suppressErrorLog?: boolean; + source?: string; + traceId?: string | null; + subtype?: string | null; + command?: string | null; + ephemeral?: boolean; + [key: string]: unknown; +} + +/** Loose view of a caught value; used to mirror the property reads done on thrown errors. */ +export type ErrorLike = Error & { + code?: string | number; + userMessage?: string | null; + context?: ErrorContext; }; +/** Interaction view for logging: commandName/customId exist only on some interaction kinds. */ +type LoggableInteraction = NexoInteraction & { commandName?: string; customId?: string }; + export class NexoBotError extends Error { - constructor(message, type = ErrorTypes.UNKNOWN, userMessage = null, context = {}) { + type: ErrorType; + userMessage: string | null; + context: ErrorContext; + code: string; + timestamp: string; + + constructor(message: string, type: ErrorType = ErrorTypes.UNKNOWN, userMessage: string | null = null, context: ErrorContext = {}) { super(message); this.name = 'NexoBotError'; this.type = type; @@ -51,7 +84,7 @@ export class NexoBotError extends Error { } // Discord API error codes that indicate a permission problem rather than a bug. -const DISCORD_PERMISSION_CODES = new Set([ +const DISCORD_PERMISSION_CODES: ReadonlySet = new Set([ 50001, // Missing Access 50013, // Missing Permissions 50007, // Cannot send messages to this user (DMs closed) @@ -59,7 +92,7 @@ const DISCORD_PERMISSION_CODES = new Set([ ]); // PostgreSQL / node-postgres error codes and errno values that indicate database trouble. -const DATABASE_ERROR_CODES = new Set([ +const DATABASE_ERROR_CODES: ReadonlySet = new Set([ 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', '57014', // query_canceled (statement timeout) '53300', // too_many_connections @@ -97,12 +130,13 @@ const USER_FACING_ERROR_HINTS = [ /^could not find\b/i, ]; -function isUserFacingPlainError(error) { +function isUserFacingPlainError(error: unknown): error is Error { if (error instanceof NexoBotError) { return false; } - const message = typeof error?.message === 'string' ? error.message.trim() : ''; + const err = error as ErrorLike | null | undefined; + const message = typeof err?.message === 'string' ? err.message.trim() : ''; if (!message || message.length > 500 || message.includes('\n')) { return false; } @@ -131,7 +165,7 @@ function isUserFacingPlainError(error) { return false; } -function inferErrorTypeFromUserMessage(message = '') { +function inferErrorTypeFromUserMessage(message: string = ''): ErrorType { const lowerMessage = message.toLowerCase(); if (lowerMessage.includes('permission') || lowerMessage.includes('not allowed')) { @@ -162,7 +196,7 @@ function inferErrorTypeFromUserMessage(message = '') { return ErrorTypes.VALIDATION; } -function normalizeInteractionError(error, context = {}) { +function normalizeInteractionError(error: unknown, context: ErrorContext = {}): unknown { if (error instanceof NexoBotError) { return error; } @@ -181,13 +215,14 @@ function normalizeInteractionError(error, context = {}) { }); } -export function categorizeError(error) { +export function categorizeError(error: unknown): ErrorType { if (error instanceof NexoBotError) { return error.type; } - const message = error?.message?.toLowerCase() || ''; - const code = error?.code; + const err = error as ErrorLike | null | undefined; + const message = err?.message?.toLowerCase() || ''; + const code = err?.code; if (typeof code === 'string' && DATABASE_ERROR_CODES.has(code)) { return ErrorTypes.DATABASE; @@ -206,7 +241,7 @@ export function categorizeError(error) { return ErrorTypes.DISCORD_API; } - if (error?.name === 'AbortError' || message.includes('network') || message.includes('fetch failed') || message.includes('enotconn')) { + if (err?.name === 'AbortError' || message.includes('network') || message.includes('fetch failed') || message.includes('enotconn')) { return ErrorTypes.NETWORK; } @@ -230,14 +265,14 @@ export function categorizeError(error) { return inferErrorTypeFromUserMessage(error.message); } - if (error?.name === 'DiscordAPIError' || error?.name === 'HTTPError') { + if (err?.name === 'DiscordAPIError' || err?.name === 'HTTPError') { return ErrorTypes.DISCORD_API; } return ErrorTypes.UNKNOWN; } -const UserMessages = { +const UserMessages: Record> = { [ErrorTypes.VALIDATION]: { default: 'Please check your input and try again.', missing_required: "You're missing some required information. Check the command options and try again.", @@ -293,12 +328,13 @@ const UserMessages = { } }; -export function getUserMessage(error, context = {}) { +export function getUserMessage(error: unknown, context: ErrorContext = {}): string { const type = categorizeError(error); const messages = UserMessages[type] || UserMessages[ErrorTypes.UNKNOWN]; + const err = error as ErrorLike; - if (error.userMessage) { - return error.userMessage; + if (err.userMessage) { + return err.userMessage; } if (isUserFacingPlainError(error)) { @@ -306,13 +342,13 @@ export function getUserMessage(error, context = {}) { } if (type === ErrorTypes.DISCORD_API) { - if (error?.code === 10062) { + if (err?.code === 10062) { return messages.interaction_expired; } - if (error?.code === 40060 || error?.code === 50027) { + if (err?.code === 40060 || err?.code === 50027) { return messages.already_acknowledged; } - if (error?.code === 50035) { + if (err?.code === 50035) { return messages.invalid_form_body; } } @@ -328,10 +364,11 @@ export function getUserMessage(error, context = {}) { return messages.default; } -function buildErrorLogData(interaction, error, errorType, context = {}) { +function buildErrorLogData(interaction: LoggableInteraction | null | undefined, error: unknown, errorType: string, context: ErrorContext = {}) { + const err = error as ErrorLike; const resolvedErrorCode = resolveErrorCode({ error, errorType, context }); const errorMetadata = getErrorMetadata(resolvedErrorCode); - const traceId = context.traceId || interaction?.traceContext?.traceId || interaction?.traceId || error?.context?.traceId; + const traceId = context.traceId || interaction?.traceContext?.traceId || interaction?.traceId || err?.context?.traceId; return { logData: { @@ -340,7 +377,7 @@ function buildErrorLogData(interaction, error, errorType, context = {}) { remediationHint: errorMetadata.remediation, severity: errorMetadata.severity, retryable: errorMetadata.retryable, - error: error.message, + error: err.message, type: errorType, traceId, guildId: interaction?.guildId, @@ -362,23 +399,24 @@ function buildErrorLogData(interaction, error, errorType, context = {}) { }; } -function logInteractionError(error, errorType, logData) { +function logInteractionError(error: unknown, errorType: string, logData: object): void { + const err = error as ErrorLike; const isUserError = USER_ERROR_TYPES.has(errorType); - const isExpectedError = Boolean(error?.context?.expected === true || error?.context?.suppressErrorLog === true); + const isExpectedError = Boolean(err?.context?.expected === true || err?.context?.suppressErrorLog === true); if (isUserError || isExpectedError) { if (errorType !== ErrorTypes.RATE_LIMIT) { - logger.debug(`User Error [${errorType.toUpperCase()}]: ${error.userMessage || error.message}`, logData); + logger.debug(`User Error [${errorType.toUpperCase()}]: ${err.userMessage || err.message}`, logData); } } else { logger.error(`System Error [${errorType.toUpperCase()}]`, { ...logData, - stack: error.stack + stack: err.stack }); } } -async function sendErrorResponse(interaction, embed, context = {}) { +async function sendErrorResponse(interaction: LoggableInteraction | null | undefined, embed: EmbedBuilder, context: ErrorContext = {}): Promise { try { if (!interaction || !interaction.id) { logger.warn('Interaction was null or invalid when handling error', { @@ -427,22 +465,24 @@ async function sendErrorResponse(interaction, embed, context = {}) { } else if (interaction.deferred) { await interaction.editReply(errorMessage); } else { + const replyMessage: InteractionReplyOptions = errorMessage; if (useEphemeral) { - errorMessage.flags = MessageFlags.Ephemeral; + replyMessage.flags = MessageFlags.Ephemeral; } - await interaction.reply(errorMessage); + await interaction.reply(replyMessage); } return true; - } catch (replyError) { + } catch (caught) { + const replyError = caught as ErrorLike; if (replyError.code === 40060 || replyError.code === 10062 || replyError.code === 50027) { logger.warn('Interaction already acknowledged, expired, or token invalid; cannot send error response:', { event: 'interaction.error.response_unavailable', errorCode: String(replyError.code), traceId: context.traceId, - guildId: interaction.guildId, - userId: interaction.user?.id, - command: interaction.commandName || context.command, + guildId: interaction!.guildId, + userId: interaction!.user?.id, + command: interaction!.commandName || context.command, code: replyError.code }); return false; @@ -453,9 +493,9 @@ async function sendErrorResponse(interaction, embed, context = {}) { errorCode: String(replyError.code || ErrorCodes.INTERACTION_RESPONSE_FAILED), remediationHint: getErrorMetadata(ErrorCodes.INTERACTION_RESPONSE_FAILED).remediation, traceId: context.traceId, - guildId: interaction.guildId, - userId: interaction.user?.id, - command: interaction.commandName || context.command, + guildId: interaction!.guildId, + userId: interaction!.user?.id, + command: interaction!.commandName || context.command, error: replyError }); return false; @@ -465,13 +505,21 @@ async function sendErrorResponse(interaction, embed, context = {}) { /** * Reply with a typed user-facing error (early-return validation, permission checks, etc.). */ -export async function replyUserError(interaction, { +export interface UserErrorOptions { + type?: ErrorType; + message?: string | null; + subtype?: string | null; + ephemeral?: boolean; + context?: ErrorContext; +} + +export async function replyUserError(interaction: NexoInteraction | null | undefined, { type = ErrorTypes.UNKNOWN, message, subtype = null, ephemeral = true, context = {} -} = {}) { +}: UserErrorOptions = {}): Promise { const errorType = type || ErrorTypes.UNKNOWN; const syntheticError = message ? createError('User error', errorType, message, { expected: true, ...context }) @@ -490,7 +538,7 @@ export async function replyUserError(interaction, { return sendErrorResponse(interaction, embed, { ...context, traceId, ephemeral, subtype }); } -const USER_ERROR_TYPES = new Set([ +const USER_ERROR_TYPES: ReadonlySet = new Set([ ErrorTypes.VALIDATION, ErrorTypes.RATE_LIMIT, ErrorTypes.USER_INPUT, @@ -498,12 +546,12 @@ const USER_ERROR_TYPES = new Set([ ErrorTypes.CONFIGURATION, ]); -function buildErrorReference(resolvedErrorCode, traceId) { +function buildErrorReference(resolvedErrorCode: string, traceId: unknown): string { const shortTrace = traceId ? String(traceId).slice(0, 8) : null; return shortTrace ? `${resolvedErrorCode} · ${shortTrace}` : resolvedErrorCode; } -export async function handleInteractionError(interaction, error, context = {}) { +export async function handleInteractionError(interaction: NexoInteraction | null | undefined, error: unknown, context: ErrorContext = {}): Promise { const normalizedError = normalizeInteractionError(error, context); const errorType = categorizeError(normalizedError); const userMessage = getUserMessage(normalizedError, context); @@ -512,7 +560,7 @@ export async function handleInteractionError(interaction, error, context = {}) { logInteractionError(normalizedError, errorType, logData); // System errors get a reference code so users can report them and we can grep logs. - const isUserError = USER_ERROR_TYPES.has(errorType) || normalizedError?.context?.expected === true; + const isUserError = USER_ERROR_TYPES.has(errorType) || (normalizedError as ErrorLike | null | undefined)?.context?.expected === true; const description = isUserError ? userMessage : `${userMessage}\n\n-# Ref: \`${buildErrorReference(resolvedErrorCode, traceId)}\``; @@ -525,7 +573,8 @@ export async function handleInteractionError(interaction, error, context = {}) { * 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 = {}) { +export function handleTaskError(taskName: string, error: unknown, context: ErrorContext = {}): void { + const err = error as ErrorLike | null | undefined; const errorType = categorizeError(error); const resolvedErrorCode = resolveErrorCode({ error, errorType, context }); const errorMetadata = getErrorMetadata(resolvedErrorCode); @@ -538,8 +587,8 @@ export function handleTaskError(taskName, error, context = {}) { severity: errorMetadata.severity, retryable: errorMetadata.retryable, type: errorType, - error: error?.message || String(error), - stack: error?.stack, + error: err?.message || String(error), + stack: err?.stack, context }); } @@ -548,8 +597,8 @@ export function handleTaskError(taskName, error, 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) => { +export function runSafeTask(taskName: string, fn: (...args: A) => R, context: ErrorContext = {}): (...args: A) => Promise | null> { + return async (...args: A): Promise | null> => { try { return await fn(...args); } catch (error) { @@ -559,15 +608,25 @@ export function runSafeTask(taskName, fn, context = {}) { }; } -export function withErrorHandling(fn, context = {}) { - return async (...args) => { +type InteractionProbe = { + isCommand?: unknown; + isButton?: unknown; + isModalSubmit?: unknown; + isStringSelectMenu?: unknown; + isChatInputCommand?: unknown; + _isPrefixCommand?: unknown; +}; + +export function withErrorHandling(fn: (...args: A) => R, context: ErrorContext = {}): (...args: A) => Promise | null> { + return async (...args: A): Promise | null> => { try { return await fn(...args); } catch (error) { const interaction = args.find((arg) => arg && typeof arg === 'object' && - (arg.isCommand || arg.isButton || arg.isModalSubmit || arg.isStringSelectMenu || arg.isChatInputCommand || arg._isPrefixCommand) - ); + ((arg as InteractionProbe).isCommand || (arg as InteractionProbe).isButton || (arg as InteractionProbe).isModalSubmit + || (arg as InteractionProbe).isStringSelectMenu || (arg as InteractionProbe).isChatInputCommand || (arg as InteractionProbe)._isPrefixCommand) + ) as NexoInteraction | undefined; // Slash commands are handled by interactionCreate — re-throw so the // central handler can attach trace context and command subtypes. @@ -586,8 +645,8 @@ export function withErrorHandling(fn, context = {}) { }; } -export function createError(message, type = ErrorTypes.UNKNOWN, userMessage = null, context = {}) { - const normalizedContext = { +export function createError(message: string, type: ErrorType = ErrorTypes.UNKNOWN, userMessage: string | null = null, context: ErrorContext = {}): NexoBotError { + const normalizedContext: ErrorContext = { ...context, errorCode: context?.errorCode || getDefaultErrorCodeByType(type) }; diff --git a/src/utils/errorRegistry.js b/src/utils/errorRegistry.ts similarity index 80% rename from src/utils/errorRegistry.js rename to src/utils/errorRegistry.ts index 51f881cf30..bc42b4ce37 100644 --- a/src/utils/errorRegistry.js +++ b/src/utils/errorRegistry.ts @@ -16,9 +16,17 @@ const ErrorCodes = Object.freeze({ TASK_ERROR: 'TASK_ERROR', UNHANDLED_REJECTION: 'UNHANDLED_REJECTION', UNKNOWN_ERROR: 'UNKNOWN_ERROR' -}); +} as const); + +export type ErrorCode = typeof ErrorCodes[keyof typeof ErrorCodes]; + +export interface ErrorMetadata { + severity: 'low' | 'medium' | 'high'; + retryable: boolean; + remediation: string; +} -const ErrorCodeRegistry = Object.freeze({ +const ErrorCodeRegistry: Readonly> = Object.freeze>({ [ErrorCodes.VALIDATION_FAILED]: { severity: 'low', retryable: false, @@ -96,7 +104,7 @@ const ErrorCodeRegistry = Object.freeze({ } }); -const TypeToErrorCode = Object.freeze({ +const TypeToErrorCode: Readonly> = Object.freeze({ validation: ErrorCodes.VALIDATION_FAILED, permission: ErrorCodes.PERMISSION_DENIED, configuration: ErrorCodes.CONFIGURATION_ERROR, @@ -108,7 +116,7 @@ const TypeToErrorCode = Object.freeze({ unknown: ErrorCodes.UNKNOWN_ERROR }); -function normalizeErrorCode(errorCode) { +function normalizeErrorCode(errorCode: unknown): string | null { if (errorCode === null || errorCode === undefined) { return null; } @@ -116,7 +124,7 @@ function normalizeErrorCode(errorCode) { return String(errorCode).trim().toUpperCase(); } -export function getErrorMetadata(errorCode) { +export function getErrorMetadata(errorCode: unknown): ErrorMetadata { const normalized = normalizeErrorCode(errorCode); if (!normalized) { return ErrorCodeRegistry[ErrorCodes.UNKNOWN_ERROR]; @@ -125,22 +133,32 @@ export function getErrorMetadata(errorCode) { return ErrorCodeRegistry[normalized] || ErrorCodeRegistry[ErrorCodes.UNKNOWN_ERROR]; } -export function getDefaultErrorCodeByType(errorType = 'unknown') { +export function getDefaultErrorCodeByType(errorType: string = 'unknown'): ErrorCode { return TypeToErrorCode[errorType] || ErrorCodes.UNKNOWN_ERROR; } -export function resolveErrorCode({ error, errorType = 'unknown', context = {} } = {}) { +export interface ResolveErrorCodeOptions { + error?: unknown; + errorType?: string; + context?: { errorCode?: unknown } | null; +} + +function readProperty(value: unknown, key: string): unknown { + return value !== null && typeof value === 'object' ? (value as Record)[key] : undefined; +} + +export function resolveErrorCode({ error, errorType = 'unknown', context = {} }: ResolveErrorCodeOptions = {}): string { const contextCode = normalizeErrorCode(context?.errorCode); if (contextCode) { return contextCode; } - const nestedContextCode = normalizeErrorCode(error?.context?.errorCode); + const nestedContextCode = normalizeErrorCode(readProperty(readProperty(error, 'context'), 'errorCode')); if (nestedContextCode) { return nestedContextCode; } - const code = normalizeErrorCode(error?.code); + const code = normalizeErrorCode(readProperty(error, 'code')); if (code) { if (/^\d+$/.test(code) && Number(code) >= 10000) { return ErrorCodes.DISCORD_API_ERROR; diff --git a/src/utils/giveaways.js b/src/utils/giveaways.ts similarity index 77% rename from src/utils/giveaways.js rename to src/utils/giveaways.ts index 1719a27b2a..e5d5686ad3 100644 --- a/src/utils/giveaways.js +++ b/src/utils/giveaways.ts @@ -9,13 +9,32 @@ import { createGiveawayButtons as createGiveawayButtonsService, selectWinners as selectWinnersService } from '../services/giveawayService.js'; +import type { NexoClient } from '../types/index.js'; + +/** Stored giveaway record (`guild:{guildId}:giveaways`, keyed by messageId). */ +export interface Giveaway { + messageId: string; + channelId?: string; + guildId?: string | null; + prize?: string; + hostId?: string; + endTime?: number; + endsAt?: number; + winnerCount?: number; + participants?: string[]; + winnerIds?: string[]; + isEnded?: boolean; + ended?: boolean; + createdAt?: string; + [key: string]: unknown; +} -export function giveawayKey(guildId) { +export function giveawayKey(guildId: string): string { return `guild:${guildId}:giveaways`; } -function arrayToGiveawayMap(giveaways) { - const map = {}; +function arrayToGiveawayMap(giveaways: Giveaway[]): Record { + const map: Record = {}; if (Array.isArray(giveaways)) { for (const giveaway of giveaways) { if (giveaway && giveaway.messageId) { @@ -26,7 +45,7 @@ function arrayToGiveawayMap(giveaways) { return map; } -export async function getGuildGiveaways(client, guildId) { +export async function getGuildGiveaways(client: NexoClient, guildId: string): Promise { try { if (!client.db) { logger.warn('Database not available for getGuildGiveaways'); @@ -35,19 +54,19 @@ export async function getGuildGiveaways(client, guildId) { const key = giveawayKey(guildId); const giveaways = await client.db.get(key, {}); - const unwrappedGiveaways = unwrapReplitData(giveaways); + const unwrappedGiveaways: unknown = unwrapReplitData(giveaways); if (typeof unwrappedGiveaways === 'object' && !Array.isArray(unwrappedGiveaways)) { - return Object.values(unwrappedGiveaways || {}); + return Object.values(unwrappedGiveaways || {}) as Giveaway[]; } - return Array.isArray(unwrappedGiveaways) ? unwrappedGiveaways : []; + return Array.isArray(unwrappedGiveaways) ? unwrappedGiveaways as Giveaway[] : []; } catch (error) { logger.error(`Error getting giveaways for guild ${guildId}:`, error); return []; } } -export async function saveGiveaway(client, guildId, giveawayData) { +export async function saveGiveaway(client: NexoClient, guildId: string, giveawayData: Giveaway): Promise { try { if (!client.db) { logger.warn('Database not available for saveGiveaway'); @@ -82,7 +101,7 @@ export async function saveGiveaway(client, guildId, giveawayData) { } } -export async function deleteGiveaway(client, guildId, messageId) { +export async function deleteGiveaway(client: NexoClient, guildId: string, messageId: string): Promise { try { if (!client.db) { logger.warn('Database not available for deleteGiveaway'); @@ -122,7 +141,7 @@ export async function deleteGiveaway(client, guildId, messageId) { } } -export function createGiveawayEmbed(giveaway, status, winners = []) { +export function createGiveawayEmbed(giveaway: Giveaway, status: string, winners: string[] = []) { try { return createGiveawayEmbedService(giveaway, status, winners); } catch (error) { @@ -131,13 +150,13 @@ export function createGiveawayEmbed(giveaway, status, winners = []) { } } -export function isGiveawayEnded(giveaway) { +export function isGiveawayEnded(giveaway: Giveaway | null | undefined): boolean { if (!giveaway) return true; const endTime = giveaway.endsAt || giveaway.endTime; - return Date.now() > endTime; + return Date.now() > endTime!; } -export function pickWinners(entrants, count) { +export function pickWinners(entrants: string[], count: number): string[] { try { return selectWinnersService(entrants, count); } catch (error) { @@ -154,7 +173,7 @@ export function pickWinners(entrants, count) { } } -export function giveawayEmbed(giveaway, status, winners = []) { +export function giveawayEmbed(giveaway: Giveaway, status: string, winners: string[] = []) { return createGiveawayEmbed(giveaway, status, winners); } @@ -164,7 +183,7 @@ export function giveawayButtons(ended = false) { } catch (error) { logger.error('Error creating giveaway buttons:', error); - const row = new ActionRowBuilder(); + const row = new ActionRowBuilder(); if (ended) { row.addComponents( new ButtonBuilder() diff --git a/src/utils/helpers.js b/src/utils/helpers.ts similarity index 54% rename from src/utils/helpers.js rename to src/utils/helpers.ts index 3ddad5aa74..5bec443875 100644 --- a/src/utils/helpers.js +++ b/src/utils/helpers.ts @@ -2,9 +2,22 @@ import { BotConfig } from "../config/bot.js"; -export function getPriorityMap() { - const priorities = BotConfig.tickets?.priorities || {}; - const map = {}; +export interface PriorityInfo { + name: string; + color: string; + emoji: string; + label: string; +} + +interface PriorityConfig { + emoji: string; + color: string; + label: string; +} + +export function getPriorityMap(): Record { + const priorities: Record = BotConfig.tickets?.priorities || {}; + const map: Record = {}; for (const [key, config] of Object.entries(priorities)) { map[key] = { @@ -21,6 +34,6 @@ export function getPriorityMap() { export const PRIORITY_MAP = getPriorityMap(); // Loaders accept compiled .js and source .ts (tsx dev mode), never declaration files. -export function isLoadableModuleFile(fileName) { +export function isLoadableModuleFile(fileName: string): boolean { return /\.(js|ts)$/.test(fileName) && !fileName.endsWith('.d.ts'); } diff --git a/src/utils/interactionHelper.js b/src/utils/interactionHelper.ts similarity index 66% rename from src/utils/interactionHelper.js rename to src/utils/interactionHelper.ts index 7f7a6d2cd9..f05652e587 100644 --- a/src/utils/interactionHelper.js +++ b/src/utils/interactionHelper.ts @@ -2,36 +2,89 @@ import { logger } from './logger.js'; import { MessageFlags } from 'discord.js'; +import type { + APIModalInteractionResponseCallbackData, + CommandInteraction, + EmbedBuilder, + InteractionDeferReplyOptions, + InteractionEditReplyOptions, + InteractionReplyOptions, + JSONEncodable, + Message, + MessageComponentInteraction, + ModalComponentData, + ModalSubmitInteraction, + RepliableInteraction, +} from 'discord.js'; import { handleInteractionError, createError, ErrorTypes } from './errorHandler.js'; +import type { ErrorLike } from './errorHandler.js'; import { ResponseCoordinator } from './responseCoordinator.js'; +import type { CoordinatedInteraction, PayloadResponder, ResponsePayload } from './responseCoordinator.js'; +import type { TraceContext } from './logger.js'; +import type { GuildConfig, NexoClient } from '../types/index.js'; + +/** Properties NexoBot attaches to interaction objects at runtime. */ +export interface InteractionExtras { + _isPrefixCommand?: boolean; + _responseCoordinator?: ResponseCoordinator; + _replyMessage?: Message | null; + __nexoResponsePatched?: boolean; + traceContext?: TraceContext; + traceId?: string; +} + +/** Every repliable discord.js interaction, including the abstract bases used in generic handlers. */ +export type AnyRepliableInteraction = RepliableInteraction | CommandInteraction | MessageComponentInteraction; + +/** Any interaction the helpers can answer (slash, context menu, button, select, modal, prefix mock). */ +export type NexoInteraction = AnyRepliableInteraction & InteractionExtras; + +/** Interactions that support showModal (everything repliable except modal submits). */ +export type ModalCapableInteraction = Exclude & InteractionExtras; + +export type ModalPayload = + | JSONEncodable + | ModalComponentData + | APIModalInteractionResponseCallbackData; + +/** An interaction whose reply methods get wrapped by patchInteractionResponses. */ +type PatchableInteraction = CoordinatedInteraction & { __nexoResponsePatched?: boolean }; + +export interface SafeExecuteOptions { + autoDefer?: boolean; + deferOptions?: InteractionDeferReplyOptions; +} const INTERACTION_TIMEOUT_MS = 15 * 60 * 1000; const DEFAULT_DEFER_OPTIONS = { flags: MessageFlags.Ephemeral }; -const INTERACTION_UNAVAILABLE_CODES = new Set([10062, 40060, 50027]); +const INTERACTION_UNAVAILABLE_CODES: ReadonlySet = new Set([10062, 40060, 50027]); -function isInteractionUnavailableError(error) { - return INTERACTION_UNAVAILABLE_CODES.has(error?.code); +function isInteractionUnavailableError(error: unknown): boolean { + return INTERACTION_UNAVAILABLE_CODES.has((error as ErrorLike | null | undefined)?.code); } -function sanitizeEditReplyOptions(options = {}) { +function sanitizeEditReplyOptions(options: ResponsePayload = {}): ResponsePayload { if (!options || typeof options !== 'object') { return options; } - const { flags, ephemeral, ...rest } = options; + // Object payloads are reply/edit option bags; read them through the reply shape. + const { flags, ephemeral, ...replyRest } = options as InteractionReplyOptions; + const rest: InteractionEditReplyOptions = replyRest; - if (flags && (flags & MessageFlags.IsComponentsV2)) { + // Number() mirrors the implicit numeric coercion of `&` for BitField objects. + if (flags && (Number(flags) & MessageFlags.IsComponentsV2)) { rest.flags = MessageFlags.IsComponentsV2; } return rest; } export class InteractionHelper { - static getCoordinator(interaction) { + static getCoordinator(interaction: CoordinatedInteraction | null | undefined): ResponseCoordinator | null { return interaction?._responseCoordinator || null; } - static patchInteractionResponses(interaction) { + static patchInteractionResponses(interaction: PatchableInteraction): void { if (!interaction || interaction.__nexoResponsePatched) { return; } @@ -44,7 +97,7 @@ export class InteractionHelper { return; } - interaction.reply = async (options) => { + interaction.reply = async (options: ResponsePayload) => { const coordinator = InteractionHelper.getCoordinator(interaction); if (coordinator?.isUsageFinalized()) { return coordinator.getReplyMessage(); @@ -73,7 +126,7 @@ export class InteractionHelper { interaction.__nexoResponsePatched = true; } - static isInteractionValid(interaction) { + static isInteractionValid(interaction: NexoInteraction | null | undefined): boolean { if (!interaction || typeof interaction !== 'object') return false; if (!interaction.id || typeof interaction.id !== 'string') return false; @@ -86,7 +139,7 @@ export class InteractionHelper { return true; } - static async ensureReady(interaction, deferOptions = { flags: MessageFlags.Ephemeral }) { + static async ensureReady(interaction: NexoInteraction, deferOptions: InteractionDeferReplyOptions = { flags: MessageFlags.Ephemeral }): Promise { if (!this.isInteractionValid(interaction)) { return false; } @@ -103,7 +156,7 @@ export class InteractionHelper { return await this.safeDefer(interaction, deferOptions); } - static async safeDefer(interaction, options = {}) { + static async safeDefer(interaction: NexoInteraction, options: InteractionDeferReplyOptions = {}): Promise { try { if (interaction.deferred || interaction.replied) { return true; @@ -125,7 +178,8 @@ export class InteractionHelper { await interaction.deferReply(options); return true; - } catch (error) { + } catch (caught) { + const error = caught as ErrorLike; if (isInteractionUnavailableError(error)) { logger.warn(`Interaction ${interaction.id} unavailable during defer:`, error.message); return false; @@ -140,7 +194,8 @@ export class InteractionHelper { } } - static async safeEditReply(interaction, options) { + static async safeEditReply(interaction: NexoInteraction, options: ResponsePayload): Promise { + const responder: PayloadResponder = interaction; try { const coordinator = this.getCoordinator(interaction); if (coordinator?.isUsageFinalized()) { @@ -162,9 +217,10 @@ export class InteractionHelper { return await this.safeReply(interaction, options); } - await interaction.editReply(sanitizeEditReplyOptions(options)); + await responder.editReply(sanitizeEditReplyOptions(options)); return true; - } catch (error) { + } catch (caught) { + const error = caught as ErrorLike; if (isInteractionUnavailableError(error)) { logger.warn(`Interaction ${interaction.id} unavailable during edit:`, error.message); return false; @@ -180,11 +236,11 @@ export class InteractionHelper { if (error.code === 10008) { logger.debug(`Interaction ${interaction.id} reply message deleted, using followUp fallback`); try { - await interaction.followUp(options); + await responder.followUp(options); return true; } catch (followUpError) { if (isInteractionUnavailableError(followUpError)) { - logger.warn(`Interaction ${interaction.id} unavailable during followUp:`, followUpError.message); + logger.warn(`Interaction ${interaction.id} unavailable during followUp:`, (followUpError as ErrorLike).message); return false; } logger.error('Failed to follow up after deleted reply:', followUpError); @@ -196,7 +252,8 @@ export class InteractionHelper { } } - static async safeReply(interaction, options) { + static async safeReply(interaction: NexoInteraction, options: ResponsePayload): Promise { + const responder: PayloadResponder = interaction; try { const coordinator = this.getCoordinator(interaction); if (coordinator?.isUsageFinalized()) { @@ -218,18 +275,19 @@ export class InteractionHelper { } if (interaction.deferred && !interaction.replied) { - await interaction.editReply(sanitizeEditReplyOptions(options)); + await responder.editReply(sanitizeEditReplyOptions(options)); return true; } if (interaction.replied) { - await interaction.followUp(options); + await responder.followUp(options); return true; } - await interaction.reply(options); + await responder.reply(options); return true; - } catch (error) { + } catch (caught) { + const error = caught as ErrorLike; if (isInteractionUnavailableError(error)) { logger.warn(`Interaction ${interaction.id} unavailable during reply:`, error.message); return false; @@ -243,7 +301,7 @@ export class InteractionHelper { } } - static async safeShowModal(interaction, modal) { + static async safeShowModal(interaction: ModalCapableInteraction, modal: ModalPayload): Promise { try { if (!this.isInteractionValid(interaction)) { logger.warn(`Interaction ${interaction.id} has expired before showModal, ignoring`); @@ -259,7 +317,7 @@ export class InteractionHelper { return true; } catch (error) { if (isInteractionUnavailableError(error)) { - logger.warn(`Interaction ${interaction.id} unavailable during showModal:`, error.message); + logger.warn(`Interaction ${interaction.id} unavailable during showModal:`, (error as ErrorLike).message); return false; } logger.error('Failed to show modal:', error); @@ -267,7 +325,12 @@ export class InteractionHelper { } } - static async safeExecute(interaction, commandFunction, errorEmbed, options = {}) { + static async safeExecute( + interaction: NexoInteraction, + commandFunction: () => unknown, + errorEmbed: string | EmbedBuilder | null | undefined, + options: SafeExecuteOptions = {}, + ): Promise { const autoDeferDefault = !interaction._isPrefixCommand; const { autoDefer = autoDeferDefault, deferOptions = { flags: MessageFlags.Ephemeral } } = options; @@ -305,14 +368,14 @@ export class InteractionHelper { } const errorToHandle = typeof errorEmbed === 'string' - ? createError(error.message || 'Command failed', ErrorTypes.UNKNOWN, errorEmbed, { expected: true }) + ? createError((error as ErrorLike).message || 'Command failed', ErrorTypes.UNKNOWN, errorEmbed, { expected: true }) : error; await handleInteractionError(interaction, errorToHandle, { source: 'interactionHelper.safeExecute' }); } } - static async universalReply(interaction, options) { + static async universalReply(interaction: NexoInteraction, options: ResponsePayload): Promise { const coordinator = this.getCoordinator(interaction); if (coordinator?.isUsageFinalized()) { return false; @@ -325,7 +388,9 @@ export class InteractionHelper { return await coordinator?.respond(options) ?? this.safeReply(interaction, options); } - const isReady = await this.ensureReady(interaction, options.flags ? { flags: options.flags } : {}); + // Reply flags are forwarded to deferReply unchanged, as in the JS original. + const replyFlags = (options as InteractionReplyOptions).flags; + const isReady = await this.ensureReady(interaction, replyFlags ? { flags: replyFlags as InteractionDeferReplyOptions['flags'] } : {}); if (!isReady) { return false; } @@ -338,10 +403,21 @@ export class InteractionHelper { } } -export function withSafeExecuteDecorator(target, propertyName, descriptor) { - const originalMethod = descriptor.value; - - descriptor.value = async function(interaction, config, client) { +type SafeExecuteMethod = ( + this: unknown, + interaction: NexoInteraction, + config: GuildConfig | null, + client: NexoClient, +) => Promise; + +export function withSafeExecuteDecorator( + target: object, + propertyName: string, + descriptor: TypedPropertyDescriptor, +): TypedPropertyDescriptor { + const originalMethod = descriptor.value!; + + descriptor.value = async function(this: unknown, interaction: NexoInteraction, config: GuildConfig | null, client: NexoClient) { await InteractionHelper.safeExecute( interaction, () => originalMethod.call(this, interaction, config, client), diff --git a/src/utils/interactionValidator.js b/src/utils/interactionValidator.ts similarity index 68% rename from src/utils/interactionValidator.js rename to src/utils/interactionValidator.ts index f42ad131a3..1cd7f3864f 100644 --- a/src/utils/interactionValidator.js +++ b/src/utils/interactionValidator.ts @@ -1,11 +1,22 @@ // interactionValidator.js import { logger } from './logger.js'; +import type { + CommandInteraction, + InteractionDeferUpdateOptions, + MessageComponentInteraction, + ModalBuilder, +} from 'discord.js'; +import type { ErrorLike } from './errorHandler.js'; +import type { AnyRepliableInteraction, ModalCapableInteraction } from './interactionHelper.js'; + +/** Interactions that support deferUpdate (components and modal submits). */ +export type UpdatableInteraction = Exclude | MessageComponentInteraction; const EXPIRED_INTERACTION_CODE = 10062; const INTERACTION_NOT_REPLIED_CODE = 40060; -export function isInteractionValid(interaction) { +export function isInteractionValid(interaction: AnyRepliableInteraction | null | undefined): boolean { if (!interaction || !interaction.id || !interaction.token) { return false; } @@ -22,7 +33,7 @@ export function isInteractionValid(interaction) { return true; } -export async function safeDeferInteraction(interaction, options = {}) { +export async function safeDeferInteraction(interaction: UpdatableInteraction, options: InteractionDeferUpdateOptions = {}): Promise { try { if (!isInteractionValid(interaction)) { logger.warn('Interaction expired before deferral', { @@ -39,7 +50,8 @@ export async function safeDeferInteraction(interaction, options = {}) { await interaction.deferUpdate(options); return true; - } catch (error) { + } catch (caught) { + const error = caught as ErrorLike; if (error.code === EXPIRED_INTERACTION_CODE || error.code === INTERACTION_NOT_REPLIED_CODE) { logger.warn('Interaction expired during deferral', { event: 'interaction.expired_during_defer', @@ -53,7 +65,7 @@ export async function safeDeferInteraction(interaction, options = {}) { } } -export async function safeShowModal(interaction, modal) { +export async function safeShowModal(interaction: ModalCapableInteraction & { customId?: string }, modal: ModalBuilder): Promise { try { if (!isInteractionValid(interaction)) { logger.warn('Interaction expired before modal show', { @@ -74,7 +86,8 @@ export async function safeShowModal(interaction, modal) { await interaction.showModal(modal); return true; - } catch (error) { + } catch (caught) { + const error = caught as ErrorLike; if (error.code === EXPIRED_INTERACTION_CODE || error.code === INTERACTION_NOT_REPLIED_CODE) { logger.warn('Interaction expired during modal show', { event: 'interaction.expired_during_modal', @@ -88,16 +101,19 @@ export async function safeShowModal(interaction, modal) { } } -export function withExpiredInteractionHandler(handler) { - return async (...args) => { +type InteractionLike = { id?: unknown; token?: unknown; customId?: string; user?: { id?: string } }; + +export function withExpiredInteractionHandler(handler: (...args: A) => R): (...args: A) => Promise | null> { + return async (...args: A): Promise | null> => { try { return await handler(...args); - } catch (error) { + } catch (caught) { + const error = caught as ErrorLike; if (error.code === EXPIRED_INTERACTION_CODE || error.code === INTERACTION_NOT_REPLIED_CODE) { const interaction = args.find(arg => - arg && typeof arg === 'object' && (arg.id && arg.token) - ); + arg && typeof arg === 'object' && ((arg as InteractionLike).id && (arg as InteractionLike).token) + ) as InteractionLike | undefined; logger.warn('Handler failed due to expired interaction', { event: 'interaction.handler_expired', diff --git a/src/utils/logger.js b/src/utils/logger.ts similarity index 76% rename from src/utils/logger.js rename to src/utils/logger.ts index df14e57dd2..1192c6b172 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.ts @@ -6,10 +6,23 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { AsyncLocalStorage } from 'async_hooks'; import crypto from 'crypto'; +import type { Interaction, InteractionType } from 'discord.js'; + +export interface TraceContext { + traceId: string; + interactionId: string | null; + interactionType: InteractionType | null; + guildId: string | null; + channelId: string | null; + userId: string | null; + command: string | null; +} + +type TransformableInfo = winston.Logform.TransformableInfo; -const traceStorage = new AsyncLocalStorage(); +const traceStorage = new AsyncLocalStorage(); -function sanitizeCommandName(interaction) { +function sanitizeCommandName(interaction: Interaction | null | undefined): string | null { if (interaction?.isChatInputCommand?.() && interaction.commandName) { return interaction.commandName; } @@ -21,11 +34,11 @@ function sanitizeCommandName(interaction) { return null; } -export function createTraceId(prefix = 'trc') { +export function createTraceId(prefix: string = 'trc'): string { return `${prefix}_${crypto.randomUUID().replace(/-/g, '')}`; } -export function createInteractionTraceContext(interaction, overrides = {}) { +export function createInteractionTraceContext(interaction: Interaction | null | undefined, overrides: Partial = {}): TraceContext { return { traceId: createTraceId(), interactionId: interaction?.id || null, @@ -38,15 +51,15 @@ export function createInteractionTraceContext(interaction, overrides = {}) { }; } -export function runWithTraceContext(traceContext, callback) { +export function runWithTraceContext(traceContext: TraceContext, callback: () => R): R { return traceStorage.run(traceContext, callback); } -export function getTraceContext() { +export function getTraceContext(): TraceContext | null { return traceStorage.getStore() || null; } -export function getTraceId() { +export function getTraceId(): string | null { return getTraceContext()?.traceId || null; } @@ -55,9 +68,9 @@ const { combine, timestamp, printf, colorize, errors, json } = format; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const validLogLevels = new Set(['error', 'warn', 'info', 'http', 'verbose', 'debug', 'silly']); +const validLogLevels = new Set(['error', 'warn', 'info', 'http', 'verbose', 'debug', 'silly']); const defaultLogLevel = process.env.NODE_ENV === 'production' ? 'info' : 'debug'; -const logLevelAliases = { +const logLevelAliases: Record = { warning: 'warn', warnings: 'warn', warns: 'warn', @@ -65,7 +78,7 @@ const logLevelAliases = { information: 'info', }; const rawRequestedLogLevel = process.env.LOG_LEVEL?.toLowerCase().trim(); -const requestedLogLevel = logLevelAliases[rawRequestedLogLevel] || rawRequestedLogLevel; +const requestedLogLevel = (rawRequestedLogLevel && logLevelAliases[rawRequestedLogLevel]) || rawRequestedLogLevel; const resolvedLogLevel = validLogLevels.has(requestedLogLevel) ? requestedLogLevel @@ -107,7 +120,7 @@ const attachTraceContext = format((info) => { return info; }); -function deriveErrorCode(info) { +function deriveErrorCode(info: TransformableInfo): unknown { if (info.errorCode) { return info.errorCode; } @@ -120,14 +133,15 @@ function deriveErrorCode(info) { return info.type; } - if (info.error && (typeof info.error.code === 'string' || typeof info.error.code === 'number')) { - return String(info.error.code); + const nestedCode = info.error && typeof info.error === 'object' ? (info.error as { code?: unknown }).code : undefined; + if (info.error && (typeof nestedCode === 'string' || typeof nestedCode === 'number')) { + return String(nestedCode); } return null; } -function normalizeEvent(info) { +function normalizeEvent(info: TransformableInfo): string { if (typeof info.event === 'string' && info.event.trim()) { return info.event; } @@ -219,17 +233,20 @@ if (process.env.NODE_ENV !== 'production') { })); } -logger.stream = { - write: (message) => { - logger.info(message.trim()); +// winston's typings declare `stream` as a query-stream method; keep the original property override. +Object.assign(logger, { + stream: { + write: (message: string) => { + logger.info(message.trim()); + }, }, -}; +}); if (pendingInvalidLevelWarning) { logger.warn(pendingInvalidLevelWarning); } -function startupLog(message) { +function startupLog(message: string): void { if (shouldPromoteUserFacingLogs) { logger.log({ level: 'warn', @@ -246,7 +263,7 @@ function startupLog(message) { }); } -function shutdownLog(message) { +function shutdownLog(message: string): void { if (shouldPromoteUserFacingLogs) { logger.log({ level: 'warn', diff --git a/src/utils/logging/logEmbeds.js b/src/utils/logging/logEmbeds.ts similarity index 64% rename from src/utils/logging/logEmbeds.js rename to src/utils/logging/logEmbeds.ts index f334e8dec7..b125f724aa 100644 --- a/src/utils/logging/logEmbeds.js +++ b/src/utils/logging/logEmbeds.ts @@ -1,30 +1,72 @@ // logEmbeds.js — shared helpers for clean, scannable log embeds import { EmbedBuilder } from 'discord.js'; +import type { APIEmbedField, Client, ColorResolvable, Guild, Role } from 'discord.js'; + +export interface LogField { + name: string; + value: unknown; + inline?: boolean; +} + +export type LogMetaEntry = [label: string, value: unknown]; + +export interface LogDescriptionOptions { + headline?: string | null; + lines?: string[]; + quoted?: boolean; + meta?: LogMetaEntry[]; +} + +export interface LogFooterOptions { + guild?: Guild | null; + executorId?: string | null; + executorTag?: string | null; + executorAvatar?: string | null; + footerText?: string | null; +} + +export interface LogAuthor { + name: string; + iconURL?: string; +} + +export interface StandardLogEmbedOptions { + color: ColorResolvable | null; + title?: string | null; + description?: string | null; + thumbnail?: string | null; + image?: string | null; + inlineFields?: LogField[]; + fields?: LogField[]; + author?: { name?: string | null; iconURL?: string | null } | null; + timestamp?: boolean; + footer?: { text?: string | null; iconURL?: string | null } | null; +} const EMOJI_PREFIX = /^[\p{Extended_Pictographic}\uFE0F\s]+/u; -export function stripFieldLabel(name = '') { +export function stripFieldLabel(name: string = ''): string { return name.replace(EMOJI_PREFIX, '').trim() || name; } -export function formatLogLine(label, value) { +export function formatLogLine(label: string, value: unknown): string { return `**${label}:** ${value}`; } -export function formatMetaLine(entries) { +export function formatMetaLine(entries: LogMetaEntry[]): string { return entries .filter(([, value]) => value !== undefined && value !== null && value !== '') .map(([label, value]) => `**${label}:** ${value}`) .join(' • '); } -export function buildQuotedBlock(lines) { +export function buildQuotedBlock(lines: string[]): string { return lines.map((line) => `> ${line}`).join('\n'); } -export function buildLogDescription({ headline, lines = [], quoted = false, meta = [] } = {}) { - const parts = []; +export function buildLogDescription({ headline, lines = [], quoted = false, meta = [] }: LogDescriptionOptions = {}): string { + const parts: string[] = []; if (headline) { parts.push(headline); @@ -41,12 +83,18 @@ export function buildLogDescription({ headline, lines = [], quoted = false, meta return parts.join('\n\n').slice(0, 4096); } -export function fieldsToLines(fields = []) { +export function fieldsToLines(fields: LogField[] = []): string[] { return fields.map((field) => formatLogLine(stripFieldLabel(field.name), field.value)); } -export function splitComparisonFields(fields = []) { - const comparison = { before: null, after: null, rest: [] }; +export interface ComparisonFields { + before: unknown; + after: unknown; + rest: LogField[]; +} + +export function splitComparisonFields(fields: LogField[] = []): ComparisonFields { + const comparison: ComparisonFields = { before: null, after: null, rest: [] }; for (const field of fields) { const label = stripFieldLabel(field.name).toLowerCase(); @@ -63,7 +111,7 @@ export function splitComparisonFields(fields = []) { return comparison; } -export function applyLogFooter(embed, { guild, executorId, executorTag, executorAvatar, footerText } = {}) { +export function applyLogFooter(embed: EmbedBuilder, { guild, executorId, executorTag, executorAvatar, footerText }: LogFooterOptions = {}): EmbedBuilder { if (footerText) { embed.setFooter({ text: footerText.slice(0, 2048), iconURL: executorAvatar || undefined }); return embed; @@ -80,14 +128,14 @@ export function applyLogFooter(embed, { guild, executorId, executorTag, executor if (guild) { embed.setFooter({ text: guild.name, - iconURL: guild.iconURL({ dynamic: true }) || undefined, + iconURL: guild.iconURL() || undefined, }); } return embed; } -export function appendContentSection(description = '', label, content) { +export function appendContentSection(description: string = '', label: string, content: string | null | undefined): string { if (!content) { return description; } @@ -97,7 +145,7 @@ export function appendContentSection(description = '', label, content) { return base ? `${base}\n\n${section}`.slice(0, 4096) : section.slice(0, 4096); } -export function formatRatingStars(rating) { +export function formatRatingStars(rating: unknown): string | null { const numeric = Number(rating); if (!Number.isFinite(numeric) || numeric < 1) { return null; @@ -107,7 +155,7 @@ export function formatRatingStars(rating) { return `${'⭐'.repeat(clamped)} (${clamped}/5)`; } -export async function resolveUserAuthor(client, userId) { +export async function resolveUserAuthor(client: Client, userId: string | null | undefined): Promise { if (!userId) { return null; } @@ -116,7 +164,7 @@ export async function resolveUserAuthor(client, userId) { const user = await client.users.fetch(userId); return { name: user.tag, - iconURL: user.displayAvatarURL({ dynamic: true }), + iconURL: user.displayAvatarURL(), }; } catch { return { @@ -136,7 +184,7 @@ export function buildStandardLogEmbed({ author = null, timestamp = true, footer, -}) { +}: StandardLogEmbedOptions): EmbedBuilder { const embed = new EmbedBuilder().setColor(color); if (title) embed.setTitle(title.slice(0, 256)); @@ -175,8 +223,12 @@ export function buildStandardLogEmbed({ const MAX_DISPLAYED_ROLE_PERMISSIONS = 5; -export function buildRoleAuditFields(role, { includeMemberCount = false } = {}) { - const fields = [ +export interface RoleAuditOptions { + includeMemberCount?: boolean; +} + +export function buildRoleAuditFields(role: Role, { includeMemberCount = false }: RoleAuditOptions = {}): APIEmbedField[] { + const fields: APIEmbedField[] = [ { name: 'Role Name', value: role.name, @@ -235,7 +287,7 @@ export function buildRoleAuditFields(role, { includeMemberCount = false } = {}) return fields; } -export function buildRoleAuditLines(role, options = {}) { +export function buildRoleAuditLines(role: Role, options: RoleAuditOptions = {}): string[] { return buildRoleAuditFields(role, options).map((field) => formatLogLine(field.name, field.value), ); diff --git a/src/utils/logging/loggingUi.js b/src/utils/logging/loggingUi.ts similarity index 76% rename from src/utils/logging/loggingUi.js rename to src/utils/logging/loggingUi.ts index 76cbc8877d..f4e6faf4db 100644 --- a/src/utils/logging/loggingUi.js +++ b/src/utils/logging/loggingUi.ts @@ -7,9 +7,13 @@ import { StringSelectMenuBuilder, StringSelectMenuOptionBuilder, } from 'discord.js'; +import type { MessageActionRowComponentBuilder } from 'discord.js'; import { EVENT_TYPES } from '../../services/loggingService.js'; -const EVENT_TYPES_BY_CATEGORY = Object.values(EVENT_TYPES).reduce((accumulator, eventType) => { +/** Per-event-type toggle map stored in the guild logging config (`false` = disabled). */ +export type EnabledEventsMap = Record; + +const EVENT_TYPES_BY_CATEGORY = Object.values(EVENT_TYPES).reduce>((accumulator, eventType) => { const [category] = eventType.split('.'); if (!accumulator[category]) { accumulator[category] = []; @@ -31,7 +35,7 @@ export const DASHBOARD_CATEGORIES = [ 'report', ]; -const DASHBOARD_CATEGORY_EMOJIS = { +const DASHBOARD_CATEGORY_EMOJIS: Record = { moderation: '🔨', message: '✉️', role: '🏷️', @@ -44,7 +48,7 @@ const DASHBOARD_CATEGORY_EMOJIS = { report: '🚨', }; -export const DASHBOARD_CATEGORY_LABELS = { +export const DASHBOARD_CATEGORY_LABELS: Record = { moderation: 'Moderation', message: 'Messages', role: 'Roles', @@ -57,14 +61,14 @@ export const DASHBOARD_CATEGORY_LABELS = { report: 'Reports', }; -function createBackButton() { +function createBackButton(): ButtonBuilder { return new ButtonBuilder() .setCustomId('log_dash_back') .setLabel('Back to Dashboard') .setStyle(ButtonStyle.Secondary); } -function createCategoryToggleButtons(enabledEvents = {}, loggingEnabled = false) { +function createCategoryToggleButtons(enabledEvents: EnabledEventsMap = {}, loggingEnabled: boolean = false): ActionRowBuilder[] { const buttons = DASHBOARD_CATEGORIES.map((category) => { const wildcardDisabled = enabledEvents[`${category}.*`] === false; const categoryEvents = EVENT_TYPES_BY_CATEGORY[category] || []; @@ -81,15 +85,15 @@ function createCategoryToggleButtons(enabledEvents = {}, loggingEnabled = false) .setStyle(isEnabled ? ButtonStyle.Success : ButtonStyle.Danger); }); - const rows = []; + const rows: ActionRowBuilder[] = []; for (let i = 0; i < buttons.length; i += 5) { - rows.push(new ActionRowBuilder().addComponents(buttons.slice(i, i + 5))); + rows.push(new ActionRowBuilder().addComponents(buttons.slice(i, i + 5))); } return rows; } -export function createLoggingMainMenuSelect() { - return new ActionRowBuilder().addComponents( +export function createLoggingMainMenuSelect(): ActionRowBuilder { + return new ActionRowBuilder().addComponents( new StringSelectMenuBuilder() .setCustomId('log_dash_menu') .setPlaceholder('Choose a setting to configure…') @@ -135,8 +139,8 @@ export function createLoggingMainMenuSelect() { ); } -export function createLoggingMainActionRow(loggingEnabled = false) { - return new ActionRowBuilder().addComponents( +export function createLoggingMainActionRow(loggingEnabled: boolean = false): ActionRowBuilder { + return new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('log_dash_toggle:audit_enabled') .setLabel('Audit Logging') @@ -148,17 +152,17 @@ export function createLoggingMainActionRow(loggingEnabled = false) { ); } -export function createLoggingDashboardComponents(_enabledEvents, loggingEnabled = false) { +export function createLoggingDashboardComponents(_enabledEvents: EnabledEventsMap | null | undefined, loggingEnabled: boolean = false): ActionRowBuilder[] { return [ createLoggingMainMenuSelect(), createLoggingMainActionRow(loggingEnabled), ]; } -export function createLoggingCategoryViewComponents(enabledEvents, loggingEnabled = false) { +export function createLoggingCategoryViewComponents(enabledEvents: EnabledEventsMap | undefined, loggingEnabled: boolean = false): ActionRowBuilder[] { const categoryRows = createCategoryToggleButtons(enabledEvents, loggingEnabled); - const actionRow = new ActionRowBuilder().addComponents( + const actionRow = new ActionRowBuilder().addComponents( createBackButton(), new ButtonBuilder() .setCustomId('log_dash_toggle:all') @@ -173,9 +177,9 @@ export function createLoggingCategoryViewComponents(enabledEvents, loggingEnable return [...categoryRows, actionRow]; } -export function createLoggingFilterComponents() { +export function createLoggingFilterComponents(): ActionRowBuilder[] { return [ - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( createBackButton(), new ButtonBuilder() .setCustomId('log_dash_add_filter:user') diff --git a/src/utils/memoryStorage.js b/src/utils/memoryStorage.ts similarity index 71% rename from src/utils/memoryStorage.js rename to src/utils/memoryStorage.ts index 26d3465b01..1ede5743a7 100644 --- a/src/utils/memoryStorage.js +++ b/src/utils/memoryStorage.ts @@ -1,18 +1,25 @@ // memoryStorage.js import { logger } from './logger.js'; +import type { KeyValueStore } from './database/wrapper.js'; + +class MemoryStorage implements KeyValueStore { + data: Map; + expirationTimes: Map; -class MemoryStorage { constructor() { this.data = new Map(); this.expirationTimes = new Map(); } - async get(key, defaultValue = null) { + get(key: string, defaultValue?: null): Promise; + get(key: string, defaultValue: T): Promise; + get(key: string, defaultValue?: T | null): Promise; + async get(key: string, defaultValue: unknown = null): Promise { const value = this.data.get(key); if (this.expirationTimes.has(key)) { - const expirationTime = this.expirationTimes.get(key); + const expirationTime = this.expirationTimes.get(key)!; if (Date.now() > expirationTime) { this.data.delete(key); this.expirationTimes.delete(key); @@ -23,7 +30,7 @@ class MemoryStorage { return value !== undefined ? value : defaultValue; } - async set(key, value, ttl = null) { + async set(key: string, value: unknown, ttl: number | null = null): Promise { this.data.set(key, value); if (ttl && ttl > 0) { @@ -33,18 +40,18 @@ class MemoryStorage { return true; } - async delete(key) { + async delete(key: string): Promise { this.data.delete(key); this.expirationTimes.delete(key); return true; } - async list(prefix) { - const keys = []; + async list(prefix: string): Promise { + const keys: string[] = []; for (const key of this.data.keys()) { if (key.startsWith(prefix)) { if (this.expirationTimes.has(key)) { - const expirationTime = this.expirationTimes.get(key); + const expirationTime = this.expirationTimes.get(key)!; if (Date.now() > expirationTime) { this.data.delete(key); this.expirationTimes.delete(key); @@ -57,11 +64,11 @@ class MemoryStorage { return keys; } - async exists(key) { + async exists(key: string): Promise { const value = this.data.get(key); if (this.expirationTimes.has(key)) { - const expirationTime = this.expirationTimes.get(key); + const expirationTime = this.expirationTimes.get(key)!; if (Date.now() > expirationTime) { this.data.delete(key); this.expirationTimes.delete(key); @@ -72,21 +79,21 @@ class MemoryStorage { return value !== undefined; } - async increment(key, amount = 1) { + async increment(key: string, amount = 1): Promise { const current = await this.get(key, 0); const newValue = current + amount; await this.set(key, newValue); return newValue; } - async decrement(key, amount = 1) { + async decrement(key: string, amount = 1): Promise { const current = await this.get(key, 0); const newValue = current - amount; await this.set(key, newValue); return newValue; } - async clear() { + async clear(): Promise { this.data.clear(); this.expirationTimes.clear(); return true; diff --git a/src/utils/messageAdapter.js b/src/utils/messageAdapter.ts similarity index 58% rename from src/utils/messageAdapter.js rename to src/utils/messageAdapter.ts index 2d69d689b4..12d5e56d91 100644 --- a/src/utils/messageAdapter.js +++ b/src/utils/messageAdapter.ts @@ -1,6 +1,18 @@ // messageAdapter.js +import type { + ChatInputCommandInteraction, + Guild, + GuildBasedChannel, + GuildMember, + Message, + PermissionsBitField, + Role, + User, +} from 'discord.js'; import { mapArgumentsToOptions } from './prefixParser.js'; +import type { PrefixCommandData, PrefixCommandJson, PrefixValidationResult } from './prefixParser.js'; +import type { Command, GuildConfig, NexoClient } from '../types/index.js'; import { createEmbed } from './embeds.js'; import { handleInteractionError } from './errorHandler.js'; import { logger } from './logger.js'; @@ -8,15 +20,72 @@ import { InteractionHelper } from './interactionHelper.js'; import { SLASH_ONLY_COMMANDS } from '../config/commands/prefixRestrictions.js'; import { getCommandPrefix } from '../config/bot.js'; import { ResponseCoordinator, buildPrefixUsage } from './responseCoordinator.js'; +import type { ResponsePayload } from './responseCoordinator.js'; import { enforceDefaultCommandPermissions } from './permissionGuard.js'; export { buildPrefixUsage }; -function getCommandJson(commandData) { +/** Placeholder returned by getUser when the mentioned user is not cached. */ +interface UncachedUserStub { + id: string; + username: string; + bot: boolean; + tag: string; +} + +/** Option accessors of the prefix mock, mirroring CommandInteractionOptionResolver. */ +export interface PrefixMockOptions { + get(name: string): string | null; + getString(name: string): string | null; + getUser(name: string): User | UncachedUserStub | null; + getMember(name: string): GuildMember | null; + getChannel(name: string): Promise | null; + getRole(name: string): Promise | null; + getInteger(name: string): number | null; + getBoolean(name: string): boolean; + getSubcommand(): string | null; + getSubcommandGroup(): string | null; + validateRequired(): PrefixValidationResult; + _hoistedOptions: Array<{ name: string; value: string; type: number }>; +} + +/** Interaction-shaped object built from a prefix message; handed to commands as a ChatInputCommandInteraction. */ +export interface PrefixMockInteraction { + user: User; + member: GuildMember | null; + readonly memberPermissions: Readonly | null; + channel: Message['channel']; + guild: Guild | null; + guildId: string | undefined; + commandName: string | null; + commandId: string; + id: string; + options: PrefixMockOptions; + createdTimestamp: number; + createdAt: Date; + _commandStartTime: number; + _isPrefixCommand: true; + client: NexoClient; + deferred: boolean; + replied: boolean; + _replyMessage: Message | null; + deleteReply(): Promise; + fetchReply(): Promise; + ephemeral: boolean; + webhook: null; + reply?(payload: ResponsePayload): Promise; + editReply?(payload: ResponsePayload): Promise; + followUp?(payload: ResponsePayload): Promise; + deferReply?(): Promise; + /** Set by ResponseCoordinator.attach. */ + _responseCoordinator?: ResponseCoordinator; +} + +function getCommandJson(commandData: PrefixCommandData | null | undefined): PrefixCommandJson | null | undefined { return commandData?.toJSON ? commandData.toJSON() : commandData; } -export function resolveSlashAccessKey(interaction) { +export function resolveSlashAccessKey(interaction: ChatInputCommandInteraction): string { const subcommandGroup = interaction.options.getSubcommandGroup(false); const subcommand = interaction.options.getSubcommand(false); @@ -31,7 +100,7 @@ export function resolveSlashAccessKey(interaction) { return interaction.commandName; } -export function resolvePrefixAccessKey(commandData, args) { +export function resolvePrefixAccessKey(commandData: PrefixCommandData, args: string[]): string | null { const options = mapArgumentsToOptions(args, commandData); const subcommand = options.getSubcommand(); const subcommandGroup = options.getSubcommandGroup(); @@ -52,11 +121,15 @@ export function resolvePrefixAccessKey(commandData, args) { return commandName; } -export function createMockInteraction(message, commandData, args) { +export function createMockInteraction( + message: Message, + commandData: PrefixCommandData, + args: string[] +): PrefixMockInteraction { const options = mapArgumentsToOptions(args, commandData); const commandStartTime = Date.now(); - const mockInteraction = { + const mockInteraction: PrefixMockInteraction = { user: message.author, member: message.member, get memberPermissions() { @@ -72,9 +145,9 @@ export function createMockInteraction(message, commandData, args) { id: message.id, options: { - get: (name) => options.get(name), - getString: (name) => options.getString(name), - getUser: (name) => { + get: (name: string) => options.get(name), + getString: (name: string) => options.getString(name), + getUser: (name: string) => { const userId = options.getUser(name); if (!userId || !message.guild) return null; @@ -93,7 +166,7 @@ export function createMockInteraction(message, commandData, args) { tag: 'Unknown#0000', }; }, - getMember: (name) => { + getMember: (name: string) => { const userId = options.getUser(name); if (!userId || !message.guild) return null; @@ -102,7 +175,7 @@ export function createMockInteraction(message, commandData, args) { return message.guild.members.cache.get(id) ?? null; }, - getChannel: (name) => { + getChannel: (name: string) => { const channelId = options.getString(name); if (!channelId || !message.guild) return null; @@ -111,7 +184,7 @@ export function createMockInteraction(message, commandData, args) { return message.guild.channels.fetch(id).catch(() => null); }, - getRole: (name) => { + getRole: (name: string) => { const roleId = options.getString(name); if (!roleId || !message.guild) return null; @@ -120,8 +193,8 @@ export function createMockInteraction(message, commandData, args) { return message.guild.roles.fetch(id).catch(() => null); }, - getInteger: (name) => options.getInteger(name), - getBoolean: (name) => options.getBoolean(name), + getInteger: (name: string) => options.getInteger(name), + getBoolean: (name: string) => options.getBoolean(name), getSubcommand: () => options.getSubcommand(), getSubcommandGroup: () => options.getSubcommandGroup(), validateRequired: () => options.validateRequired(), @@ -171,7 +244,7 @@ export function createMockInteraction(message, commandData, args) { return mockInteraction; } -export function supportsPrefixExecution(command) { +export function supportsPrefixExecution(command: Command): boolean { if (command.prefixOnly === false || command.slashOnly === true) { return false; } @@ -188,13 +261,24 @@ export function supportsPrefixExecution(command) { return !!command.execute; } -export async function executePrefixCommand(command, message, args, client, prefixOverride = null, guildConfig = null) { +export async function executePrefixCommand( + command: Command, + message: Message, + args: string[], + client: NexoClient, + prefixOverride: string | null = null, + guildConfig: GuildConfig | null = null +): Promise { const mockInteraction = createMockInteraction(message, command.data, args); - const coordinator = mockInteraction._responseCoordinator; + // Commands, permission checks and error handling expect a ChatInputCommandInteraction; + // the prefix mock implements the subset they use (see PrefixMockInteraction). + const interaction = mockInteraction as unknown as ChatInputCommandInteraction; + // Always set: createMockInteraction calls ResponseCoordinator.attach. + const coordinator = mockInteraction._responseCoordinator!; const prefix = prefixOverride || getCommandPrefix(); try { - const permissionAllowed = await enforceDefaultCommandPermissions(mockInteraction, command, { + const permissionAllowed = await enforceDefaultCommandPermissions(interaction, command, { source: 'messageAdapter.executePrefixCommand', guildConfig, }); @@ -209,12 +293,12 @@ export async function executePrefixCommand(command, message, args, client, prefi } if (command.prefixExecute) { - await command.prefixExecute(mockInteraction, guildConfig, client); + await command.prefixExecute(interaction, guildConfig, client); } else { - await command.execute(mockInteraction, guildConfig, client); + await command.execute(interaction, guildConfig, client); } } catch (error) { - await handleInteractionError(mockInteraction, error, { + await handleInteractionError(interaction, error, { type: 'prefix_command', command: command.data?.name, source: 'messageAdapter.executePrefixCommand', diff --git a/src/utils/moderation.js b/src/utils/moderation.ts similarity index 71% rename from src/utils/moderation.js rename to src/utils/moderation.ts index 13ac5e4e53..94dc2c40f9 100644 --- a/src/utils/moderation.js +++ b/src/utils/moderation.ts @@ -4,8 +4,55 @@ import { logEvent as logAuditEvent, EVENT_TYPES } from '../services/loggingServi import { formatLogLine } from './logging/logEmbeds.js'; import { logger } from './logger.js'; import { getFromDb, setInDb } from './database.js'; +import type { Guild } from 'discord.js'; +import type { NexoClient } from '../types/index.js'; -const ACTION_TO_EVENT_TYPE = { +export interface ModerationEventMetadata { + userId?: string; + moderatorId?: string; + [key: string]: unknown; +} + +export interface ModerationEvent { + action: string; + /** "tag (id)" */ + target?: string; + /** "tag (id)" */ + executor?: string; + reason?: string | null; + duration?: string | number | null; + caseId?: string | number; + metadata?: ModerationEventMetadata; +} + +export interface ModerationCaseData { + action: string; + target?: string; + executor?: string; + reason?: string | null; + duration?: string | number | null; + metadata?: ModerationEventMetadata; + targetUserId?: string; + moderatorId?: string; +} + +/** Stored case (`moderation_case_{guildId}_{caseId}` and `moderation_cases_list_{guildId}`). */ +export interface ModerationCase extends ModerationCaseData { + createdAt: string; + caseId: number; +} + +export interface ModerationCaseFilters { + userId?: string; + moderatorId?: string; + action?: string; + limit?: number; + offset?: number; +} + +type AuditEventType = (typeof EVENT_TYPES)[keyof typeof EVENT_TYPES]; + +const ACTION_TO_EVENT_TYPE: Record = { 'Member Banned': EVENT_TYPES.MODERATION_BAN, 'Member Kicked': EVENT_TYPES.MODERATION_KICK, 'Member Timed Out': EVENT_TYPES.MODERATION_TIMEOUT, @@ -24,13 +71,13 @@ const ACTION_TO_EVENT_TYPE = { 'Case Updated': EVENT_TYPES.MODERATION_CONFIG, }; -function buildModerationLogData(event) { +function buildModerationLogData(event: ModerationEvent) { const targetIdMatch = event.target?.match(/\((\d+)\)/); const targetId = targetIdMatch?.[1]; const executorIdMatch = event.executor?.match(/\((\d+)\)/); const executorTag = event.executor?.split(' (')[0] || 'Moderator'; - const lines = []; + const lines: string[] = []; if (event.target) { lines.push(formatLogLine('User', event.target)); } @@ -47,7 +94,7 @@ function buildModerationLogData(event) { lines.push(formatLogLine('Case', `\`${event.caseId}\``)); } - const meta = []; + const meta: [string, string][] = []; if (event.metadata) { Object.entries(event.metadata).forEach(([key, value]) => { if (value !== undefined && value !== null && key !== 'userId' && key !== 'moderatorId') { @@ -70,7 +117,12 @@ function buildModerationLogData(event) { }; } -export async function logEvent({ client, guild, guildId, event }) { +export async function logEvent({ client, guild, guildId, event }: { + client: NexoClient; + guild?: Guild | null; + guildId?: string; + event: ModerationEvent; +}): Promise { try { if (!guild && guildId) { guild = client.guilds.cache.get(guildId) || await client.guilds.fetch(guildId).catch(() => null); @@ -96,7 +148,7 @@ export async function logEvent({ client, guild, guildId, event }) { } } -export async function generateCaseId(client, guildId) { +export async function generateCaseId(client: NexoClient, guildId: string): Promise { try { const caseKey = `moderation_cases_${guildId}`; const currentCase = await getFromDb(caseKey, 0); @@ -109,10 +161,14 @@ return Date.now(); } } -export async function storeModerationCase({ guildId, caseId, caseData }) { +export async function storeModerationCase({ guildId, caseId, caseData }: { + guildId: string; + caseId: number; + caseData: ModerationCaseData; +}): Promise { try { const caseKey = `moderation_case_${guildId}_${caseId}`; - const caseDataWithTimestamp = { + const caseDataWithTimestamp: ModerationCase = { ...caseData, createdAt: new Date().toISOString(), caseId @@ -121,7 +177,7 @@ export async function storeModerationCase({ guildId, caseId, caseData }) { await setInDb(caseKey, caseDataWithTimestamp); const caseListKey = `moderation_cases_list_${guildId}`; - const caseList = await getFromDb(caseListKey, []); + const caseList = await getFromDb(caseListKey, []); caseList.push(caseDataWithTimestamp); if (caseList.length > 1000) { @@ -136,14 +192,14 @@ export async function storeModerationCase({ guildId, caseId, caseData }) { } } -export async function getModerationCases(guildId, filters = {}) { +export async function getModerationCases(guildId: string, filters: ModerationCaseFilters = {}): Promise { try { const { userId, moderatorId, action, limit = 50, offset = 0 } = filters; - const allCases = []; + const allCases: ModerationCase[] = []; const caseListKey = `moderation_cases_list_${guildId}`; - const caseList = await getFromDb(caseListKey, []); + const caseList = await getFromDb(caseListKey, []); let filteredCases = caseList; @@ -159,7 +215,7 @@ export async function getModerationCases(guildId, filters = {}) { filteredCases = filteredCases.filter(case_ => case_.action === action); } - filteredCases.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + filteredCases.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); return filteredCases.slice(offset, offset + limit); } catch (error) { @@ -168,7 +224,11 @@ export async function getModerationCases(guildId, filters = {}) { } } -export async function logModerationAction({ client, guild, event }) { +export async function logModerationAction({ client, guild, event }: { + client: NexoClient; + guild: Guild; + event: ModerationEvent; +}): Promise { const caseId = await generateCaseId(client, guild.id); await storeModerationCase({ diff --git a/src/utils/mutex.js b/src/utils/mutex.ts similarity index 81% rename from src/utils/mutex.js rename to src/utils/mutex.ts index 8f15b7370f..7668ed5acf 100644 --- a/src/utils/mutex.js +++ b/src/utils/mutex.ts @@ -1,10 +1,10 @@ // mutex.js -const locks = new Map(); +const locks = new Map>(); export const Mutex = { - async runExclusive(key, task) { + async runExclusive(key: string, task: () => T | Promise): Promise { const currentLock = locks.get(key) || Promise.resolve(); diff --git a/src/utils/panelStatus.js b/src/utils/panelStatus.ts similarity index 54% rename from src/utils/panelStatus.js rename to src/utils/panelStatus.ts index bf180e2d27..9638f80013 100644 --- a/src/utils/panelStatus.js +++ b/src/utils/panelStatus.ts @@ -2,10 +2,48 @@ * Shared helpers for detecting bot-posted panel messages (tickets, verification, etc.) */ -export function messageHasButtonCustomId(message, buttonCustomId) { +import type { Guild, GuildTextBasedChannel, Message } from 'discord.js'; +import type { NexoClient } from '../types/index.js'; + +/** Raw component JSON fields inspected while walking a message's components. */ +interface ComponentJsonLike { + type?: number; + custom_id?: string; + components?: readonly ComponentLike[]; +} + +/** A discord.js component (with toJSON) or its raw JSON. */ +interface ComponentLike extends ComponentJsonLike { + toJSON?(): ComponentJsonLike; +} + +type PanelMessage = Pick | null | undefined; + +export interface PanelMarker { + buttonCustomId?: string | null; + selectCustomId?: string | null; +} + +export type PanelMissingReason = 'no_channel' | 'channel_missing' | 'panel_deleted'; + +export interface PanelStatus { + exists: boolean; + reason?: PanelMissingReason; + message?: Message; + channel?: GuildTextBasedChannel; + recoveredId?: string; +} + +export interface BotPanelStatusOptions extends PanelMarker { + channelId?: string | null; + messageId?: string | null; + scanLimit?: number; +} + +export function messageHasButtonCustomId(message: PanelMessage, buttonCustomId: string | null | undefined): boolean { if (!message?.components?.length || !buttonCustomId) return false; - const walk = (components) => { + const walk = (components: readonly ComponentLike[]): boolean => { for (const component of components) { const json = typeof component.toJSON === 'function' ? component.toJSON() : component; if (!json) continue; @@ -15,14 +53,14 @@ export function messageHasButtonCustomId(message, buttonCustomId) { return false; }; - const rows = [...(message.components.values?.() || message.components)]; + const rows: ComponentLike[] = [...(message.components.values?.() || message.components)]; return walk(rows); } -export function messageHasSelectMenuCustomId(message, selectCustomId) { +export function messageHasSelectMenuCustomId(message: PanelMessage, selectCustomId: string | null | undefined): boolean { if (!message?.components?.length || !selectCustomId) return false; - const walk = (components) => { + const walk = (components: readonly ComponentLike[]): boolean => { for (const component of components) { const json = typeof component.toJSON === 'function' ? component.toJSON() : component; if (!json) continue; @@ -32,17 +70,20 @@ export function messageHasSelectMenuCustomId(message, selectCustomId) { return false; }; - const rows = [...(message.components.values?.() || message.components)]; + const rows: ComponentLike[] = [...(message.components.values?.() || message.components)]; return walk(rows); } -export function messageHasPanelMarker(message, { buttonCustomId, selectCustomId } = {}) { +export function messageHasPanelMarker(message: PanelMessage, { buttonCustomId, selectCustomId }: PanelMarker = {}): boolean { if (buttonCustomId && messageHasButtonCustomId(message, buttonCustomId)) return true; if (selectCustomId && messageHasSelectMenuCustomId(message, selectCustomId)) return true; return false; } -export function formatPanelStatusField(panelStatus, { repostHint = 'Repost Panel' } = {}) { +export function formatPanelStatusField( + panelStatus: PanelStatus | null | undefined, + { repostHint = 'Repost Panel' }: { repostHint?: string } = {}, +): string { if (!panelStatus) return '`Unknown`'; if (panelStatus.exists) { @@ -66,13 +107,13 @@ export function formatPanelStatusField(panelStatus, { repostHint = 'Repost Panel return '`Unknown`'; } -export async function getBotPanelStatus(client, guild, { +export async function getBotPanelStatus(client: NexoClient, guild: Guild, { channelId, messageId = null, buttonCustomId = null, selectCustomId = null, scanLimit = 50, -} = {}) { +}: BotPanelStatusOptions = {}): Promise { if (!channelId) { return { exists: false, reason: 'no_channel' }; } @@ -81,7 +122,8 @@ export async function getBotPanelStatus(client, guild, { return { exists: false, reason: 'no_channel' }; } - const channel = await guild.channels.fetch(channelId).catch(() => null); + // Panel channels are text channels (as the JS assumed). + const channel = await guild.channels.fetch(channelId).catch(() => null) as GuildTextBasedChannel | null; if (!channel) { return { exists: false, reason: 'channel_missing' }; } @@ -96,8 +138,8 @@ export async function getBotPanelStatus(client, guild, { } const messages = await channel.messages.fetch({ limit: scanLimit }).catch(() => null); - const messageList = messages - ? [...(typeof messages.values === 'function' ? messages.values() : messages)] + const messageList: Message[] = messages + ? [...messages.values()] : []; const recovered = messageList.find( (entry) => entry.author.id === client.user.id && messageHasPanelMarker(entry, marker), @@ -110,7 +152,19 @@ export async function getBotPanelStatus(client, guild, { return { exists: false, reason: 'panel_deleted', channel }; } -export async function getTicketPanelStatus(client, guild, config) { +/** Guild config fields read for the ticket panel. */ +interface TicketPanelConfig { + ticketPanelChannelId?: string | null; + ticketPanelMessageId?: string | null; +} + +/** Stored location of a verification or reaction-role panel. */ +interface PanelLocation { + channelId?: string | null; + messageId?: string | null; +} + +export async function getTicketPanelStatus(client: NexoClient, guild: Guild, config: TicketPanelConfig): Promise { return getBotPanelStatus(client, guild, { channelId: config.ticketPanelChannelId, messageId: config.ticketPanelMessageId, @@ -118,7 +172,11 @@ export async function getTicketPanelStatus(client, guild, config) { }); } -export async function getVerificationPanelStatus(client, guild, config) { +export async function getVerificationPanelStatus( + client: NexoClient, + guild: Guild, + config: PanelLocation | null | undefined, +): Promise { return getBotPanelStatus(client, guild, { channelId: config?.channelId, messageId: config?.messageId, @@ -126,11 +184,14 @@ export async function getVerificationPanelStatus(client, guild, config) { }); } -export async function getReactionRolePanelStatus(client, guild, panelData) { +export async function getReactionRolePanelStatus( + client: NexoClient, + guild: Guild, + panelData: PanelLocation | null | undefined, +): Promise { return getBotPanelStatus(client, guild, { channelId: panelData?.channelId, messageId: panelData?.messageId, selectCustomId: 'reaction_roles', }); } - diff --git a/src/utils/permissionGuard.js b/src/utils/permissionGuard.ts similarity index 70% rename from src/utils/permissionGuard.js rename to src/utils/permissionGuard.ts index d6cb4a84d5..ce16a008d0 100644 --- a/src/utils/permissionGuard.js +++ b/src/utils/permissionGuard.ts @@ -1,16 +1,40 @@ // permissionGuard.js import { PermissionFlagsBits } from 'discord.js'; +import type { + ChatInputCommandInteraction, + GuildBasedChannel, + GuildMember, + PermissionResolvable, +} from 'discord.js'; +import type { Command, GuildConfig } from '../types/index.js'; import { logger } from './logger.js'; import { replyUserError, ErrorTypes } from './errorHandler.js'; import { isBotOwner, getBotMessage } from '../config/bot.js'; +/** The part of a command's JSON body read here. */ +interface PermissionCommandJson { + default_member_permissions?: string | null; +} + +/** A SlashCommandBuilder (anything with toJSON) or its plain JSON. */ +interface PermissionCommandData extends PermissionCommandJson { + toJSON?(): PermissionCommandJson; +} + +type RoleRef = string | { id?: string | null } | null | undefined; + +/** Guild config fields read here (GuildConfig satisfies this). */ +type ModeratorRoleConfig = { modRole?: RoleRef } | null | undefined; + +type PermissionMember = GuildMember | null | undefined; + /** * Read default_member_permissions from a SlashCommandBuilder (or its JSON). * @param {import('discord.js').SlashCommandBuilder | object} commandData * @returns {bigint | null} */ -export function getCommandDefaultPermissions(commandData) { +export function getCommandDefaultPermissions(commandData: PermissionCommandData | null | undefined): bigint | null { const json = commandData?.toJSON?.() ?? commandData; const value = json?.default_member_permissions; @@ -21,7 +45,7 @@ export function getCommandDefaultPermissions(commandData) { return BigInt(value); } -function normalizeRoleId(role) { +function normalizeRoleId(role: RoleRef): string | null { if (!role) { return null; } @@ -37,7 +61,7 @@ function normalizeRoleId(role) { return null; } -function isModerationCategory(category) { +function isModerationCategory(category: string | null | undefined): boolean { return category?.toLowerCase?.() === 'moderation'; } @@ -47,7 +71,7 @@ function isModerationCategory(category) { * @param {object | null | undefined} guildConfig * @returns {boolean} */ -export function memberHasConfiguredModeratorRole(member, guildConfig) { +export function memberHasConfiguredModeratorRole(member: PermissionMember, guildConfig: ModeratorRoleConfig): boolean { if (!member || !guildConfig) { return false; } @@ -64,7 +88,11 @@ export function memberHasConfiguredModeratorRole(member, guildConfig) { * @param {bigint | bigint[] | null} [requiredPermissions] * @returns {boolean} */ -export function memberHasModerationCommandAccess(member, guildConfig, requiredPermissions = null) { +export function memberHasModerationCommandAccess( + member: PermissionMember, + guildConfig: ModeratorRoleConfig, + requiredPermissions: PermissionResolvable | null = null +): boolean { if (!member) { return false; } @@ -92,7 +120,11 @@ export function memberHasModerationCommandAccess(member, guildConfig, requiredPe * @param {{ guildConfig?: object | null, commandCategory?: string | null }} [options] * @returns {boolean} */ -export function memberMeetsCommandPermissions(member, permissionBitfield, options = {}) { +export function memberMeetsCommandPermissions( + member: PermissionMember, + permissionBitfield: bigint | null, + options: { guildConfig?: ModeratorRoleConfig; commandCategory?: string | null } = {} +): boolean { if (permissionBitfield == null) { return true; } @@ -119,12 +151,13 @@ export function memberMeetsCommandPermissions(member, permissionBitfield, option * @returns {Promise} */ export async function checkModerationPermissions( - interaction, - guildConfig, - requiredPermissions, + interaction: ChatInputCommandInteraction, + guildConfig: ModeratorRoleConfig, + requiredPermissions: PermissionResolvable | null, errorMessage = 'You do not have permission to use this command.' -) { - if (memberHasModerationCommandAccess(interaction.member, guildConfig, requiredPermissions)) { +): Promise { + // Guild commands: member is a full GuildMember (as the JS assumed). + if (memberHasModerationCommandAccess(interaction.member as GuildMember | null, guildConfig, requiredPermissions)) { return true; } @@ -148,7 +181,11 @@ export async function checkModerationPermissions( * Slash commands are gated by Discord, but prefix commands must mirror the same requirement in code. * @returns {Promise} true when the member may proceed */ -export async function enforceDefaultCommandPermissions(interaction, command, context = {}) { +export async function enforceDefaultCommandPermissions( + interaction: ChatInputCommandInteraction, + command: Pick | null | undefined, + context: { source?: string; guildConfig?: GuildConfig | null } = {} +): Promise { if (isBotOwner(interaction.user?.id)) { return true; } @@ -158,7 +195,8 @@ export async function enforceDefaultCommandPermissions(interaction, command, con return true; } - const member = interaction.member; + // Prefix/guild invocations: member is a full GuildMember (as the JS assumed). + const member = interaction.member as GuildMember | null; if (memberMeetsCommandPermissions(member, requiredPermissions, { guildConfig: context.guildConfig ?? null, commandCategory: command?.category ?? null, @@ -187,12 +225,12 @@ export async function enforceDefaultCommandPermissions(interaction, command, con return false; } -export function isAdmin(member) { +export function isAdmin(member: PermissionMember): boolean { if (!member) return false; return member.permissions.has(PermissionFlagsBits.Administrator); } -export function isModerator(member, guildConfig = null) { +export function isModerator(member: PermissionMember, guildConfig: ModeratorRoleConfig = null): boolean { if (!member) return false; if (memberHasConfiguredModeratorRole(member, guildConfig)) { return true; @@ -203,12 +241,12 @@ export function isModerator(member, guildConfig = null) { ]); } -export function hasPermission(member, permissions) { +export function hasPermission(member: PermissionMember, permissions: PermissionResolvable): boolean { if (!member) return false; return member.permissions.has(permissions); } -export function botHasPermission(channel, permissions) { +export function botHasPermission(channel: GuildBasedChannel | null | undefined, permissions: PermissionResolvable): boolean { if (!channel || !channel.guild) return false; const botMember = channel.guild.members.me; if (!botMember) return false; @@ -216,11 +254,12 @@ export function botHasPermission(channel, permissions) { } export async function checkUserPermissions( - interaction, - requiredPermissions, + interaction: ChatInputCommandInteraction, + requiredPermissions: PermissionResolvable, errorMessage = 'You do not have permission to use this command.' -) { - const member = interaction.member; +): Promise { + // Guild commands: member is a full GuildMember (as the JS assumed). + const member = interaction.member as GuildMember; if (!member.permissions.has(requiredPermissions)) { await replyUserError(interaction, { @@ -239,11 +278,12 @@ export async function checkUserPermissions( } export async function checkBotPermissions( - interaction, - requiredPermissions, - channel = null -) { - const targetChannel = channel || interaction.channel; + interaction: ChatInputCommandInteraction, + requiredPermissions: PermissionResolvable, + channel: GuildBasedChannel | null = null +): Promise { + // DM channels have no guild; the `.guild` check below rejects them. + const targetChannel = (channel || interaction.channel) as GuildBasedChannel | null; if (!targetChannel || !targetChannel.guild) { await replyUserError(interaction, { @@ -265,7 +305,7 @@ export async function checkBotPermissions( } const permissions = targetChannel.permissionsFor(botMember); - const missingPerms = []; + const missingPerms: PermissionResolvable[] = []; const permArray = Array.isArray(requiredPermissions) ? requiredPermissions : [requiredPermissions]; for (const perm of permArray) { @@ -290,7 +330,7 @@ export async function checkBotPermissions( return true; } -function hashUserId(userId) { +function hashUserId(userId: string): string { let hash = 0; for (let i = 0; i < userId.length; i++) { @@ -301,7 +341,7 @@ function hashUserId(userId) { return Math.abs(hash).toString(16).substring(0, 8); } -export function auditPermissionCheck(userId, action, allowed, reason = null) { +export function auditPermissionCheck(userId: string, action: string, allowed: boolean, reason: string | null = null): void { const userHash = hashUserId(userId); diff --git a/src/utils/postgresDatabase.js b/src/utils/postgresDatabase.ts similarity index 78% rename from src/utils/postgresDatabase.js rename to src/utils/postgresDatabase.ts index 64c5c45eca..884e67485d 100644 --- a/src/utils/postgresDatabase.js +++ b/src/utils/postgresDatabase.ts @@ -13,6 +13,8 @@ import { isTempBackedType, getStructuredListPlan, } from './database/keyParser.js'; +import type { ParsedKey } from './database/keyParser.js'; +import type { KeyValueStore } from './database/wrapper.js'; import { runKeyMigration } from './database/keyMigration.js'; import { tableStatements, @@ -21,7 +23,19 @@ import { triggerDefinitions, } from './database/schema.js'; -function normalizeTimestampInput(value, fallback = new Date()) { +type PgError = Error & { code?: string; detail?: string; hint?: string }; + +export interface VerificationAuditRecord { + guildId: string; + userId: string; + action: string; + source?: string | null; + moderatorId?: string | null; + metadata?: Record; + createdAt?: Date | string | number; +} + +function normalizeTimestampInput(value: unknown, fallback = new Date()): Date { if (value instanceof Date && !Number.isNaN(value.getTime())) { return value; } @@ -37,11 +51,19 @@ function normalizeTimestampInput(value, fallback = new Date()) { } } - const parsedDate = new Date(value); + const parsedDate = new Date(value as string | number | Date); return !Number.isNaN(parsedDate.getTime()) ? parsedDate : fallback; } -class PostgreSQLDatabase { +class PostgreSQLDatabase implements KeyValueStore { + pool: pg.Pool | null; + isConnected: boolean; + connectionPromise: Promise | null; + allowedTableIdentifiers: Set; + allowedMigrationIdentifiers: Set; + lastFailureReason: string | null; + lastFailureMessage: string | null; + constructor() { this.pool = null; this.isConnected = false; @@ -52,7 +74,7 @@ class PostgreSQLDatabase { this.lastFailureMessage = null; } - async connect() { + async connect(): Promise { if (this.connectionPromise) { return this.connectionPromise; } @@ -61,7 +83,7 @@ class PostgreSQLDatabase { return this.connectionPromise; } - async _establishConnection() { + async _establishConnection(): Promise { const retries = Number.isFinite(pgConfig.options.retries) ? pgConfig.options.retries : 0; const baseDelay = Number.isFinite(pgConfig.options.backoffBase) ? pgConfig.options.backoffBase : 100; const multiplier = Number.isFinite(pgConfig.options.backoffMultiplier) ? pgConfig.options.backoffMultiplier : 2; @@ -91,21 +113,21 @@ class PostgreSQLDatabase { await this.createTables(); try { - const columnCheck = await this.pool.query(` + const columnCheck = await this.pool!.query<{ column_name: string }>(` SELECT column_name FROM information_schema.columns WHERE table_name = 'guilds' AND column_name = 'counters' `); if (columnCheck.rows.length === 0) { - await this.pool.query(` + await this.pool!.query(` ALTER TABLE ${pgConfig.tables.guilds} ADD COLUMN counters JSONB DEFAULT '[]' `); logger.info('Added counters column to guilds table'); } } catch (error) { - logger.warn('Could not add counters column to guilds table:', error.message); + logger.warn('Could not add counters column to guilds table:', (error as Error).message); } } @@ -128,7 +150,7 @@ class PostgreSQLDatabase { return true; } - const error = new Error( + const error: PgError = new Error( `Schema version check failed: expected ${migrationCheck.expectedVersion} but found ${migrationCheck.currentVersion === null ? 'none' : migrationCheck.currentVersion}` ); error.code = 'SCHEMA_VERSION_MISMATCH'; @@ -139,20 +161,20 @@ class PostgreSQLDatabase { await this.runStartupKeyMigration(); return true; } catch (error) { - this.lastFailureReason = error.code || 'POSTGRES_CONNECTION_FAILED'; - this.lastFailureMessage = error.message || 'Unknown PostgreSQL error'; + this.lastFailureReason = (error as PgError).code || 'POSTGRES_CONNECTION_FAILED'; + this.lastFailureMessage = (error as PgError).message || 'Unknown PostgreSQL error'; if (this.pool) { try { await this.pool.end(); } catch (closeError) { - logger.warn('Failed to close PostgreSQL pool after error:', closeError.message); + logger.warn('Failed to close PostgreSQL pool after error:', (closeError as Error).message); } this.pool = null; } const isLastAttempt = attempt >= attempts; - const isSchemaMismatch = error.code === 'SCHEMA_VERSION_MISMATCH'; + const isSchemaMismatch = (error as PgError).code === 'SCHEMA_VERSION_MISMATCH'; if (isLastAttempt) { logger.error('Failed to initialize PostgreSQL Database:', error); this.isConnected = false; @@ -165,7 +187,7 @@ class PostgreSQLDatabase { return false; } - logger.warn(`PostgreSQL connection attempt ${attempt} failed: ${error.message}`); + logger.warn(`PostgreSQL connection attempt ${attempt} failed: ${(error as Error).message}`); const backoff = Math.round(baseDelay * Math.pow(multiplier, attempt - 1)); await new Promise(resolve => setTimeout(resolve, backoff)); } @@ -175,7 +197,7 @@ class PostgreSQLDatabase { return false; } - async runStartupKeyMigration() { + async runStartupKeyMigration(): Promise { if (pgConfig.features.autoMigrate === false) { return; } @@ -204,7 +226,7 @@ class PostgreSQLDatabase { }; } - async ensureMigrationLedger() { + async ensureMigrationLedger(): Promise { const migrationTable = assertAllowlistedIdentifier( pgConfig.migration.table, this.allowedMigrationIdentifiers, @@ -212,7 +234,7 @@ class PostgreSQLDatabase { ); const safeMigrationTable = quoteIdentifier(migrationTable); - await this.pool.query(` + await this.pool!.query(` CREATE TABLE IF NOT EXISTS ${safeMigrationTable} ( version INTEGER PRIMARY KEY, label VARCHAR(255) NOT NULL, @@ -225,7 +247,7 @@ class PostgreSQLDatabase { async getLatestSchemaVersion() { const safeMigrationTable = await this.ensureMigrationLedger(); - const result = await this.pool.query( + const result = await this.pool!.query<{ version: number; label: string; applied_at: Date }>( `SELECT version, label, applied_at FROM ${safeMigrationTable} ORDER BY version DESC LIMIT 1` ); @@ -236,9 +258,9 @@ class PostgreSQLDatabase { return result.rows[0]; } - async setSchemaVersion(version, label) { + async setSchemaVersion(version: number, label: string): Promise { const safeMigrationTable = await this.ensureMigrationLedger(); - await this.pool.query( + await this.pool!.query( `INSERT INTO ${safeMigrationTable} (version, label) VALUES ($1, $2) ON CONFLICT (version) @@ -273,10 +295,10 @@ class PostgreSQLDatabase { }; } - async createTables() { + async createTables(): Promise { for (const table of tableStatements) { try { - await this.pool.query(table); + await this.pool!.query(table); } catch (error) { logger.error('Error creating table:', error); } @@ -288,21 +310,21 @@ class PostgreSQLDatabase { await this.createAuditTriggers(); } - async createIndexes() { + async createIndexes(): Promise { for (const index of indexStatements) { try { - await this.pool.query(index); + await this.pool!.query(index); } catch (error) { - logger.warn('Error creating index:', error.message); + logger.warn('Error creating index:', (error as Error).message); } } logger.info('Performance indexes created/verified'); } - async createAuditTriggers() { + async createAuditTriggers(): Promise { try { - await this.pool.query(UPDATE_TIMESTAMP_FUNCTION); + await this.pool!.query(UPDATE_TIMESTAMP_FUNCTION); const triggers = triggerDefinitions; @@ -321,34 +343,34 @@ class PostgreSQLDatabase { 'Trigger table identifier' ); - await this.pool.query( + await this.pool!.query( `DROP TRIGGER IF EXISTS ${quoteIdentifier(safeTriggerIdentifier)} ON ${quoteIdentifier(safeTableIdentifier)};` ); - await this.pool.query( + await this.pool!.query( `CREATE TRIGGER ${quoteIdentifier(safeTriggerIdentifier)} BEFORE UPDATE ON ${quoteIdentifier(safeTableIdentifier)} FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();` ); } catch (error) { - logger.warn(`Error creating trigger ${trigger.name} on ${trigger.table}: ${error.message}`); + logger.warn(`Error creating trigger ${trigger.name} on ${trigger.table}: ${(error as Error).message}`); } } logger.info('Audit triggers created/verified'); } catch (error) { - logger.warn('Error creating audit triggers:', error.message); + logger.warn('Error creating audit triggers:', (error as Error).message); } } - async _getTempValue(key, defaultValue = null) { - const result = await this.pool.query( + async _getTempValue(key: string, defaultValue: unknown = null): Promise { + const result = await this.pool!.query<{ value: unknown }>( `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) { + async _getWithLegacyFallback(canonicalKey: string, originalKey: string, defaultValue: unknown): Promise { let value = await this._getTempValue(canonicalKey, defaultValue); if (value !== defaultValue) { return value; @@ -369,7 +391,10 @@ class PostgreSQLDatabase { return defaultValue; } - async get(key, defaultValue = null) { + get(key: string, defaultValue?: null): Promise; + get(key: string, defaultValue: T): Promise; + get(key: string, defaultValue?: T | null): Promise; + async get(key: string, defaultValue: unknown = null): Promise { try { if (!this.isAvailable()) { logger.warn('PostgreSQL not available, returning default value'); @@ -384,7 +409,7 @@ class PostgreSQLDatabase { } if (parsedKey.type === 'cache') { - const result = await this.pool.query( + const result = await this.pool!.query<{ value: unknown }>( `SELECT value FROM ${pgConfig.tables.cache_data} WHERE key = $1 AND (expires_at IS NULL OR expires_at > NOW())`, [parsedKey.fullKey], ); @@ -410,7 +435,7 @@ class PostgreSQLDatabase { } } - async set(key, value, ttl = null) { + async set(key: string, value: unknown, ttl: number | null = null): Promise { try { if (!this.isAvailable()) { logger.warn('PostgreSQL not available, cannot set value'); @@ -423,7 +448,7 @@ class PostgreSQLDatabase { const jsonValue = JSON.stringify(value ?? null); if (parsedKey.type === 'temp' || isTempBackedType(parsedKey.type)) { - await this.pool.query( + await this.pool!.query( `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`, @@ -433,7 +458,7 @@ class PostgreSQLDatabase { } if (parsedKey.type === 'cache') { - await this.pool.query( + await this.pool!.query( `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`, @@ -449,7 +474,7 @@ class PostgreSQLDatabase { } } - async delete(key) { + async delete(key: string): Promise { try { if (!this.isAvailable()) { logger.warn('PostgreSQL not available, cannot delete key'); @@ -461,21 +486,21 @@ class PostgreSQLDatabase { 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]); + await this.pool!.query(`DELETE FROM ${pgConfig.tables.temp_data} WHERE key = $1`, [parsedKey.fullKey]); deleted = true; } else if (parsedKey.type === 'cache') { - await this.pool.query(`DELETE FROM ${pgConfig.tables.cache_data} WHERE key = $1`, [parsedKey.fullKey]); + await this.pool!.query(`DELETE FROM ${pgConfig.tables.cache_data} WHERE key = $1`, [parsedKey.fullKey]); 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]); + 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]); + await this.pool!.query(`DELETE FROM ${pgConfig.tables.temp_data} WHERE key = $1`, [key]); } return deleted; @@ -485,19 +510,19 @@ class PostgreSQLDatabase { } } - async list(prefix) { + async list(prefix: string): Promise { try { if (!this.isAvailable()) { logger.warn('PostgreSQL not available, returning empty list'); return []; } - const keys = new Set(); + 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( + const tempResult = await this.pool!.query<{ key: string }>( `SELECT key FROM ${pgConfig.tables.temp_data} WHERE key LIKE $1 AND (expires_at IS NULL OR expires_at > NOW())`, [`${tempPrefix}%`], ); @@ -506,7 +531,7 @@ class PostgreSQLDatabase { } } - const cacheResult = await this.pool.query( + const cacheResult = await this.pool!.query<{ key: string }>( `SELECT key FROM ${pgConfig.tables.cache_data} WHERE key LIKE $1 AND (expires_at IS NULL OR expires_at > NOW())`, [`${prefix}%`], ); @@ -515,7 +540,7 @@ class PostgreSQLDatabase { } for (const query of plan.queries) { - const result = await this.pool.query(query.sql, query.params); + const result = await this.pool!.query>(query.sql, query.params); for (const row of result.rows) { keys.add(query.mapKey(row)); } @@ -535,7 +560,7 @@ class PostgreSQLDatabase { } } - async insertVerificationAudit(record) { + async insertVerificationAudit(record: VerificationAuditRecord): Promise { try { if (!this.isAvailable()) { return false; @@ -553,7 +578,7 @@ class PostgreSQLDatabase { const timestamp = createdAt instanceof Date ? createdAt : new Date(createdAt); - await this.pool.query( + await this.pool!.query( `INSERT INTO ${pgConfig.tables.verification_audit} (guild_id, user_id, action, source, moderator_id, metadata, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)`, [guildId, userId, action, source, moderatorId, metadata, timestamp] @@ -566,7 +591,7 @@ class PostgreSQLDatabase { } } - async exists(key) { + async exists(key: string): Promise { try { if (!this.isAvailable()) { return false; @@ -580,7 +605,7 @@ class PostgreSQLDatabase { } } - async increment(key, amount = 1) { + async increment(key: string, amount = 1): Promise { try { if (!this.isAvailable()) { return amount; @@ -596,7 +621,7 @@ class PostgreSQLDatabase { } } - async decrement(key, amount = 1) { + async decrement(key: string, amount = 1): Promise { try { if (!this.isAvailable()) { return -amount; @@ -612,50 +637,50 @@ class PostgreSQLDatabase { } } - async getStructuredData(parsedKey, defaultValue) { + async getStructuredData(parsedKey: ParsedKey, defaultValue: unknown): Promise { try { switch (parsedKey.type) { case 'guild_config': - const guildResult = await this.pool.query( + const guildResult = await this.pool!.query<{ config: unknown }>( `SELECT config FROM ${pgConfig.tables.guilds} WHERE id = $1`, [parsedKey.guildId] ); return guildResult.rows.length > 0 ? guildResult.rows[0].config : defaultValue; case 'guild_birthdays': - const birthdayResult = await this.pool.query( + const birthdayResult = await this.pool!.query<{ user_id: string; month: number; day: number }>( `SELECT user_id, month, day FROM ${pgConfig.tables.birthdays} WHERE guild_id = $1`, [parsedKey.guildId] ); - const birthdays = {}; + const birthdays: Record = {}; birthdayResult.rows.forEach(row => { birthdays[row.user_id] = { month: row.month, day: row.day }; }); return birthdays; case 'guild_giveaways': - const giveawayResult = await this.pool.query( + const giveawayResult = await this.pool!.query<{ data: unknown }>( `SELECT data FROM ${pgConfig.tables.giveaways} WHERE guild_id = $1`, [parsedKey.guildId] ); return giveawayResult.rows.map(row => row.data); case 'welcome_config': - const welcomeResult = await this.pool.query( + const welcomeResult = await this.pool!.query<{ config: unknown }>( `SELECT config FROM ${pgConfig.tables.welcome_configs} WHERE guild_id = $1`, [parsedKey.guildId] ); return welcomeResult.rows.length > 0 ? welcomeResult.rows[0].config : defaultValue; case 'leveling_config': - const levelingConfigResult = await this.pool.query( + const levelingConfigResult = await this.pool!.query<{ config: unknown }>( `SELECT config FROM ${pgConfig.tables.leveling_configs} WHERE guild_id = $1`, [parsedKey.guildId] ); return levelingConfigResult.rows.length > 0 ? levelingConfigResult.rows[0].config : defaultValue; case 'user_level': { - const userLevelResult = await this.pool.query( + const userLevelResult = await this.pool!.query<{ xp: string; level: number; total_xp: string; last_message: Date; rank: number }>( `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] ); @@ -672,7 +697,7 @@ class PostgreSQLDatabase { } case 'economy': { - const economyResult = await this.pool.query( + const economyResult = await this.pool!.query<{ balance: string | null; bank: string | null; data: unknown }>( `SELECT balance, bank, data FROM ${pgConfig.tables.economy} WHERE guild_id = $1 AND user_id = $2`, [parsedKey.guildId, parsedKey.userId] ); @@ -686,7 +711,7 @@ class PostgreSQLDatabase { } case 'afk_status': { - const afkResult = await this.pool.query( + const afkResult = await this.pool!.query<{ reason: string | null; status_at: Date; expires_at: Date | null }>( `SELECT reason, status_at, expires_at FROM ${pgConfig.tables.afk_status} WHERE guild_id = $1 AND user_id = $2`, [parsedKey.guildId, parsedKey.userId], ); @@ -700,14 +725,14 @@ class PostgreSQLDatabase { } case 'ticket': - const ticketResult = await this.pool.query( + const ticketResult = await this.pool!.query<{ data: unknown }>( `SELECT data FROM ${pgConfig.tables.tickets} WHERE guild_id = $1 AND channel_id = $2`, [parsedKey.guildId, parsedKey.channelId] ); return ticketResult.rows.length > 0 ? ticketResult.rows[0].data : defaultValue; case 'counters': - const counterResult = await this.pool.query( + const counterResult = await this.pool!.query<{ counters: unknown }>( `SELECT counters FROM ${pgConfig.tables.guilds} WHERE id = $1`, [parsedKey.guildId] ); @@ -722,11 +747,13 @@ class PostgreSQLDatabase { } } - async setStructuredData(parsedKey, value, ttl) { + async setStructuredData(parsedKey: ParsedKey, value: unknown, ttl: number | null): Promise { + // Stored JSON of unknown shape; field access below mirrors the original untyped code. + const record = value as Record; try { switch (parsedKey.type) { case 'guild_config': - await this.pool.query( + await this.pool!.query( `INSERT INTO ${pgConfig.tables.guilds} (id, config, updated_at) VALUES ($1, $2, CURRENT_TIMESTAMP) ON CONFLICT (id) DO UPDATE SET config = $2, updated_at = CURRENT_TIMESTAMP`, @@ -735,24 +762,24 @@ class PostgreSQLDatabase { return true; case 'guild_birthdays': - await this.pool.query( + await this.pool!.query( `INSERT INTO ${pgConfig.tables.guilds} (id, created_at) VALUES ($1, CURRENT_TIMESTAMP) ON CONFLICT (id) DO NOTHING`, [parsedKey.guildId] ); - await this.pool.query(`DELETE FROM ${pgConfig.tables.birthdays} WHERE guild_id = $1`, [parsedKey.guildId]); + await this.pool!.query(`DELETE FROM ${pgConfig.tables.birthdays} WHERE guild_id = $1`, [parsedKey.guildId]); - for (const [userId, birthday] of Object.entries(value)) { - await this.pool.query( + for (const [userId, birthday] of Object.entries(value as Record)) { + await this.pool!.query( `INSERT INTO ${pgConfig.tables.users} (id, created_at) VALUES ($1, CURRENT_TIMESTAMP) ON CONFLICT (id) DO NOTHING`, [userId] ); - await this.pool.query( + await this.pool!.query( `INSERT INTO ${pgConfig.tables.birthdays} (guild_id, user_id, month, day) VALUES ($1, $2, $3, $4)`, [parsedKey.guildId, userId, birthday.month, birthday.day] @@ -761,16 +788,16 @@ class PostgreSQLDatabase { return true; case 'guild_giveaways': - await this.pool.query( + await this.pool!.query( `INSERT INTO ${pgConfig.tables.guilds} (id, created_at) VALUES ($1, CURRENT_TIMESTAMP) ON CONFLICT (id) DO NOTHING`, [parsedKey.guildId] ); - await this.pool.query(`DELETE FROM ${pgConfig.tables.giveaways} WHERE guild_id = $1`, [parsedKey.guildId]); + await this.pool!.query(`DELETE FROM ${pgConfig.tables.giveaways} WHERE guild_id = $1`, [parsedKey.guildId]); - const giveaways = Array.isArray(value) + const giveaways: Array<{ messageId?: string; endsAt?: string | number } | null | undefined> = Array.isArray(value) ? value : (value && typeof value === 'object' ? Object.values(value) : []); @@ -778,7 +805,7 @@ class PostgreSQLDatabase { if (!giveaway?.messageId) { continue; } - await this.pool.query( + await this.pool!.query( `INSERT INTO ${pgConfig.tables.giveaways} (guild_id, message_id, data, ends_at) VALUES ($1, $2, $3, $4)`, [parsedKey.guildId, giveaway.messageId, giveaway, giveaway.endsAt ? new Date(giveaway.endsAt) : null] @@ -787,14 +814,14 @@ class PostgreSQLDatabase { return true; case 'welcome_config': - await this.pool.query( + await this.pool!.query( `INSERT INTO ${pgConfig.tables.guilds} (id, created_at) VALUES ($1, CURRENT_TIMESTAMP) ON CONFLICT (id) DO NOTHING`, [parsedKey.guildId] ); - await this.pool.query( + await this.pool!.query( `INSERT INTO ${pgConfig.tables.welcome_configs} (guild_id, config, updated_at) VALUES ($1, $2, CURRENT_TIMESTAMP) ON CONFLICT (guild_id) DO UPDATE SET config = $2, updated_at = CURRENT_TIMESTAMP`, @@ -803,14 +830,14 @@ class PostgreSQLDatabase { return true; case 'leveling_config': - await this.pool.query( + await this.pool!.query( `INSERT INTO ${pgConfig.tables.guilds} (id, created_at) VALUES ($1, CURRENT_TIMESTAMP) ON CONFLICT (id) DO NOTHING`, [parsedKey.guildId] ); - await this.pool.query( + await this.pool!.query( `INSERT INTO ${pgConfig.tables.leveling_configs} (guild_id, config, updated_at) VALUES ($1, $2, CURRENT_TIMESTAMP) ON CONFLICT (guild_id) DO UPDATE SET config = $2, updated_at = CURRENT_TIMESTAMP`, @@ -819,89 +846,89 @@ class PostgreSQLDatabase { return true; case 'user_level': - await this.pool.query( + await this.pool!.query( `INSERT INTO ${pgConfig.tables.guilds} (id, created_at) VALUES ($1, CURRENT_TIMESTAMP) ON CONFLICT (id) DO NOTHING`, [parsedKey.guildId] ); - await this.pool.query( + await this.pool!.query( `INSERT INTO ${pgConfig.tables.users} (id, created_at) VALUES ($1, CURRENT_TIMESTAMP) ON CONFLICT (id) DO NOTHING`, [parsedKey.userId] ); - const lastMessageValue = value?.lastMessage ?? value?.last_message; + const lastMessageValue = record?.lastMessage ?? record?.last_message; const normalizedLastMessage = normalizeTimestampInput(lastMessageValue, new Date()); - await this.pool.query( + await this.pool!.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 = $3, level = $4, total_xp = $5, last_message = $6, rank = $7, updated_at = CURRENT_TIMESTAMP`, - [parsedKey.guildId, parsedKey.userId, value.xp || 0, value.level || 0, value.totalXp || 0, normalizedLastMessage, value.rank || 0] + [parsedKey.guildId, parsedKey.userId, record.xp || 0, record.level || 0, record.totalXp || 0, normalizedLastMessage, record.rank || 0] ); return true; case 'economy': - await this.pool.query( + await this.pool!.query( `INSERT INTO ${pgConfig.tables.guilds} (id, created_at) VALUES ($1, CURRENT_TIMESTAMP) ON CONFLICT (id) DO NOTHING`, [parsedKey.guildId] ); - await this.pool.query( + await this.pool!.query( `INSERT INTO ${pgConfig.tables.users} (id, created_at) VALUES ($1, CURRENT_TIMESTAMP) ON CONFLICT (id) DO NOTHING`, [parsedKey.userId] ); - await this.pool.query( + await this.pool!.query( `INSERT INTO ${pgConfig.tables.economy} (guild_id, user_id, balance, bank, data, updated_at) VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP) ON CONFLICT (guild_id, user_id) DO UPDATE SET balance = $3, bank = $4, data = $5, updated_at = CURRENT_TIMESTAMP`, - [parsedKey.guildId, parsedKey.userId, value.wallet ?? value.balance ?? 0, value.bank ?? 0, value] + [parsedKey.guildId, parsedKey.userId, record.wallet ?? record.balance ?? 0, record.bank ?? 0, value] ); return true; case 'afk_status': - await this.pool.query( + await this.pool!.query( `INSERT INTO ${pgConfig.tables.guilds} (id, created_at) VALUES ($1, CURRENT_TIMESTAMP) ON CONFLICT (id) DO NOTHING`, [parsedKey.guildId] ); - await this.pool.query( + await this.pool!.query( `INSERT INTO ${pgConfig.tables.users} (id, created_at) VALUES ($1, CURRENT_TIMESTAMP) ON CONFLICT (id) DO NOTHING`, [parsedKey.userId] ); - await this.pool.query( + await this.pool!.query( `INSERT INTO ${pgConfig.tables.afk_status} (guild_id, user_id, reason, expires_at) 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 ?? value.expires_at) ? new Date(value.expiresAt ?? value.expires_at) : null] + [parsedKey.guildId, parsedKey.userId, record.reason, (record.expiresAt ?? record.expires_at) ? new Date((record.expiresAt ?? record.expires_at) as string | number | Date) : null] ); return true; case 'ticket': - await this.pool.query( + await this.pool!.query( `INSERT INTO ${pgConfig.tables.guilds} (id, created_at) VALUES ($1, CURRENT_TIMESTAMP) ON CONFLICT (id) DO NOTHING`, [parsedKey.guildId] ); - await this.pool.query( + await this.pool!.query( `INSERT INTO ${pgConfig.tables.tickets} (guild_id, channel_id, data, expires_at) VALUES ($1, $2, $3, $4) ON CONFLICT (channel_id) DO UPDATE SET @@ -911,14 +938,14 @@ class PostgreSQLDatabase { return true; case 'counters': - await this.pool.query( + await this.pool!.query( `INSERT INTO ${pgConfig.tables.guilds} (id, created_at) VALUES ($1, CURRENT_TIMESTAMP) ON CONFLICT (id) DO NOTHING`, [parsedKey.guildId] ); - const columnCheck = await this.pool.query(` + const columnCheck = await this.pool!.query<{ column_name: string }>(` SELECT column_name FROM information_schema.columns WHERE table_name = '${pgConfig.tables.guilds}' AND column_name = 'counters' @@ -927,14 +954,14 @@ class PostgreSQLDatabase { if (columnCheck.rows.length === 0) { logger.warn('Counters column does not exist, attempting to add it...'); try { - await this.pool.query(` + await this.pool!.query(` ALTER TABLE ${pgConfig.tables.guilds} ADD COLUMN counters JSONB DEFAULT '[]' `); logger.info('Added counters column to guilds table'); } catch (alterError) { logger.error('Failed to add counters column:', alterError); - throw new Error(`Counters column missing and could not be created: ${alterError.message}`); + throw new Error(`Counters column missing and could not be created: ${(alterError as Error).message}`); } } @@ -944,14 +971,14 @@ class PostgreSQLDatabase { const jsonString = JSON.stringify(normalizedCounters); try { - await this.pool.query( + await this.pool!.query( `INSERT INTO ${pgConfig.tables.guilds} (id, counters, updated_at) VALUES ($1, $2::jsonb, CURRENT_TIMESTAMP) ON CONFLICT (id) DO UPDATE SET counters = $2::jsonb, updated_at = CURRENT_TIMESTAMP`, [parsedKey.guildId, jsonString] ); } catch (queryError) { - logger.error('PostgreSQL query error', { message: queryError.message, detail: queryError.detail, hint: queryError.hint }); + logger.error('PostgreSQL query error', { message: (queryError as PgError).message, detail: (queryError as PgError).detail, hint: (queryError as PgError).hint }); throw queryError; } return true; @@ -965,47 +992,47 @@ class PostgreSQLDatabase { } } - async deleteStructuredData(parsedKey) { + async deleteStructuredData(parsedKey: ParsedKey): Promise { try { switch (parsedKey.type) { case 'guild_config': - await this.pool.query(`DELETE FROM ${pgConfig.tables.guilds} WHERE id = $1`, [parsedKey.guildId]); + await this.pool!.query(`DELETE FROM ${pgConfig.tables.guilds} WHERE id = $1`, [parsedKey.guildId]); return true; case 'guild_birthdays': - await this.pool.query(`DELETE FROM ${pgConfig.tables.birthdays} WHERE guild_id = $1`, [parsedKey.guildId]); + await this.pool!.query(`DELETE FROM ${pgConfig.tables.birthdays} WHERE guild_id = $1`, [parsedKey.guildId]); return true; case 'guild_giveaways': - await this.pool.query(`DELETE FROM ${pgConfig.tables.giveaways} WHERE guild_id = $1`, [parsedKey.guildId]); + await this.pool!.query(`DELETE FROM ${pgConfig.tables.giveaways} WHERE guild_id = $1`, [parsedKey.guildId]); return true; case 'welcome_config': - await this.pool.query(`DELETE FROM ${pgConfig.tables.welcome_configs} WHERE guild_id = $1`, [parsedKey.guildId]); + await this.pool!.query(`DELETE FROM ${pgConfig.tables.welcome_configs} WHERE guild_id = $1`, [parsedKey.guildId]); return true; case 'leveling_config': - await this.pool.query(`DELETE FROM ${pgConfig.tables.leveling_configs} WHERE guild_id = $1`, [parsedKey.guildId]); + await this.pool!.query(`DELETE FROM ${pgConfig.tables.leveling_configs} WHERE guild_id = $1`, [parsedKey.guildId]); return true; case 'user_level': - await this.pool.query(`DELETE FROM ${pgConfig.tables.user_levels} WHERE guild_id = $1 AND user_id = $2`, [parsedKey.guildId, parsedKey.userId]); + await this.pool!.query(`DELETE FROM ${pgConfig.tables.user_levels} WHERE guild_id = $1 AND user_id = $2`, [parsedKey.guildId, parsedKey.userId]); return true; case 'economy': - await this.pool.query(`DELETE FROM ${pgConfig.tables.economy} WHERE guild_id = $1 AND user_id = $2`, [parsedKey.guildId, parsedKey.userId]); + await this.pool!.query(`DELETE FROM ${pgConfig.tables.economy} WHERE guild_id = $1 AND user_id = $2`, [parsedKey.guildId, parsedKey.userId]); return true; case 'afk_status': - await this.pool.query(`DELETE FROM ${pgConfig.tables.afk_status} WHERE guild_id = $1 AND user_id = $2`, [parsedKey.guildId, parsedKey.userId]); + await this.pool!.query(`DELETE FROM ${pgConfig.tables.afk_status} WHERE guild_id = $1 AND user_id = $2`, [parsedKey.guildId, parsedKey.userId]); return true; case 'ticket': - await this.pool.query(`DELETE FROM ${pgConfig.tables.tickets} WHERE guild_id = $1 AND channel_id = $2`, [parsedKey.guildId, parsedKey.channelId]); + 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( + await this.pool!.query( `UPDATE ${pgConfig.tables.guilds} SET counters = '[]'::jsonb, updated_at = CURRENT_TIMESTAMP WHERE id = $1`, [parsedKey.guildId], ); @@ -1020,7 +1047,7 @@ class PostgreSQLDatabase { } } - async disconnect() { + async disconnect(): Promise { try { if (this.pool) { await this.pool.end(); @@ -1037,13 +1064,13 @@ class PostgreSQLDatabase { return null; } - const result = await this.pool.query('SELECT version()'); + const result = await this.pool!.query<{ version: string }>('SELECT version()'); return { version: result.rows[0].version, connected: this.isConnected, - poolSize: this.pool.totalCount, - idleCount: this.pool.idleCount, - waitingCount: this.pool.waitingCount + poolSize: this.pool!.totalCount, + idleCount: this.pool!.idleCount, + waitingCount: this.pool!.waitingCount }; } catch (error) { logger.error('Error getting PostgreSQL info:', error); diff --git a/src/utils/prefixParser.js b/src/utils/prefixParser.ts similarity index 64% rename from src/utils/prefixParser.js rename to src/utils/prefixParser.ts index 53a1e37d15..7a28788369 100644 --- a/src/utils/prefixParser.js +++ b/src/utils/prefixParser.ts @@ -3,7 +3,65 @@ import { resolveSubcommandAlias } from '../config/commands/commandAliases.js'; import { logger } from './logger.js'; -export function parsePrefixCommand(content, prefix) { +/** A command option as found in a command's JSON (only the fields read here). */ +export interface PrefixOptionDef { + name: string; + description?: string; + type: number; + required?: boolean; + options?: readonly PrefixOptionDef[]; +} + +/** The parts of a command's JSON body the prefix parser reads. */ +export interface PrefixCommandJson { + name?: string; + options?: readonly PrefixOptionDef[]; +} + +/** A SlashCommandBuilder (anything with toJSON) or its plain JSON. */ +export interface PrefixCommandData extends PrefixCommandJson { + toJSON?(): PrefixCommandJson; +} + +export interface MissingPrefixOption { + name: string; + description?: string; + type: number; +} + +export interface PrefixValidationResult { + valid: boolean; + missing: MissingPrefixOption[]; + subcommandName?: string | null; + subcommandGroupName?: string | null; + optionDefs?: PrefixOptionDef[]; +} + +/** + * Mimics the subset of CommandInteractionOptionResolver used by commands. + * Values are the raw string arguments; resolving users/channels/roles is done by messageAdapter. + */ +export interface PrefixOptionResolver { + _positional: string[]; + get(name: string): string | null; + getString(name: string): string | null; + getUser(name: string): string | null; + getMember?(name: string): string | null; + getChannel?(name: string): string | null; + getRole?(name: string): string | null; + getInteger(name: string): number | null; + getBoolean(name: string): boolean; + getSubcommand(): string | null; + getSubcommandGroup(): string | null; + validateRequired(): PrefixValidationResult; +} + +export interface ParsedPrefixCommand { + commandName: string; + args: string[]; +} + +export function parsePrefixCommand(content: string | null | undefined, prefix: string): ParsedPrefixCommand | null { if (!content || !content.startsWith(prefix)) { return null; } @@ -29,8 +87,8 @@ export function parsePrefixCommand(content, prefix) { }; } -function parseArguments(input) { - const args = []; +function parseArguments(input: string): string[] { + const args: string[] = []; let current = ''; let inQuote = false; let quoteChar = ''; @@ -75,22 +133,22 @@ function parseArguments(input) { return args; } -export function mapArgumentsToOptions(args, commandData) { - const options = {}; - let subcommandName = null; - let subcommandGroupName = null; +export function mapArgumentsToOptions(args: string[], commandData: PrefixCommandData): PrefixOptionResolver { + const options: Record = {}; + let subcommandName: string | null = null; + let subcommandGroupName: string | null = null; - const cmdData = commandData.toJSON ? commandData.toJSON() : commandData; + const cmdData: PrefixCommandJson = commandData.toJSON ? commandData.toJSON() : commandData; if (!cmdData || !cmdData.options) { return { _positional: args, - get: (name) => args[0] || null, - getString: (name) => args[0] || null, - getUser: (name) => null, - getInteger: (name) => parseInt(args[0]) || null, - getBoolean: (name) => args[0] === 'true', + get: (name: string) => args[0] || null, + getString: (name: string) => args[0] || null, + getUser: (name: string) => null, + getInteger: (name: string) => parseInt(args[0]) || null, + getBoolean: (name: string) => args[0] === 'true', getSubcommand: () => null, getSubcommandGroup: () => null, validateRequired: () => ({ valid: true, missing: [] }) @@ -102,7 +160,7 @@ export function mapArgumentsToOptions(args, commandData) { const hasSubcommands = subcommands.length > 0 && !subcommandGroup; let currentArgs = args; - let optionDefs = []; + let optionDefs: PrefixOptionDef[] = []; logger.debug( `Parsing prefix command: commandName=${cmdData.name}, args=${JSON.stringify(args)}, hasSubcommands=${hasSubcommands}, hasSubcommandGroup=${!!subcommandGroup}, optionsCount=${cmdData.options.length}`, @@ -152,7 +210,7 @@ export function mapArgumentsToOptions(args, commandData) { options[optionDef.name] = value; } - const missing = []; + const missing: MissingPrefixOption[] = []; if (subcommandName || (!hasSubcommands && !subcommandGroup)) { for (const opt of optionDefs) { if (opt.required && !options[opt.name]) { @@ -193,14 +251,14 @@ export function mapArgumentsToOptions(args, commandData) { return { ...options, _positional: args, - get: (name) => options[name] || null, - getString: (name) => options[name] || null, - getUser: (name) => options[name] || null, - getMember: (name) => options[name] || null, - getChannel: (name) => options[name] || null, - getRole: (name) => options[name] || null, - getInteger: (name) => options[name] ? parseInt(options[name]) : null, - getBoolean: (name) => options[name] === 'true', + get: (name: string) => options[name] || null, + getString: (name: string) => options[name] || null, + getUser: (name: string) => options[name] || null, + getMember: (name: string) => options[name] || null, + getChannel: (name: string) => options[name] || null, + getRole: (name: string) => options[name] || null, + getInteger: (name: string) => options[name] ? parseInt(options[name]) : null, + getBoolean: (name: string) => options[name] === 'true', getSubcommand: () => subcommandName, getSubcommandGroup: () => subcommandGroupName, validateRequired: () => ({ diff --git a/src/utils/rateLimiter.js b/src/utils/rateLimiter.ts similarity index 65% rename from src/utils/rateLimiter.js rename to src/utils/rateLimiter.ts index 09bc2423db..3cdfe07942 100644 --- a/src/utils/rateLimiter.js +++ b/src/utils/rateLimiter.ts @@ -2,9 +2,20 @@ import { logger } from './logger.js'; -const rateLimitStore = new Map(); +interface RateLimitEntry { + count: number; + windowStart: number; +} + +export interface RateLimitStatus { + limited: boolean; + remaining: number; + attempts?: number; +} -export async function checkRateLimit(key, maxAttempts = 5, windowMs = 60000) { +const rateLimitStore = new Map(); + +export async function checkRateLimit(key: string, maxAttempts = 5, windowMs = 60000): Promise { try { const now = Date.now(); const entry = rateLimitStore.get(key); @@ -30,7 +41,7 @@ export async function checkRateLimit(key, maxAttempts = 5, windowMs = 60000) { } } -export function getRateLimitStatus(key, windowMs = 60000) { +export function getRateLimitStatus(key: string, windowMs = 60000): RateLimitStatus { const entry = rateLimitStore.get(key); if (!entry) { return { limited: false, remaining: windowMs }; @@ -46,11 +57,11 @@ export function getRateLimitStatus(key, windowMs = 60000) { }; } -export function clearRateLimit(key) { +export function clearRateLimit(key: string): void { rateLimitStore.delete(key); } -export function clearAllRateLimits() { +export function clearAllRateLimits(): void { rateLimitStore.clear(); logger.info('All rate limits cleared'); -} \ No newline at end of file +} diff --git a/src/utils/responseCoordinator.js b/src/utils/responseCoordinator.js deleted file mode 100644 index dac7873481..0000000000 --- a/src/utils/responseCoordinator.js +++ /dev/null @@ -1,197 +0,0 @@ -// responseCoordinator.js — single respond-once gate for prefix and slash commands - -import { buildUserErrorEmbed } from './embeds.js'; -import { logger } from './logger.js'; - -function getCommandJson(commandData) { - return commandData?.toJSON ? commandData.toJSON() : commandData; -} - -export function buildPrefixUsage(prefix, commandData, validation) { - const commandJson = getCommandJson(commandData); - const usageParts = [`${prefix}${commandJson.name}`]; - - if (validation.subcommandGroupName) { - usageParts.push(validation.subcommandGroupName); - } - - if (validation.subcommandName) { - usageParts.push(validation.subcommandName); - } else if (!validation.subcommandGroupName && commandJson.options?.some((opt) => opt.type === 1)) { - usageParts.push('[subcommand]'); - } - - const optionDefs = validation.optionDefs || []; - for (const option of optionDefs) { - usageParts.push(`[${option.name}]`); - } - - return usageParts.filter(Boolean).join(' '); -} - -export class ResponseCoordinator { - constructor(interaction, { message = null } = {}) { - this.interaction = interaction; - this.message = message; - this._replyMessage = null; - this._finalized = false; - this._finalizedReason = null; - } - - static attach(interaction, options = {}) { - if (interaction._responseCoordinator) { - return interaction._responseCoordinator; - } - - const coordinator = new ResponseCoordinator(interaction, options); - interaction._responseCoordinator = coordinator; - return coordinator; - } - - hasResponded() { - return ( - this._finalized - || !!this._replyMessage - || !!this.interaction._replyMessage - || this.interaction.replied - || this.interaction.deferred - ); - } - - isUsageFinalized() { - return this._finalizedReason === 'usage'; - } - - markFinalized(reason) { - this._finalized = true; - this._finalizedReason = reason; - this.interaction.replied = true; - } - - getReplyMessage() { - return this._replyMessage || this.interaction._replyMessage || null; - } - - setReplyMessage(sentMessage) { - this._replyMessage = sentMessage; - this.interaction._replyMessage = sentMessage; - } - - isPrefixInteraction() { - return Boolean(this.interaction._isPrefixCommand || this.message?.channel); - } - - async sendPrefixPayload(payload) { - if (!this.message?.channel) { - return null; - } - - const sentMessage = await this.message.channel.send(payload); - this.setReplyMessage(sentMessage); - return sentMessage; - } - - async deferLocal() { - this.interaction.deferred = true; - return true; - } - - async respond(payload) { - if (this.isUsageFinalized()) { - return this.getReplyMessage(); - } - - const existing = this.getReplyMessage(); - if (existing) { - return this.edit(payload); - } - - this.interaction.replied = true; - - if (this.message?.channel) { - const sentMessage = await this.message.channel.send(payload); - this.setReplyMessage(sentMessage); - return sentMessage; - } - - if (this.interaction.deferred) { - if (this.isPrefixInteraction()) { - return this.sendPrefixPayload(payload); - } - await this.interaction.editReply(payload); - return null; - } - - if (this.interaction.replied) { - if (this.message?.channel) { - return this.message.channel.send(payload); - } - await this.interaction.followUp(payload); - return null; - } - - if (this.isPrefixInteraction()) { - return this.sendPrefixPayload(payload); - } - - await this.interaction.reply(payload); - return null; - } - - async edit(payload) { - if (this.isUsageFinalized()) { - return this.getReplyMessage(); - } - - const existing = this.getReplyMessage(); - if (existing) { - try { - return await existing.edit(payload); - } catch (error) { - logger.debug(`ResponseCoordinator edit failed: ${error.message}`); - if (this.message?.channel) { - const sentMessage = await this.message.channel.send(payload); - this.setReplyMessage(sentMessage); - return sentMessage; - } - throw error; - } - } - - if (this.isPrefixInteraction()) { - return this.sendPrefixPayload(payload); - } - - if (this.interaction.deferred || this.interaction.replied) { - await this.interaction.editReply(payload); - return null; - } - - return this.respond(payload); - } - - async followUp(payload) { - if (this.message?.channel) { - return this.message.channel.send(payload); - } - - return this.interaction.followUp(payload); - } - - async respondUsage(usageLine) { - const embed = buildUserErrorEmbed( - 'validation', - `Usage\n\`${usageLine}\``, - { titleOverride: 'Wrong Usage' } - ); - - const result = await this.respond({ embeds: [embed] }); - this.markFinalized('usage'); - return result; - } - - async respondUsageFromCommand(prefix, commandData, validation) { - const usageLine = buildPrefixUsage(prefix, commandData, validation); - return this.respondUsage(usageLine); - } -} diff --git a/src/utils/responseCoordinator.ts b/src/utils/responseCoordinator.ts new file mode 100644 index 0000000000..40eb3ef2e5 --- /dev/null +++ b/src/utils/responseCoordinator.ts @@ -0,0 +1,250 @@ +// responseCoordinator.js — single respond-once gate for prefix and slash commands + +import { buildUserErrorEmbed } from './embeds.js'; +import { logger } from './logger.js'; +import type { + InteractionEditReplyOptions, + InteractionReplyOptions, + Message, + MessageCreateOptions, + MessageEditOptions, + MessagePayload, + SendableChannels, +} from 'discord.js'; +import type { PrefixCommandData, PrefixCommandJson, PrefixValidationResult } from './prefixParser.js'; + +/** Any payload the coordinator forwards unchanged to reply/editReply/followUp or channel.send. */ +export type ResponsePayload = string | MessagePayload | InteractionReplyOptions | InteractionEditReplyOptions; + +/** + * Response methods typed with the shared payload. Method syntax keeps the parameter check + * bivariant, so discord.js interactions (with their overloaded methods) fit this shape. + */ +export interface PayloadResponder { + reply(payload: ResponsePayload): Promise; + editReply(payload: ResponsePayload): Promise; + followUp(payload: ResponsePayload): Promise; +} + +/** What the coordinator needs from an interaction: real interactions and the prefix mock both fit. */ +export interface CoordinatedInteraction { + deferred: boolean; + replied: boolean; + _replyMessage?: Message | null; + _isPrefixCommand?: boolean; + _responseCoordinator?: ResponseCoordinator; + reply?(payload: ResponsePayload): Promise; + editReply?(payload: ResponsePayload): Promise; + followUp?(payload: ResponsePayload): Promise; +} + +export interface ResponseCoordinatorOptions { + message?: Message | null; +} + +export type PrefixUsageValidation = Pick; + +// Payloads are forwarded to channel.send / message.edit unchanged, exactly as the JS did. +function sendToChannel(message: Message, payload: ResponsePayload): Promise { + // Prefix messages come from channels the bot can post in; discord.js types also include PartialGroupDMChannel. + return (message.channel as SendableChannels).send(payload as string | MessagePayload | MessageCreateOptions); +} + +function getCommandJson(commandData: PrefixCommandData): PrefixCommandJson { + return commandData?.toJSON ? commandData.toJSON() : commandData; +} + +export function buildPrefixUsage(prefix: string, commandData: PrefixCommandData, validation: PrefixUsageValidation): string { + const commandJson = getCommandJson(commandData); + const usageParts = [`${prefix}${commandJson.name}`]; + + if (validation.subcommandGroupName) { + usageParts.push(validation.subcommandGroupName); + } + + if (validation.subcommandName) { + usageParts.push(validation.subcommandName); + } else if (!validation.subcommandGroupName && commandJson.options?.some((opt) => opt.type === 1)) { + usageParts.push('[subcommand]'); + } + + const optionDefs = validation.optionDefs || []; + for (const option of optionDefs) { + usageParts.push(`[${option.name}]`); + } + + return usageParts.filter(Boolean).join(' '); +} + +export class ResponseCoordinator { + interaction: CoordinatedInteraction; + message: Message | null; + _replyMessage: Message | null; + _finalized: boolean; + _finalizedReason: string | null; + + constructor(interaction: CoordinatedInteraction, { message = null }: ResponseCoordinatorOptions = {}) { + this.interaction = interaction; + this.message = message; + this._replyMessage = null; + this._finalized = false; + this._finalizedReason = null; + } + + static attach(interaction: CoordinatedInteraction, options: ResponseCoordinatorOptions = {}): ResponseCoordinator { + if (interaction._responseCoordinator) { + return interaction._responseCoordinator; + } + + const coordinator = new ResponseCoordinator(interaction, options); + interaction._responseCoordinator = coordinator; + return coordinator; + } + + hasResponded(): boolean { + return ( + this._finalized + || !!this._replyMessage + || !!this.interaction._replyMessage + || this.interaction.replied + || this.interaction.deferred + ); + } + + isUsageFinalized(): boolean { + return this._finalizedReason === 'usage'; + } + + markFinalized(reason: string): void { + this._finalized = true; + this._finalizedReason = reason; + this.interaction.replied = true; + } + + getReplyMessage(): Message | null { + return this._replyMessage || this.interaction._replyMessage || null; + } + + setReplyMessage(sentMessage: Message): void { + this._replyMessage = sentMessage; + this.interaction._replyMessage = sentMessage; + } + + isPrefixInteraction(): boolean { + return Boolean(this.interaction._isPrefixCommand || this.message?.channel); + } + + async sendPrefixPayload(payload: ResponsePayload): Promise { + if (!this.message?.channel) { + return null; + } + + const sentMessage = await sendToChannel(this.message, payload); + this.setReplyMessage(sentMessage); + return sentMessage; + } + + async deferLocal(): Promise { + this.interaction.deferred = true; + return true; + } + + async respond(payload: ResponsePayload): Promise { + if (this.isUsageFinalized()) { + return this.getReplyMessage(); + } + + const existing = this.getReplyMessage(); + if (existing) { + return this.edit(payload); + } + + this.interaction.replied = true; + + if (this.message?.channel) { + const sentMessage = await sendToChannel(this.message, payload); + this.setReplyMessage(sentMessage); + return sentMessage; + } + + if (this.interaction.deferred) { + if (this.isPrefixInteraction()) { + return this.sendPrefixPayload(payload); + } + await this.interaction.editReply!(payload); + return null; + } + + if (this.interaction.replied) { + if (this.message?.channel) { + return sendToChannel(this.message, payload); + } + await this.interaction.followUp!(payload); + return null; + } + + if (this.isPrefixInteraction()) { + return this.sendPrefixPayload(payload); + } + + await this.interaction.reply!(payload); + return null; + } + + async edit(payload: ResponsePayload): Promise { + if (this.isUsageFinalized()) { + return this.getReplyMessage(); + } + + const existing = this.getReplyMessage(); + if (existing) { + try { + return await existing.edit(payload as string | MessagePayload | MessageEditOptions); + } catch (error) { + logger.debug(`ResponseCoordinator edit failed: ${(error as Error).message}`); + if (this.message?.channel) { + const sentMessage = await sendToChannel(this.message, payload); + this.setReplyMessage(sentMessage); + return sentMessage; + } + throw error; + } + } + + if (this.isPrefixInteraction()) { + return this.sendPrefixPayload(payload); + } + + if (this.interaction.deferred || this.interaction.replied) { + await this.interaction.editReply!(payload); + return null; + } + + return this.respond(payload); + } + + async followUp(payload: ResponsePayload): Promise { + if (this.message?.channel) { + return sendToChannel(this.message, payload); + } + + return this.interaction.followUp!(payload); + } + + async respondUsage(usageLine: string): Promise { + const embed = buildUserErrorEmbed( + 'validation', + `Usage\n\`${usageLine}\``, + { titleOverride: 'Wrong Usage' } + ); + + const result = await this.respond({ embeds: [embed] }); + this.markFinalized('usage'); + return result; + } + + async respondUsageFromCommand(prefix: string, commandData: PrefixCommandData, validation: PrefixUsageValidation): Promise { + const usageLine = buildPrefixUsage(prefix, commandData, validation); + return this.respondUsage(usageLine); + } +} diff --git a/src/utils/safeMathParser.js b/src/utils/safeMathParser.ts similarity index 83% rename from src/utils/safeMathParser.js rename to src/utils/safeMathParser.ts index bb2f8d6c29..c4af6806ca 100644 --- a/src/utils/safeMathParser.js +++ b/src/utils/safeMathParser.ts @@ -1,6 +1,6 @@ // safeMathParser.js -const SUPPORTED_FUNCTIONS = Object.freeze({ +const SUPPORTED_FUNCTIONS: Readonly number>> = Object.freeze({ sin: Math.sin, cos: Math.cos, tan: Math.tan, @@ -11,12 +11,12 @@ const SUPPORTED_FUNCTIONS = Object.freeze({ exp: Math.exp }); -const SUPPORTED_CONSTANTS = Object.freeze({ +const SUPPORTED_CONSTANTS: Readonly> = Object.freeze({ pi: Math.PI, e: Math.E }); -const OPERATOR_PRECEDENCE = Object.freeze({ +const OPERATOR_PRECEDENCE: Readonly> = Object.freeze({ 'u-': 5, '^': 4, '*': 3, @@ -28,7 +28,11 @@ const OPERATOR_PRECEDENCE = Object.freeze({ const RIGHT_ASSOCIATIVE_OPERATORS = new Set(['^', 'u-']); -function normalizeExpression(input) { +type Token = + | { type: 'number'; value: number } + | { type: 'function' | 'leftParen' | 'rightParen' | 'operator'; value: string }; + +function normalizeExpression(input: unknown): string { if (typeof input !== 'string') { throw new Error('Expression must be a string'); } @@ -42,20 +46,20 @@ function normalizeExpression(input) { .replace(/√/g, 'sqrt'); } -function preprocessDegrees(expression) { +function preprocessDegrees(expression: string): string { return expression.replace(/(\d+(?:\.\d+)?)\s*deg\b/g, '($1*pi/180)'); } -function isDigit(character) { +function isDigit(character: string): boolean { return character >= '0' && character <= '9'; } -function isAlpha(character) { +function isAlpha(character: string): boolean { return (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || character === '_'; } -function tokenize(expression) { - const tokens = []; +function tokenize(expression: string): Token[] { + const tokens: Token[] = []; let index = 0; while (index < expression.length) { @@ -134,10 +138,10 @@ function tokenize(expression) { return tokens; } -function toRpn(tokens) { - const output = []; - const stack = []; - let previousTokenType = null; +function toRpn(tokens: Token[]): Token[] { + const output: Token[] = []; + const stack: Token[] = []; + let previousTokenType: Token['type'] | null = null; for (const token of tokens) { if (token.type === 'number') { @@ -170,7 +174,7 @@ function toRpn(tokens) { const isRightAssociative = RIGHT_ASSOCIATIVE_OPERATORS.has(operatorValue); if ((isRightAssociative && currentPrecedence < topPrecedence) || (!isRightAssociative && currentPrecedence <= topPrecedence)) { - output.push(stack.pop()); + output.push(stack.pop()!); } else { break; } @@ -191,7 +195,7 @@ function toRpn(tokens) { let hasOpeningParen = false; while (stack.length > 0) { - const top = stack.pop(); + const top = stack.pop()!; if (top.type === 'leftParen') { hasOpeningParen = true; break; @@ -205,7 +209,7 @@ function toRpn(tokens) { } if (stack.length > 0 && stack[stack.length - 1].type === 'function') { - output.push(stack.pop()); + output.push(stack.pop()!); } previousTokenType = 'rightParen'; @@ -213,7 +217,7 @@ function toRpn(tokens) { } while (stack.length > 0) { - const top = stack.pop(); + const top = stack.pop()!; if (top.type === 'leftParen' || top.type === 'rightParen') { throw new Error('Mismatched parentheses'); } @@ -223,8 +227,8 @@ function toRpn(tokens) { return output; } -function evaluateRpn(rpnTokens) { - const stack = []; +function evaluateRpn(rpnTokens: Token[]): number { + const stack: number[] = []; for (const token of rpnTokens) { if (token.type === 'number') { @@ -237,7 +241,7 @@ function evaluateRpn(rpnTokens) { if (stack.length < 1) { throw new Error('Invalid expression'); } - stack.push(-stack.pop()); + stack.push(-stack.pop()!); continue; } @@ -245,8 +249,8 @@ function evaluateRpn(rpnTokens) { throw new Error('Invalid expression'); } - const right = stack.pop(); - const left = stack.pop(); + const right = stack.pop()!; + const left = stack.pop()!; if (token.value === '+') stack.push(left + right); else if (token.value === '-') stack.push(left - right); @@ -264,7 +268,7 @@ function evaluateRpn(rpnTokens) { throw new Error('Invalid function usage'); } - const value = stack.pop(); + const value = stack.pop()!; const handler = SUPPORTED_FUNCTIONS[token.value]; if (!handler) { throw new Error(`Unsupported function: ${token.value}`); @@ -281,7 +285,7 @@ function evaluateRpn(rpnTokens) { return stack[0]; } -export function evaluateMathExpression(expression) { +export function evaluateMathExpression(expression: unknown): number { const normalized = preprocessDegrees(normalizeExpression(expression)); const tokens = tokenize(normalized); const rpn = toRpn(tokens); diff --git a/src/utils/schemas.js b/src/utils/schemas.ts similarity index 77% rename from src/utils/schemas.js rename to src/utils/schemas.ts index 1ecc1af4bf..755da5b794 100644 --- a/src/utils/schemas.js +++ b/src/utils/schemas.ts @@ -95,20 +95,45 @@ export const EconomyDataSchema = z lastWithdraw: z.number().int().nonnegative().default(0), xp: z.number().int().nonnegative().default(0), level: z.number().int().nonnegative().default(1), - inventory: z.record(z.any()).default({}), + inventory: z.record(z.unknown()).default({}), cooldowns: z.record(z.number().int().nonnegative()).default({}) }) .passthrough(); -const DEFAULT_LOGGING = { +/** Stored per-guild configuration (passthrough: unknown keys are kept). */ +type GuildConfigData = z.infer; + +/** Stored per-user economy record. */ +export type EconomyData = z.infer; + +type LoggingConfig = z.infer; + +/** Loose view of a stored (possibly legacy) logging config. */ +interface RawLoggingConfig { + enabled?: boolean; + channelId?: string | null; + channels?: { audit?: string | null; applications?: string | null; reports?: string | null } | null; + ignore?: { users?: string[]; channels?: string[] } | null; + enabledEvents?: Record; + [key: string]: unknown; +} + +interface LegacyLoggingFields { + logChannelId?: string | null; + reportChannelId?: string | null; + enableLogging?: unknown; + logIgnore?: { users?: string[]; channels?: string[] } | null; +} + +const DEFAULT_LOGGING: LoggingConfig = { enabled: false, channels: { audit: null, applications: null, reports: null }, ignore: { users: [], channels: [] }, enabledEvents: {}, }; -function migrateLoggingConfig(raw = {}, legacy = {}) { - const base = typeof raw === 'object' && raw !== null ? raw : {}; +function migrateLoggingConfig(raw: unknown = {}, legacy: LegacyLoggingFields = {}): LoggingConfig { + const base: RawLoggingConfig = typeof raw === 'object' && raw !== null ? (raw as RawLoggingConfig) : {}; const { logChannelId, reportChannelId, @@ -157,7 +182,7 @@ function migrateLoggingConfig(raw = {}, legacy = {}) { }; } -export function stripLegacyLoggingFields(config) { +export function stripLegacyLoggingFields(config: T): T { if (!config || typeof config !== 'object') { return config; } @@ -168,19 +193,20 @@ export function stripLegacyLoggingFields(config) { reportChannelId: _reportChannelId, logIgnore: _logIgnore, ...rest - } = config; + } = config as Record; if (rest.logging && typeof rest.logging === 'object') { - const { channelId: _channelId, ...loggingRest } = rest.logging; + const { channelId: _channelId, ...loggingRest } = rest.logging as Record; rest.logging = loggingRest; } - return rest; + return rest as T; } -export function normalizeGuildConfig(raw, defaults = {}) { +export function normalizeGuildConfig(raw: unknown, defaults: Record = {}): GuildConfigData { const base = typeof raw === 'object' && raw !== null ? raw : {}; - const merged = { ...defaults, ...base }; + // Unvalidated until safeParse below; typed as the target shape for the legacy-field migration. + const merged = { ...defaults, ...base } as GuildConfigData; merged.logging = migrateLoggingConfig(merged.logging, { logChannelId: merged.logChannelId, @@ -202,14 +228,15 @@ export function normalizeGuildConfig(raw, defaults = {}) { return stripLegacyLoggingFields(normalized); } -export function normalizeEconomyData(raw, defaults = {}) { +export function normalizeEconomyData(raw: unknown, defaults: Record = {}): EconomyData { const base = typeof raw === 'object' && raw !== null ? raw : {}; const merged = { ...defaults, ...base }; const parsed = EconomyDataSchema.safeParse(merged); - return parsed.success ? parsed.data : { ...defaults, ...base }; + // Fallback keeps the unvalidated record as-is (original behaviour). + return parsed.success ? parsed.data : ({ ...defaults, ...base } as EconomyData); } -export function validateGuildConfigOrThrow(rawConfig, context = {}) { +export function validateGuildConfigOrThrow(rawConfig: unknown, context: Record = {}): GuildConfigData { const normalized = normalizeGuildConfig(rawConfig); const parsed = GuildConfigSchema.safeParse(normalized); diff --git a/src/utils/serviceErrorBoundary.js b/src/utils/serviceErrorBoundary.ts similarity index 55% rename from src/utils/serviceErrorBoundary.js rename to src/utils/serviceErrorBoundary.ts index 52deed4556..8b1da496ec 100644 --- a/src/utils/serviceErrorBoundary.js +++ b/src/utils/serviceErrorBoundary.ts @@ -1,9 +1,28 @@ // serviceErrorBoundary.js import { createError, ErrorTypes, NexoBotError, categorizeError, getUserMessage } from './errorHandler.js'; +import type { ErrorContext, ErrorLike, ErrorType } from './errorHandler.js'; import { resolveErrorCode, getErrorMetadata } from './errorRegistry.js'; -function normalizeBoundaryContext(context = {}) { +export interface ServiceErrorOptions { + context?: ErrorContext | null; + type?: ErrorType; + service?: string; + operation?: string; + errorCode?: string; + message?: string; + userMessage?: string; + expected?: boolean; +} + +/** Static options, or a factory that derives them from the wrapped call's arguments. */ +export type ServiceErrorOptionsInput = ServiceErrorOptions | ((...args: A) => ServiceErrorOptions); + +function isThenable(value: unknown): value is Promise { + return Boolean(value) && typeof (value as { then?: unknown }).then === 'function'; +} + +function normalizeBoundaryContext(context: ErrorContext | null | undefined = {}): ErrorContext { if (!context || typeof context !== 'object') { return {}; } @@ -11,7 +30,7 @@ function normalizeBoundaryContext(context = {}) { return context; } -export function ensureTypedServiceError(error, options = {}) { +export function ensureTypedServiceError(error: unknown, options: ServiceErrorOptions = {}): NexoBotError { if (error instanceof NexoBotError) { return error; } @@ -32,13 +51,13 @@ export function ensureTypedServiceError(error, options = {}) { const errorMetadata = getErrorMetadata(errorCode); const message = options.message || `${service}.${operation} failed`; const userMessage = options.userMessage || getUserMessage(error); - const isExpectedUserFailure = [ + const isExpectedUserFailure = ([ ErrorTypes.VALIDATION, ErrorTypes.USER_INPUT, ErrorTypes.CONFIGURATION, ErrorTypes.PERMISSION, ErrorTypes.RATE_LIMIT, - ].includes(type); + ] as ErrorType[]).includes(type); return createError(message, type, userMessage, { ...context, @@ -48,21 +67,24 @@ export function ensureTypedServiceError(error, options = {}) { remediationHint: errorMetadata.remediation, severity: errorMetadata.severity, retryable: errorMetadata.retryable, - originalErrorMessage: error?.message || String(error), - originalErrorName: error?.name || 'Error', + originalErrorMessage: (error as ErrorLike | null | undefined)?.message || String(error), + originalErrorName: (error as ErrorLike | null | undefined)?.name || 'Error', expected: options.expected ?? isExpectedUserFailure, }); } -export function wrapServiceBoundary(fn, options = {}) { - return function wrappedServiceBoundary(...args) { +export function wrapServiceBoundary( + fn: (this: This, ...args: A) => R, + options: ServiceErrorOptionsInput = {}, +): (this: This, ...args: A) => R { + return function wrappedServiceBoundary(this: This, ...args: A): R { try { const result = fn.apply(this, args); - if (result && typeof result.then === 'function') { + if (isThenable(result)) { return result.catch((error) => { throw ensureTypedServiceError(error, typeof options === 'function' ? options(...args) : options); - }); + }) as R; } return result; @@ -72,15 +94,20 @@ export function wrapServiceBoundary(fn, options = {}) { }; } -export function wrapServiceClassMethods(ServiceClass, optionsFactory) { +export function wrapServiceClassMethods( + ServiceClass: T, + optionsFactory?: (methodName: string, ...args: unknown[]) => ServiceErrorOptions, +): T { + // Static members are looked up and replaced by name. + const staticMembers = ServiceClass as Record; const methodNames = Object.getOwnPropertyNames(ServiceClass) .filter((name) => name !== 'length' && name !== 'name' && name !== 'prototype') - .filter((name) => typeof ServiceClass[name] === 'function'); + .filter((name) => typeof staticMembers[name] === 'function'); for (const methodName of methodNames) { - ServiceClass[methodName] = wrapServiceBoundary( - ServiceClass[methodName], - (...args) => { + staticMembers[methodName] = wrapServiceBoundary( + staticMembers[methodName] as (this: unknown, ...args: unknown[]) => unknown, + (...args: unknown[]) => { const baseOptions = typeof optionsFactory === 'function' ? optionsFactory(methodName, ...args) : {}; diff --git a/src/utils/sqlIdentifiers.js b/src/utils/sqlIdentifiers.ts similarity index 71% rename from src/utils/sqlIdentifiers.js rename to src/utils/sqlIdentifiers.ts index af76f5e063..ec5111658e 100644 --- a/src/utils/sqlIdentifiers.js +++ b/src/utils/sqlIdentifiers.ts @@ -2,7 +2,11 @@ const SQL_IDENTIFIER_PATTERN = /^[a-z_][a-z0-9_]*$/; -export function assertAllowlistedIdentifier(identifier, allowlist, label = 'SQL identifier') { +export function assertAllowlistedIdentifier( + identifier: unknown, + allowlist: ReadonlySet, + label = 'SQL identifier', +): string { if (typeof identifier !== 'string' || identifier.trim().length === 0) { throw new Error(`${label} must be a non-empty string`); } @@ -18,6 +22,6 @@ export function assertAllowlistedIdentifier(identifier, allowlist, label = 'SQL return identifier; } -export function quoteIdentifier(identifier) { +export function quoteIdentifier(identifier: string): string { return `"${identifier}"`; } \ No newline at end of file diff --git a/src/utils/ticket/ticketLogging.js b/src/utils/ticket/ticketLogging.ts similarity index 73% rename from src/utils/ticket/ticketLogging.js rename to src/utils/ticket/ticketLogging.ts index ec16ef0402..3613e0db9d 100644 --- a/src/utils/ticket/ticketLogging.js +++ b/src/utils/ticket/ticketLogging.ts @@ -1,6 +1,15 @@ // ticketLogging.js import { ChannelType } from 'discord.js'; +import type { + Guild, + GuildBasedChannel, + GuildMember, + GuildTextBasedChannel, + MessageCreateOptions, + PermissionsString, +} from 'discord.js'; +import type { NexoClient } from '../../types/index.js'; import { getGuildConfig } from '../../services/config/guildConfig.js'; import { logger } from '../logger.js'; import { @@ -8,8 +17,48 @@ import { formatRatingStars, resolveUserAuthor, } from '../logging/logEmbeds.js'; +import type { LogAuthor, LogField } from '../logging/logEmbeds.js'; + +export interface TicketEventMetadata { + rating?: number | null; + comment?: string | null; + messageCount?: number; + duration?: string | number; + subject?: string; + [key: string]: unknown; +} + +export interface TicketEvent { + /** open | close | delete | claim | unclaim | priority | pin | unpin | transcript | feedback */ + type: string; + ticketId?: string; + ticketNumber?: string | number; + userId?: string; + executorId?: string; + reason?: string | null; + priority?: string; + rating?: number | null; + attachments?: MessageCreateOptions['files']; + metadata?: TicketEventMetadata; +} + +/** Guild config fields read for ticket logging. */ +interface TicketLogConfig { + ticketLogsChannelId?: string | null; + ticketTranscriptChannelId?: string | null; +} + +export interface TicketLoggingConfig { + enabled: boolean; + lifecycleChannelId: string | null; + transcriptChannelId: string | null; +} -export async function logTicketEvent({ client, guildId, event }) { +export async function logTicketEvent({ client, guildId, event }: { + client: NexoClient; + guildId: string; + event: TicketEvent; +}): Promise { try { const guild = client.guilds.cache.get(guildId) || await client.guilds.fetch(guildId).catch(() => null); if (!guild) { @@ -24,13 +73,14 @@ export async function logTicketEvent({ client, guildId, event }) { return; } - const channel = guild.channels.cache.get(logChannelId) || await guild.channels.fetch(logChannelId).catch(() => null); + // Ticket log channels are text channels (as the JS assumed). + const channel = (guild.channels.cache.get(logChannelId) || await guild.channels.fetch(logChannelId).catch(() => null)) as GuildTextBasedChannel | null; if (!channel) { logger.warn(`Ticket log channel not found: ${logChannelId} for event type: ${event.type}`); return; } - const permissions = channel.permissionsFor(guild.members.me); + const permissions = channel.permissionsFor(guild.members.me!); if (!permissions.has(['SendMessages', 'EmbedLinks'])) { logger.warn(`Missing permissions in ticket log channel: ${logChannelId}`); return; @@ -38,7 +88,7 @@ export async function logTicketEvent({ client, guildId, event }) { const embed = await createTicketLogEmbed(guild, event); - const messageOptions = { embeds: [embed] }; + const messageOptions: MessageCreateOptions = { embeds: [embed] }; if (event.attachments && event.attachments.length > 0) { messageOptions.files = event.attachments; @@ -59,7 +109,15 @@ export async function logTicketFeedback({ userId, rating = null, comment = null, -}) { +}: { + client: NexoClient; + guildId: string; + ticketNumber?: string | number; + ticketChannelId?: string; + userId?: string; + rating?: number | null; + comment?: string | null; +}): Promise { await logTicketEvent({ client, guildId, @@ -76,7 +134,7 @@ export async function logTicketFeedback({ }); } -function getLogChannelForEventType(config, eventType) { +function getLogChannelForEventType(config: TicketLogConfig, eventType: string): string | null { switch (eventType) { case 'transcript': return config.ticketTranscriptChannelId || null; @@ -97,7 +155,7 @@ function getLogChannelForEventType(config, eventType) { } } -const TICKET_EVENT_STYLES = { +const TICKET_EVENT_STYLES: Record = { open: { color: 0x5865F2, title: 'Ticket Created' }, close: { color: 0xED4245, title: 'Ticket Closed' }, delete: { color: 0x8b0000, title: 'Ticket Deleted' }, @@ -108,7 +166,7 @@ const TICKET_EVENT_STYLES = { feedback: { color: 0x57F287, title: 'Feedback Received' }, }; -async function createTicketLogEmbed(guild, event) { +async function createTicketLogEmbed(guild: Guild, event: TicketEvent) { const style = TICKET_EVENT_STYLES[event.type] || { color: 0x95a5a6, title: 'Ticket Event' }; const ticketNumber = event.ticketNumber || event.ticketId; const ticketRef = ticketNumber ? `#${ticketNumber}` : 'Unknown'; @@ -116,9 +174,9 @@ async function createTicketLogEmbed(guild, event) { const executorMention = event.executorId ? `<@${event.executorId}>` : null; const userMention = event.userId ? `<@${event.userId}>` : null; - let inlineFields = []; - let fields = []; - let author = null; + let inlineFields: LogField[] = []; + let fields: LogField[] = []; + let author: LogAuthor | null = null; let footer = { text: 'Nexo Ticketing' }; switch (event.type) { @@ -172,7 +230,7 @@ async function createTicketLogEmbed(guild, event) { break; case 'priority': { - const priorityEmojis = { none: '⚪', low: '🔵', medium: '🟢', high: '🟡', urgent: '🔴' }; + const priorityEmojis: Record = { none: '⚪', low: '🔵', medium: '🟢', high: '🟡', urgent: '🔴' }; const priorityLabel = event.priority ? `${priorityEmojis[event.priority] || '⚪'} ${event.priority.charAt(0).toUpperCase()}${event.priority.slice(1)}` : 'Unknown'; @@ -246,8 +304,8 @@ async function createTicketLogEmbed(guild, event) { }); } -export async function getTicketLoggingConfig(client, guildId) { - const config = await getGuildConfig(client, guildId); +export async function getTicketLoggingConfig(client: NexoClient, guildId: string): Promise { + const config: TicketLogConfig = await getGuildConfig(client, guildId); return { enabled: !!(config.ticketLogsChannelId || config.ticketTranscriptChannelId), lifecycleChannelId: config.ticketLogsChannelId || null, @@ -255,7 +313,10 @@ export async function getTicketLoggingConfig(client, guildId) { }; } -export function validateLogChannel(channel, botMember) { +export function validateLogChannel( + channel: GuildBasedChannel | null | undefined, + botMember: GuildMember +): { valid: boolean; error?: string } { if (!channel || channel.type !== ChannelType.GuildText) { return { valid: false, @@ -264,7 +325,7 @@ export function validateLogChannel(channel, botMember) { } const permissions = channel.permissionsFor(botMember); - const requiredPermissions = ['SendMessages', 'EmbedLinks']; + const requiredPermissions: PermissionsString[] = ['SendMessages', 'EmbedLinks']; const missing = requiredPermissions.filter((perm) => !permissions.has(perm)); diff --git a/src/utils/ticket/ticketPermissions.js b/src/utils/ticket/ticketPermissions.ts similarity index 55% rename from src/utils/ticket/ticketPermissions.js rename to src/utils/ticket/ticketPermissions.ts index a4411e8efc..82923d73e1 100644 --- a/src/utils/ticket/ticketPermissions.js +++ b/src/utils/ticket/ticketPermissions.ts @@ -1,11 +1,16 @@ // ticketPermissions.js import { PermissionFlagsBits } from 'discord.js'; +import type { ChatInputCommandInteraction, GuildMember } from 'discord.js'; +import type { NexoClient } from '../../types/index.js'; import { getGuildConfig } from '../../services/config/guildConfig.js'; import { getTicketData } from '../database.js'; -export async function getTicketPermissionContext({ client, interaction }) { - const guildId = interaction.guildId; +export async function getTicketPermissionContext({ client, interaction }: { + client: NexoClient; + interaction: ChatInputCommandInteraction; +}) { + const guildId = interaction.guildId!; const channelId = interaction.channelId; const [config, ticketData] = await Promise.all([ @@ -13,9 +18,11 @@ export async function getTicketPermissionContext({ client, interaction }) { getTicketData(guildId, channelId) ]); - const hasManageChannels = interaction.member.permissions.has(PermissionFlagsBits.ManageChannels); - const staffRoleId = config.ticketStaffRoleId || null; - const hasTicketStaffRole = Boolean(staffRoleId && interaction.member.roles?.cache?.has(staffRoleId)); + // Ticket commands only run in guilds with a full GuildMember (as the JS assumed). + const member = interaction.member as GuildMember; + const hasManageChannels = member.permissions.has(PermissionFlagsBits.ManageChannels); + const staffRoleId: string | null = config.ticketStaffRoleId || null; + const hasTicketStaffRole = Boolean(staffRoleId && member.roles?.cache?.has(staffRoleId)); const isTicketCreator = Boolean( ticketData?.userId && String(ticketData.userId) === String(interaction.user.id), ); @@ -29,4 +36,4 @@ export async function getTicketPermissionContext({ client, interaction }) { canManageTicket: hasManageChannels || hasTicketStaffRole, canCloseTicket: hasManageChannels || hasTicketStaffRole || isTicketCreator, }; -} \ No newline at end of file +} diff --git a/src/utils/validation.js b/src/utils/validation.ts similarity index 77% rename from src/utils/validation.js rename to src/utils/validation.ts index 3c9094eec5..e4bd36968c 100644 --- a/src/utils/validation.js +++ b/src/utils/validation.ts @@ -2,7 +2,7 @@ import { logger } from './logger.js'; -export function validateString(value, fieldName = 'string', maxLength = 2000) { +export function validateString(value: unknown, fieldName = 'string', maxLength = 2000): string | null { if (typeof value !== 'string') { logger.warn(`[VALIDATION] ${fieldName} must be a string, got ${typeof value}`); return null; @@ -21,7 +21,7 @@ export function validateString(value, fieldName = 'string', maxLength = 2000) { return value; } -export function validateNumber(value, fieldName = 'number') { +export function validateNumber(value: unknown, fieldName = 'number'): number | null { if (typeof value !== 'number' || isNaN(value)) { logger.warn(`[VALIDATION] ${fieldName} must be a valid number, got ${value}`); return null; @@ -35,7 +35,7 @@ export function validateNumber(value, fieldName = 'number') { return value; } -export function validateDiscordId(value, fieldName = 'ID') { +export function validateDiscordId(value: unknown, fieldName = 'ID'): string | null { if (typeof value !== 'string') { logger.warn(`[VALIDATION] ${fieldName} must be a string`); return null; @@ -49,7 +49,7 @@ export function validateDiscordId(value, fieldName = 'ID') { return value; } -export function validateCustomId(value, fieldName = 'customId') { +export function validateCustomId(value: unknown, fieldName = 'customId'): string | null { if (typeof value !== 'string' || value.length === 0) { logger.warn(`[VALIDATION] ${fieldName} must be a non-empty string`); return null; @@ -68,7 +68,7 @@ export function validateCustomId(value, fieldName = 'customId') { return value; } -export function validateRequiredProps(obj, requiredProps, objName = 'object') { +export function validateRequiredProps(obj: unknown, requiredProps: string[], objName = 'object'): boolean { if (!obj || typeof obj !== 'object') { logger.warn(`[VALIDATION] ${objName} must be an object`); return false; @@ -84,7 +84,7 @@ export function validateRequiredProps(obj, requiredProps, objName = 'object') { return true; } -export function validateUrl(value, fieldName = 'URL') { +export function validateUrl(value: unknown, fieldName = 'URL'): string | null { if (typeof value !== 'string' || value.length === 0) { logger.warn(`[VALIDATION] ${fieldName} must be a non-empty string`); return null; @@ -99,7 +99,7 @@ export function validateUrl(value, fieldName = 'URL') { } } -export function validateRange(value, min, max, fieldName = 'value') { +export function validateRange(value: unknown, min: number, max: number, fieldName = 'value'): number | null { if (typeof value !== 'number' || isNaN(value)) { logger.warn(`[VALIDATION] ${fieldName} must be a number`); return null; @@ -113,7 +113,7 @@ export function validateRange(value, min, max, fieldName = 'value') { return value; } -export function validateEnum(value, allowedValues, fieldName = 'value') { +export function validateEnum(value: T, allowedValues: readonly T[], fieldName = 'value'): T | null { if (!allowedValues.includes(value)) { logger.warn(`[VALIDATION] ${fieldName} must be one of: ${allowedValues.join(', ')}`); return null; @@ -122,7 +122,7 @@ export function validateEnum(value, allowedValues, fieldName = 'value') { return value; } -export function sanitizeMarkdown(text) { +export function sanitizeMarkdown(text: unknown): string { if (typeof text !== 'string') return ''; return text @@ -135,7 +135,7 @@ export function sanitizeMarkdown(text) { .replace(/~/g, '\\~'); } -export function sanitizeInput(input, maxLength = 2000) { +export function sanitizeInput(input: unknown, maxLength = 2000): string { if (typeof input !== 'string') return ''; return input @@ -144,15 +144,15 @@ export function sanitizeInput(input, maxLength = 2000) { .replace(/[\x00-\x1F\x7F]/g, ''); } -export function sanitizeMention(mention) { +export function sanitizeMention(mention: string): string | null { const validId = mention.replace(/[<@!&#]/g, ''); return /^\d+$/.test(validId) ? validId : null; } -export function escapeHtml(text) { +export function escapeHtml(text: unknown): string { if (typeof text !== 'string') return ''; - const map = { + const map: Record = { '&': '&', '<': '<', '>': '>', diff --git a/src/utils/welcome.js b/src/utils/welcome.ts similarity index 68% rename from src/utils/welcome.js rename to src/utils/welcome.ts index 8c474a18a6..232ab6dbdc 100644 --- a/src/utils/welcome.js +++ b/src/utils/welcome.ts @@ -7,14 +7,36 @@ const DEFAULT_TEMPLATES = { goodbye: '{user.tag} has left the server.' }; -function replaceAll(message, token, value) { +/** Fields read from a (possibly partial) discord.js User. */ +interface WelcomeFormatUser { + toString?(): string; + tag?: string | null; + username?: string | null; + discriminator?: string | null; + id?: string; +} + +/** Fields read from a discord.js Guild. */ +interface WelcomeFormatGuild { + name?: string; + id?: string; + memberCount?: number; +} + +export interface WelcomeFormatData { + user?: WelcomeFormatUser | null; + guild?: WelcomeFormatGuild | null; + [key: string]: unknown; +} + +function replaceAll(message: string, token: string, value: unknown): string { if (value === undefined || value === null) { return message; } return message.split(token).join(String(value)); } -export function truncateForEmbedField(value, maxLength = 1024) { +export function truncateForEmbedField(value: unknown, maxLength = 1024): string { const text = String(value ?? '').trim(); if (!text) { return '—'; @@ -22,7 +44,7 @@ export function truncateForEmbedField(value, maxLength = 1024) { return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`; } -export function formatWelcomeMessage(message, data) { +export function formatWelcomeMessage(message: unknown, data: WelcomeFormatData | null | undefined): string { if (typeof message !== 'string') return ''; if (!message) return ''; @@ -38,7 +60,7 @@ export function formatWelcomeMessage(message, data) { logger.warn('Invalid guild object passed to formatWelcomeMessage'); } - const tokens = { + const tokens: Record = { '{user}': user?.toString?.() || 'User', '{user.mention}': user?.toString?.() || 'User', '{user.tag}': user?.tag || 'Unknown#0000', @@ -64,10 +86,10 @@ export function formatWelcomeMessage(message, data) { return result; } -export function getDefaultWelcomeMessage() { +export function getDefaultWelcomeMessage(): string { return DEFAULT_TEMPLATES.welcome; } -export function getDefaultGoodbyeMessage() { +export function getDefaultGoodbyeMessage(): string { return DEFAULT_TEMPLATES.goodbye; -} \ No newline at end of file +} diff --git a/tsconfig.json b/tsconfig.json index 119700d157..235ab3889d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,7 @@ "forceConsistentCasingInFileNames": true, "allowJs": true, "checkJs": false, - "strict": false + "strict": true }, "include": ["src"] } From 4519bc98fa5864b727d12e6a76d07f5a3daf2f45 Mon Sep 17 00:00:00 2001 From: robertwalter Date: Sat, 17 Oct 2026 16:26:54 +0200 Subject: [PATCH 05/20] refactor(ts): convert services to TypeScript Co-Authored-By: Claude Opus 5.5 --- ...cationService.js => applicationService.ts} | 73 +++--- ...{birthdayService.js => birthdayService.ts} | 56 +++-- ...cessService.js => commandAccessService.ts} | 82 +++++-- .../{configService.js => configService.ts} | 96 +++++--- .../config/{guildConfig.js => guildConfig.ts} | 20 +- ...gGameService.js => countingGameService.ts} | 64 ++++-- .../{economyService.js => economyService.ts} | 27 ++- ...{giveawayService.js => giveawayService.ts} | 51 +++-- ...reateService.js => joinToCreateService.ts} | 95 +++++--- ...SyncService.js => levelRoleSyncService.ts} | 29 ++- .../leveling/{leveling.js => leveling.ts} | 83 +++++-- .../leveling/{xpSystem.js => xpSystem.ts} | 29 ++- .../{loggingService.js => loggingService.ts} | 96 ++++++-- ...erationService.js => moderationService.ts} | 69 ++++-- .../{warningService.js => warningService.ts} | 48 ++-- .../{musicActions.js => musicActions.ts} | 207 ++++++++++-------- .../music/{musicEmbeds.js => musicEmbeds.ts} | 39 +++- ...{musicVoiceState.js => musicVoiceState.ts} | 10 +- .../music/{permissions.js => permissions.ts} | 7 +- .../{playerHandler.js => playerHandler.ts} | 36 +-- .../music/{playerStore.js => playerStore.ts} | 28 ++- src/services/music/prefixSupport.js | 10 - src/services/music/prefixSupport.ts | 12 + .../music/{riffySetup.js => riffySetup.ts} | 20 +- ...HealthService.js => panelHealthService.ts} | 44 ++-- ...nRoleService.js => reactionRoleService.ts} | 131 ++++++----- ...rstatsService.js => serverstatsService.ts} | 72 ++++-- src/services/{ticket.js => ticket.ts} | 119 ++++++---- ...ationService.js => verificationService.ts} | 83 ++++--- src/types/index.ts | 2 + src/utils/database/tickets.ts | 2 +- 31 files changed, 1145 insertions(+), 595 deletions(-) rename src/services/{applicationService.js => applicationService.ts} (85%) rename src/services/{birthdayService.js => birthdayService.ts} (82%) rename src/services/{commandAccessService.js => commandAccessService.ts} (71%) rename src/services/config/{configService.js => configService.ts} (84%) rename src/services/config/{guildConfig.js => guildConfig.ts} (76%) rename src/services/{countingGameService.js => countingGameService.ts} (77%) rename src/services/{economyService.js => economyService.ts} (91%) rename src/services/{giveawayService.js => giveawayService.ts} (88%) rename src/services/{joinToCreateService.js => joinToCreateService.ts} (83%) rename src/services/leveling/{levelRoleSyncService.js => levelRoleSyncService.ts} (77%) rename src/services/leveling/{leveling.js => leveling.ts} (78%) rename src/services/leveling/{xpSystem.js => xpSystem.ts} (79%) rename src/services/{loggingService.js => loggingService.ts} (78%) rename src/services/moderation/{moderationService.js => moderationService.ts} (87%) rename src/services/moderation/{warningService.js => warningService.ts} (69%) rename src/services/music/{musicActions.js => musicActions.ts} (66%) rename src/services/music/{musicEmbeds.js => musicEmbeds.ts} (77%) rename src/services/music/{musicVoiceState.js => musicVoiceState.ts} (79%) rename src/services/music/{permissions.js => permissions.ts} (54%) rename src/services/music/{playerHandler.js => playerHandler.ts} (86%) rename src/services/music/{playerStore.js => playerStore.ts} (54%) delete mode 100644 src/services/music/prefixSupport.js create mode 100644 src/services/music/prefixSupport.ts rename src/services/music/{riffySetup.js => riffySetup.ts} (72%) rename src/services/{panelHealthService.js => panelHealthService.ts} (78%) rename src/services/{reactionRoleService.js => reactionRoleService.ts} (74%) rename src/services/{serverstatsService.js => serverstatsService.ts} (72%) rename src/services/{ticket.js => ticket.ts} (89%) rename src/services/{verificationService.js => verificationService.ts} (87%) diff --git a/src/services/applicationService.js b/src/services/applicationService.ts similarity index 85% rename from src/services/applicationService.js rename to src/services/applicationService.ts index e322ad4c1a..adcfe8bac2 100644 --- a/src/services/applicationService.js +++ b/src/services/applicationService.ts @@ -3,6 +3,7 @@ import { logger } from '../utils/logger.js'; import { createError, ErrorTypes } from '../utils/errorHandler.js'; import { PermissionFlagsBits } from 'discord.js'; +import type { GuildMember } from 'discord.js'; import { sanitizeInput, sanitizeMarkdown } from '../utils/validation.js'; import { getApplicationSettings, @@ -15,17 +16,31 @@ import { getApplicationRoles, saveApplicationRoles } from '../utils/database.js'; +import type { ApplicationFilters, ApplicationInput, ApplicationSettings } from '../utils/database.js'; import botConfig from '../config/bot.js'; +import type { NexoClient } from '../types/index.js'; -const applicationCooldowns = new Map(); +export interface ApplicationReviewData { + action: string; + reason?: string | null; + reviewerId: string; +} + +export interface ApplicationRoleChange { + action: string; + roleId?: string | null; + name?: string | null; +} + +const applicationCooldowns = new Map(); const APPLICATION_SUBMIT_COOLDOWN = (botConfig.applications?.applicationCooldown ?? 24) * 60 * 60 * 1000; class ApplicationService { - static sanitizeApplicationText(value, maxLength) { + static sanitizeApplicationText(value: unknown, maxLength: number): string { return sanitizeMarkdown(sanitizeInput(String(value ?? ''), maxLength)); } - static validateApplicationSubmission(data) { + static validateApplicationSubmission(data: ApplicationInput): boolean { if (!data.guildId || !data.userId || !data.roleId) { throw createError( 'Missing required fields for application submission', @@ -79,7 +94,7 @@ class ApplicationService { return true; } - static checkApplicationCooldown(userId) { + static checkApplicationCooldown(userId: string): boolean { const now = Date.now(); const cooldownKey = `submit_${userId}`; const lastSubmit = applicationCooldowns.get(cooldownKey); @@ -98,13 +113,13 @@ class ApplicationService { return true; } - static async checkManagerPermission(client, guildId, member) { + static async checkManagerPermission(client: NexoClient, guildId: string, member: GuildMember): Promise { const settings = await getApplicationSettings(client, guildId); const isManager = member.permissions.has(PermissionFlagsBits.ManageGuild) || - (settings.managerRoles && - settings.managerRoles.some(roleId => member.roles.cache.has(roleId))); + ((settings.managerRoles as string[] | undefined) && + (settings.managerRoles as string[]).some(roleId => member.roles.cache.has(roleId))); if (!isManager) { throw createError( @@ -118,7 +133,7 @@ class ApplicationService { return true; } - static async submitApplication(client, data) { + static async submitApplication(client: NexoClient, data: ApplicationInput) { try { this.validateApplicationSubmission(data); @@ -149,7 +164,7 @@ class ApplicationService { const sanitizedData = { ...data, - answers: data.answers.map(answer => ({ + answers: data.answers!.map(answer => ({ question: this.sanitizeApplicationText(answer.question, 200), answer: this.sanitizeApplicationText(answer.answer, 1000) })) @@ -168,16 +183,16 @@ class ApplicationService { return application; } catch (error) { logger.error('Error submitting application', { - error: error.message, + error: (error as Error).message, userId: data.userId, guildId: data.guildId, - stack: error.stack + stack: (error as Error).stack }); throw error; } } - static async reviewApplication(client, guildId, applicationId, reviewData) { + static async reviewApplication(client: NexoClient, guildId: string, applicationId: string, reviewData: ApplicationReviewData) { try { const { action, reason, reviewerId } = reviewData; @@ -230,16 +245,16 @@ class ApplicationService { return updatedApplication; } catch (error) { logger.error('Error reviewing application', { - error: error.message, + error: (error as Error).message, applicationId, guildId, - stack: error.stack + stack: (error as Error).stack }); throw error; } } - static async getApplicationsList(client, guildId, filters = {}) { + static async getApplicationsList(client: NexoClient, guildId: string, filters: ApplicationFilters = {}) { try { const applications = await getApplications(client, guildId, filters); @@ -252,10 +267,10 @@ class ApplicationService { return applications; } catch (error) { logger.error('Error getting applications list', { - error: error.message, + error: (error as Error).message, guildId, filters, - stack: error.stack + stack: (error as Error).stack }); throw createError( 'Failed to retrieve applications', @@ -266,7 +281,7 @@ class ApplicationService { } } - static async updateSettings(client, guildId, updates) { + static async updateSettings(client: NexoClient, guildId: string, updates: Partial) { try { if (updates.logChannelId && typeof updates.logChannelId !== 'string') { @@ -313,16 +328,16 @@ class ApplicationService { return updatedSettings; } catch (error) { logger.error('Error updating application settings', { - error: error.message, + error: (error as Error).message, guildId, updates, - stack: error.stack + stack: (error as Error).stack }); throw error; } } - static async manageApplicationRoles(client, guildId, data) { + static async manageApplicationRoles(client: NexoClient, guildId: string, data: ApplicationRoleChange) { try { const { action, roleId, name } = data; @@ -391,16 +406,16 @@ class ApplicationService { return currentRoles; } catch (error) { logger.error('Error managing application roles', { - error: error.message, + error: (error as Error).message, guildId, data, - stack: error.stack + stack: (error as Error).stack }); throw error; } } - static async getUserApplications(client, guildId, userId) { + static async getUserApplications(client: NexoClient, guildId: string, userId: string) { try { const applications = await getUserApplications(client, guildId, userId); @@ -413,10 +428,10 @@ class ApplicationService { return applications; } catch (error) { logger.error('Error getting user applications', { - error: error.message, + error: (error as Error).message, guildId, userId, - stack: error.stack + stack: (error as Error).stack }); throw createError( 'Failed to retrieve your applications', @@ -427,7 +442,7 @@ class ApplicationService { } } - static async getSingleApplication(client, guildId, applicationId) { + static async getSingleApplication(client: NexoClient, guildId: string, applicationId: string) { try { const application = await getApplication(client, guildId, applicationId); @@ -443,10 +458,10 @@ class ApplicationService { return application; } catch (error) { logger.error('Error getting application', { - error: error.message, + error: (error as Error).message, applicationId, guildId, - stack: error.stack + stack: (error as Error).stack }); throw error; } diff --git a/src/services/birthdayService.js b/src/services/birthdayService.ts similarity index 82% rename from src/services/birthdayService.js rename to src/services/birthdayService.ts index e2cde99e3a..8c489c4661 100644 --- a/src/services/birthdayService.js +++ b/src/services/birthdayService.ts @@ -4,8 +4,12 @@ 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 { NexoBotError, ErrorTypes } from '../utils/errorHandler.js'; +import type { GuildMember, GuildTextBasedChannel } from 'discord.js'; +import type { NexoClient } from '../types/index.js'; -export function validateBirthday(month, day) { +export type BirthdayValidation = { isValid: true } | { isValid: false; error: string }; + +export function validateBirthday(month: unknown, day: unknown): BirthdayValidation { if (typeof month !== 'number' || typeof day !== 'number') { return { @@ -41,7 +45,7 @@ export function validateBirthday(month, day) { return { isValid: true }; } -export async function setBirthday(client, guildId, userId, month, day) { +export async function setBirthday(client: NexoClient, guildId: string, userId: string, month: number, day: number) { try { const validation = validateBirthday(month, day); @@ -90,8 +94,8 @@ export async function setBirthday(client, guildId, userId, month, day) { }; } catch (error) { logger.error('Error in setBirthday service', { - error: error.message, - stack: error.stack, + error: (error as Error).message, + stack: (error as Error).stack, userId, guildId, month, @@ -102,7 +106,7 @@ export async function setBirthday(client, guildId, userId, month, day) { } } -export async function getUserBirthday(client, guildId, userId) { +export async function getUserBirthday(client: NexoClient, guildId: string, userId: string) { try { const birthdays = await getGuildBirthdays(client, guildId); const birthdayData = birthdays[userId]; @@ -118,7 +122,7 @@ export async function getUserBirthday(client, guildId, userId) { }; } catch (error) { logger.error('Error in getUserBirthday service', { - error: error.message, + error: (error as Error).message, userId, guildId }); @@ -126,7 +130,7 @@ export async function getUserBirthday(client, guildId, userId) { } } -export async function getAllBirthdays(client, guildId) { +export async function getAllBirthdays(client: NexoClient, guildId: string) { try { const birthdays = await getGuildBirthdays(client, guildId); @@ -149,14 +153,14 @@ export async function getAllBirthdays(client, guildId) { return sortedBirthdays; } catch (error) { logger.error('Error in getAllBirthdays service', { - error: error.message, + error: (error as Error).message, guildId }); throw error; } } -export async function deleteBirthday(client, guildId, userId) { +export async function deleteBirthday(client: NexoClient, guildId: string, userId: string) { try { const birthday = await getUserBirthday(client, guildId, userId); @@ -188,7 +192,7 @@ export async function deleteBirthday(client, guildId, userId) { }; } catch (error) { logger.error('Error in deleteBirthday service', { - error: error.message, + error: (error as Error).message, userId, guildId }); @@ -196,7 +200,7 @@ export async function deleteBirthday(client, guildId, userId) { } } -export async function getUpcomingBirthdays(client, guildId, limit = 5) { +export async function getUpcomingBirthdays(client: NexoClient, guildId: string, limit = 5) { try { const birthdays = await getGuildBirthdays(client, guildId); @@ -207,7 +211,14 @@ export async function getUpcomingBirthdays(client, guildId, limit = 5) { const today = new Date(); const currentYear = today.getFullYear(); - const upcomingBirthdays = []; + const upcomingBirthdays: Array<{ + userId: string; + month: number; + day: number; + monthName: string; + date: Date; + daysUntil: number; + }> = []; for (const [userId, userData] of Object.entries(birthdays)) { let nextBirthday = new Date(currentYear, userData.month - 1, userData.day); @@ -216,7 +227,7 @@ export async function getUpcomingBirthdays(client, guildId, limit = 5) { nextBirthday = new Date(currentYear + 1, userData.month - 1, userData.day); } - const daysUntil = Math.ceil((nextBirthday - today) / (1000 * 60 * 60 * 24)); + const daysUntil = Math.ceil((nextBirthday.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); upcomingBirthdays.push({ userId, @@ -233,7 +244,7 @@ export async function getUpcomingBirthdays(client, guildId, limit = 5) { return upcomingBirthdays.slice(0, limit); } catch (error) { logger.error('Error in getUpcomingBirthdays service', { - error: error.message, + error: (error as Error).message, guildId, limit }); @@ -241,14 +252,14 @@ export async function getUpcomingBirthdays(client, guildId, limit = 5) { } } -export async function getTodaysBirthdays(client, guildId) { +export async function getTodaysBirthdays(client: NexoClient, guildId: string) { try { const birthdays = await getGuildBirthdays(client, guildId); const today = new Date(); const currentMonth = today.getUTCMonth() + 1; const currentDay = today.getUTCDate(); - const todaysBirthdays = []; + const todaysBirthdays: Array<{ userId: string; month: number; day: number; monthName: string }> = []; for (const [userId, userData] of Object.entries(birthdays)) { if (userData.month === currentMonth && userData.day === currentDay) { @@ -264,14 +275,14 @@ export async function getTodaysBirthdays(client, guildId) { return todaysBirthdays; } catch (error) { logger.error('Error in getTodaysBirthdays service', { - error: error.message, + error: (error as Error).message, guildId }); throw error; } } -export async function checkBirthdays(client) { +export async function checkBirthdays(client: NexoClient): Promise { const today = new Date(); const currentMonth = today.getUTCMonth() + 1; const currentDay = today.getUTCDate(); @@ -293,11 +304,12 @@ export async function checkBirthdays(client) { continue; } - const channel = await guild.channels.fetch(birthdayChannelId).catch(() => null); + // Not narrowed in the original: a non-text channel throws on send and is logged below. + const channel = await guild.channels.fetch(birthdayChannelId).catch(() => null) as GuildTextBasedChannel | null; if (!channel) continue; const trackingKey = getBirthdayTrackingKey(guildId); - const trackingData = (await client.db.get(trackingKey)) || {}; + const trackingData = (await client.db.get>(trackingKey)) || {}; const updatedTrackingData = { ...trackingData }; for (const userId of Object.keys(trackingData)) { @@ -320,7 +332,7 @@ export async function checkBirthdays(client) { // Use the canonical birthday storage (guild::birthdays) that set/remove commands write to. const birthdays = (await getGuildBirthdays(client, guildId)) || {}; - const birthdayMembers = []; + const birthdayMembers: GuildMember[] = []; for (const [userId, userData] of Object.entries(birthdays)) { if (userData.month === currentMonth && userData.day === currentDay) { const member = await guild.members.fetch(userId).catch(() => null); @@ -348,7 +360,7 @@ export async function checkBirthdays(client) { description: `A very happy birthday to ${mentionList}! Wishing you an amazing day! 🎈`, color: 0xff69b4, footer: { text: 'Birthday Bot' }, - timestamp: new Date() + timestamp: new Date().toISOString() }] }); } diff --git a/src/services/commandAccessService.js b/src/services/commandAccessService.ts similarity index 71% rename from src/services/commandAccessService.js rename to src/services/commandAccessService.ts index f33a343b10..5113e44c3d 100644 --- a/src/services/commandAccessService.js +++ b/src/services/commandAccessService.ts @@ -7,8 +7,33 @@ import { getCategoryIcon, PROTECTED_COMMANDS, } from '../config/commands/commandCategories.js'; +import type { RESTPostAPIChatInputApplicationCommandsJSONBody } from 'discord.js'; +import type { GuildConfigContext } from '../utils/database/guildConfigStorage.js'; +import type { CommandData, NexoClient } from '../types/index.js'; + +export interface CommandRegistryEntry { + name: string; + description: string; + protected: boolean; + isSubcommand: boolean; + parentCommand?: string; +} + +export interface CommandRegistryCategory { + key: string; + folder: string; + displayName: string; + icon: string; + commands: CommandRegistryEntry[]; +} + +/** The config fields this service reads; values may be legacy arrays or toggle records. */ +export interface CommandAccessConfig { + disabledCommands?: unknown; + disabledCategories?: unknown; +} -function normalizeToggleRecord(raw) { +function normalizeToggleRecord(raw: unknown): Record { if (!raw) { return {}; } @@ -26,8 +51,8 @@ function normalizeToggleRecord(raw) { return {}; } -export function buildCommandRegistry(client) { - const categories = new Map(); +export function buildCommandRegistry(client: NexoClient): Map { + const categories = new Map(); for (const command of client.commands.values()) { if (!command?.data?.name) { @@ -48,20 +73,20 @@ export function buildCommandRegistry(client) { } // Add the main command - categories.get(categoryKey).commands.push({ + categories.get(categoryKey)!.commands.push({ name: command.data.name, - description: command.data.description || 'No description', + description: (command.data as CommandData & { description?: string }).description || 'No description', protected: PROTECTED_COMMANDS.has(command.data.name.toLowerCase()), isSubcommand: false, }); // Add subcommands if they exist - const commandJson = command.data.toJSON?.() || {}; + const commandJson: Partial = command.data.toJSON?.() || {}; for (const option of commandJson.options || []) { if (option.type === 1) { const subcommandName = `${command.data.name} ${option.name}`; - categories.get(categoryKey).commands.push({ + categories.get(categoryKey)!.commands.push({ name: subcommandName, description: option.description || 'No description', protected: false, @@ -74,7 +99,7 @@ export function buildCommandRegistry(client) { for (const sub of option.options || []) { if (sub.type === 1) { const subcommandName = `${command.data.name} ${option.name} ${sub.name}`; - categories.get(categoryKey).commands.push({ + categories.get(categoryKey)!.commands.push({ name: subcommandName, description: sub.description || 'No description', protected: false, @@ -94,7 +119,9 @@ export function buildCommandRegistry(client) { return categories; } -export function getCategoryRegistry(client, categoryKey = null) { +export function getCategoryRegistry(client: NexoClient, categoryKey?: null): Map; +export function getCategoryRegistry(client: NexoClient, categoryKey: string): CommandRegistryCategory | null; +export function getCategoryRegistry(client: NexoClient, categoryKey: string | null = null): Map | CommandRegistryCategory | null { const registry = buildCommandRegistry(client); if (!categoryKey) { @@ -104,11 +131,11 @@ export function getCategoryRegistry(client, categoryKey = null) { return registry.get(normalizeCategoryKey(categoryKey)) || null; } -export function isProtectedCommand(commandName) { +export function isProtectedCommand(commandName: string | null | undefined): boolean { return PROTECTED_COMMANDS.has(String(commandName || '').toLowerCase()); } -export function isCommandEnabledInConfig(config, commandName, category) { +export function isCommandEnabledInConfig(config: CommandAccessConfig | null | undefined, commandName: string, category: string | null | undefined): boolean { const normalizedName = String(commandName || '').toLowerCase(); // Check if it's a subcommand (contains space) @@ -142,7 +169,7 @@ export function isCommandEnabledInConfig(config, commandName, category) { return true; } -export async function isCommandEnabled(client, guildId, commandName, category = null) { +export async function isCommandEnabled(client: NexoClient, guildId: string, commandName: string, category: string | null = null): Promise { const config = await getGuildConfig(client, guildId); let resolvedCategory = category; @@ -154,17 +181,24 @@ export async function isCommandEnabled(client, guildId, commandName, category = return isCommandEnabledInConfig(config, commandName, resolvedCategory); } -export function getCommandAccessSnapshot(client, config) { +export function getCommandAccessSnapshot(client: NexoClient, config: CommandAccessConfig | null | undefined) { const registry = buildCommandRegistry(client); const disabledCommands = normalizeToggleRecord(config?.disabledCommands); const disabledCategories = normalizeToggleRecord(config?.disabledCategories); - const categories = []; + const categories: Array = []; for (const category of registry.values()) { const categoryDisabled = Boolean(disabledCategories[category.key]); - const enabledCommands = []; - const disabledCommandNames = []; + const enabledCommands: string[] = []; + const disabledCommandNames: string[] = []; for (const command of category.commands) { const enabled = isCommandEnabledInConfig(config, command.name, category.folder); @@ -201,11 +235,11 @@ export function getCommandAccessSnapshot(client, config) { }; } -async function persistAccessConfig(client, guildId, updates, context = {}) { +async function persistAccessConfig(client: NexoClient, guildId: string, updates: Record, context: GuildConfigContext = {}) { return updateGuildConfig(client, guildId, updates, context); } -export function resolveCommandTarget(client, commandName) { +export function resolveCommandTarget(client: NexoClient, commandName: string | null | undefined): CommandRegistryEntry | null { const normalizedName = String(commandName || '').toLowerCase().trim(); const registry = buildCommandRegistry(client); @@ -219,7 +253,7 @@ export function resolveCommandTarget(client, commandName) { return null; } -export async function disableCommand(client, guildId, commandName, context = {}) { +export async function disableCommand(client: NexoClient, guildId: string, commandName: string, context: GuildConfigContext = {}) { const normalizedName = String(commandName || '').toLowerCase().trim(); const target = resolveCommandTarget(client, normalizedName); @@ -240,7 +274,7 @@ export async function disableCommand(client, guildId, commandName, context = {}) return { commandName: normalizedName, enabled: false }; } -export async function enableCommand(client, guildId, commandName, context = {}) { +export async function enableCommand(client: NexoClient, guildId: string, commandName: string, context: GuildConfigContext = {}) { const normalizedName = String(commandName || '').toLowerCase().trim(); const target = resolveCommandTarget(client, normalizedName); @@ -256,7 +290,7 @@ export async function enableCommand(client, guildId, commandName, context = {}) return { commandName: normalizedName, enabled: true }; } -export async function disableCategory(client, guildId, categoryKey, context = {}) { +export async function disableCategory(client: NexoClient, guildId: string, categoryKey: string, context: GuildConfigContext = {}) { const normalizedKey = normalizeCategoryKey(categoryKey); const category = getCategoryRegistry(client, normalizedKey); @@ -272,7 +306,7 @@ export async function disableCategory(client, guildId, categoryKey, context = {} return { categoryKey: normalizedKey, displayName: category.displayName, enabled: false }; } -export async function enableCategory(client, guildId, categoryKey, context = {}) { +export async function enableCategory(client: NexoClient, guildId: string, categoryKey: string, context: GuildConfigContext = {}) { const normalizedKey = normalizeCategoryKey(categoryKey); const category = getCategoryRegistry(client, normalizedKey); @@ -288,7 +322,7 @@ export async function enableCategory(client, guildId, categoryKey, context = {}) return { categoryKey: normalizedKey, displayName: category.displayName, enabled: true }; } -export async function resetCategoryCommands(client, guildId, categoryKey, context = {}) { +export async function resetCategoryCommands(client: NexoClient, guildId: string, categoryKey: string, context: GuildConfigContext = {}) { const normalizedKey = normalizeCategoryKey(categoryKey); const category = getCategoryRegistry(client, normalizedKey); @@ -307,7 +341,7 @@ export async function resetCategoryCommands(client, guildId, categoryKey, contex return { categoryKey: normalizedKey, displayName: category.displayName }; } -export function resolveCategoryChoice(client, input) { +export function resolveCategoryChoice(client: NexoClient, input: string | null | undefined): CommandRegistryCategory | null { if (!input) { return null; } diff --git a/src/services/config/configService.js b/src/services/config/configService.ts similarity index 84% rename from src/services/config/configService.js rename to src/services/config/configService.ts index 465cd1b2e4..7298e20bd1 100644 --- a/src/services/config/configService.js +++ b/src/services/config/configService.ts @@ -3,15 +3,44 @@ import { logger } from '../../utils/logger.js'; import { getGuildConfig, setGuildConfig } from './guildConfig.js'; import { PermissionFlagsBits } from 'discord.js'; +import type { Guild, GuildMember } from 'discord.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 type { ErrorLike } from '../../utils/errorHandler.js'; +import type { NexoClient } from '../../types/index.js'; + +interface ConfigValidationRule { + type: 'channel' | 'role' | 'string' | 'boolean' | 'number' | 'object'; + required: boolean; + maxLength?: number; + minLength?: number; + min?: number; + max?: number; +} + +/** Loose view of a stored guild config for the legacy logging-key migration. */ +type ConfigRecord = { + logging?: { channels?: Record; enabled?: boolean; [key: string]: unknown }; + [key: string]: unknown; +}; + +export interface ConfigChangeRecord { + key: string; + oldValue: unknown; + newValue: unknown; + changedBy: string; + timestamp: string; + conflicts?: string[]; + isBulkUpdate?: boolean; + isReset?: boolean; +} -const configChangeHistory = new Map(); +const configChangeHistory = new Map(); const CONFIG_HISTORY_LIMIT = 100; -const CONFIG_VALIDATION_RULES = { +const CONFIG_VALIDATION_RULES: Record = { logChannelId: { type: 'channel', required: false }, reportChannelId: { type: 'channel', required: false }, premiumRoleId: { type: 'role', required: false }, @@ -26,17 +55,17 @@ const CONFIG_VALIDATION_RULES = { logging: { type: 'object', required: false } }; -const SETTING_CONFLICTS = { +const SETTING_CONFLICTS: Record = { 'birthdayChannelId': [], 'logging': [], }; -const LEGACY_LOGGING_KEY_MAP = { +const LEGACY_LOGGING_KEY_MAP: Record = { logChannelId: 'audit', reportChannelId: 'reports', }; -const ConfigValueSchemas = Object.freeze({ +const ConfigValueSchemas: Readonly>> = Object.freeze({ logChannelId: z.union([z.string().min(1), z.object({ id: z.string().min(1) }), z.null()]), reportChannelId: z.union([z.string().min(1), z.object({ id: z.string().min(1) }), z.null()]), premiumRoleId: z.union([z.string().min(1), z.object({ id: z.string().min(1) })]), @@ -56,16 +85,16 @@ class ConfigService { static MAX_CHANNEL_IDS = 10; static MAX_ROLE_IDS = 20; static MAX_PREFIX_LENGTH = 10; - static PROTECTED_SETTINGS = ['_id', 'guildId', 'createdAt']; - static UNSAFE_KEYS = ['__proto__', 'prototype', 'constructor']; + static PROTECTED_SETTINGS: string[] = ['_id', 'guildId', 'createdAt']; + static UNSAFE_KEYS: string[] = ['__proto__', 'prototype', 'constructor']; - static applyLoggingLegacyKey(config, key, value, previousConfig = {}) { + static applyLoggingLegacyKey(config: ConfigRecord, key: string, value: unknown, previousConfig: ConfigRecord = {}): ConfigRecord { if (key === 'logIgnore') { const logging = { ...(previousConfig.logging || config.logging || {}), ignore: value, }; - const next = { ...config, logging }; + const next: ConfigRecord = { ...config, logging }; delete next.logIgnore; return next; } @@ -75,7 +104,7 @@ class ConfigService { return config; } - const channelId = value && typeof value === 'object' ? value.id : value; + const channelId = value && typeof value === 'object' ? (value as { id?: unknown }).id : value; const logging = { ...(previousConfig.logging || config.logging || {}), channels: { @@ -85,7 +114,7 @@ class ConfigService { enabled: channelId ? true : (previousConfig.logging?.enabled ?? config.logging?.enabled ?? false), }; - const next = { ...config, logging }; + const next: ConfigRecord = { ...config, logging }; delete next[key]; if (key === 'logChannelId') { delete next.enableLogging; @@ -96,7 +125,7 @@ class ConfigService { return next; } - static validateConfigKeySafety(key) { + static validateConfigKeySafety(key: string): void { if (typeof key !== 'string' || key.trim().length === 0) { throw createError( 'Invalid setting key', @@ -116,7 +145,7 @@ class ConfigService { } } - static async validateConfigValue(key, value, guild) { + static async validateConfigValue(key: string, value: unknown, guild: Guild): Promise { logger.debug(`[CONFIG_SERVICE] Validating config value`, { key, type: typeof value }); const rule = CONFIG_VALIDATION_RULES[key]; @@ -161,7 +190,7 @@ class ConfigService { ); } - const channelId = typeof value === 'string' ? value : value.id; + const channelId = typeof value === 'string' ? value : (value as { id: string }).id; const channel = guild.channels.cache.get(channelId); if (!channel) { @@ -195,7 +224,7 @@ class ConfigService { ); } - const roleId = typeof value === 'string' ? value : value.id; + const roleId = typeof value === 'string' ? value : (value as { id: string }).id; const role = guild.roles.cache.get(roleId); if (!role) { @@ -208,7 +237,7 @@ class ConfigService { } const botHighestRole = guild.members.me?.roles.highest; - if (role.position >= botHighestRole?.position) { + if (botHighestRole !== undefined && role.position >= botHighestRole.position) { throw createError( 'Role too high', ErrorTypes.VALIDATION, @@ -312,10 +341,10 @@ class ConfigService { return true; } - static detectConflicts(currentConfig, key, value) { + static detectConflicts(currentConfig: ConfigRecord, key: string, value: unknown): string[] { logger.debug(`[CONFIG_SERVICE] Checking for config conflicts`, { key }); - const conflicts = []; + const conflicts: string[] = []; const relatedSettings = SETTING_CONFLICTS[key] || []; for (const related of relatedSettings) { @@ -332,7 +361,7 @@ class ConfigService { return conflicts; } - static async updateSetting(client, guildId, key, value, adminId) { + static async updateSetting(client: NexoClient, guildId: string, key: string, value: unknown, adminId: string) { logger.info(`[CONFIG_SERVICE] Updating setting`, { guildId, key, @@ -382,7 +411,7 @@ class ConfigService { const oldValue = currentConfig[key]; - let updatedConfig = { ...currentConfig, [key]: value }; + let updatedConfig: ConfigRecord = { ...currentConfig, [key]: value }; updatedConfig = this.applyLoggingLegacyKey(updatedConfig, key, value, currentConfig); await setGuildConfig(client, guildId, updatedConfig); @@ -414,7 +443,7 @@ class ConfigService { }; } - static async bulkUpdate(client, guildId, updates, adminId) { + static async bulkUpdate(client: NexoClient, guildId: string, updates: Record, adminId: string) { logger.info(`[CONFIG_SERVICE] Bulk updating settings`, { guildId, updateCount: Object.keys(updates).length, @@ -431,8 +460,8 @@ class ConfigService { ); } - const validatedUpdates = {}; - const validationErrors = []; + const validatedUpdates: Record = {}; + const validationErrors: string[] = []; for (const [key, value] of Object.entries(updates)) { try { @@ -446,7 +475,8 @@ class ConfigService { await this.validateConfigValue(key, value, guild); validatedUpdates[key] = value; } catch (error) { - validationErrors.push(`${key}: ${error.details?.message || error.message}`); + const err = error as ErrorLike & { details?: { message?: string } }; + validationErrors.push(`${key}: ${err.details?.message || err.message}`); } } @@ -495,12 +525,12 @@ class ConfigService { }; } - static recordChange(guildId, changeData) { + static recordChange(guildId: string, changeData: ConfigChangeRecord): void { if (!configChangeHistory.has(guildId)) { configChangeHistory.set(guildId, []); } - const history = configChangeHistory.get(guildId); + const history = configChangeHistory.get(guildId)!; history.push(changeData); if (history.length > CONFIG_HISTORY_LIMIT) { @@ -514,12 +544,12 @@ class ConfigService { }); } - static getChangeHistory(guildId, limit = 20) { + static getChangeHistory(guildId: string, limit = 20): ConfigChangeRecord[] { const history = configChangeHistory.get(guildId) || []; return history.slice(-limit).reverse(); } - static async resetSetting(client, guildId, key, adminId) { + static async resetSetting(client: NexoClient, guildId: string, key: string, adminId: string) { logger.info(`[CONFIG_SERVICE] Resetting setting`, { guildId, key, @@ -558,7 +588,7 @@ class ConfigService { }; } - static async getConfigSummary(client, guildId) { + static async getConfigSummary(client: NexoClient, guildId: string) { logger.debug(`[CONFIG_SERVICE] Fetching config summary`, { guildId }); const config = await getGuildConfig(client, guildId); @@ -573,7 +603,7 @@ class ConfigService { ); } - const summary = {}; + const summary: Record = {}; for (const [key, value] of Object.entries(config)) { if (this.PROTECTED_SETTINGS.includes(key)) continue; @@ -582,14 +612,14 @@ class ConfigService { if (!rule) continue; if (rule.type === 'channel' && value) { - const channel = guild.channels.cache.get(value); + const channel = guild.channels.cache.get(value as string); summary[key] = { id: value, name: channel?.name || 'Unknown', status: channel ? 'Valid' : 'Missing' }; } else if (rule.type === 'role' && value) { - const role = guild.roles.cache.get(value); + const role = guild.roles.cache.get(value as string); summary[key] = { id: value, name: role?.name || 'Unknown', @@ -607,7 +637,7 @@ class ConfigService { }; } - static verifyPermission(member) { + static verifyPermission(member: GuildMember): boolean { return member.permissions.has([ PermissionFlagsBits.Administrator, PermissionFlagsBits.ManageGuild diff --git a/src/services/config/guildConfig.js b/src/services/config/guildConfig.ts similarity index 76% rename from src/services/config/guildConfig.js rename to src/services/config/guildConfig.ts index e48348d397..99c62b5c88 100644 --- a/src/services/config/guildConfig.js +++ b/src/services/config/guildConfig.ts @@ -3,11 +3,13 @@ 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 type { GuildConfigContext } from '../../utils/database/guildConfigStorage.js'; import { createError, ErrorTypes, wrapServiceBoundary } from '../../utils/errorHandler.js'; +import type { GuildConfig, NexoClient } from '../../types/index.js'; export { GUILD_CONFIG_DEFAULTS }; -export const getGuildConfig = wrapServiceBoundary(async function getGuildConfig(client, guildId, context = {}) { +export const getGuildConfig = wrapServiceBoundary(async function getGuildConfig(client: NexoClient, guildId: string, context: GuildConfigContext = {}): Promise { const config = await readGuildConfig(client, guildId, context); return normalizeGuildConfig(config, GUILD_CONFIG_DEFAULTS); }, { @@ -17,7 +19,7 @@ export const getGuildConfig = wrapServiceBoundary(async function getGuildConfig( userMessage: 'Failed to load server configuration. Please try again.', }); -export const setGuildConfig = wrapServiceBoundary(async function setGuildConfig(client, guildId, config, context = {}) { +export const setGuildConfig = wrapServiceBoundary(async function setGuildConfig(client: NexoClient, guildId: string, config: unknown, context: GuildConfigContext = {}): Promise { const normalized = normalizeGuildConfig(config, GUILD_CONFIG_DEFAULTS); return await writeGuildConfig(client, guildId, normalized, context); }, { @@ -27,7 +29,7 @@ export const setGuildConfig = wrapServiceBoundary(async function setGuildConfig( userMessage: 'Failed to save server configuration. Please try again.', }); -export const updateGuildConfig = wrapServiceBoundary(async function updateGuildConfig(client, guildId, updates, context = {}) { +export const updateGuildConfig = wrapServiceBoundary(async function updateGuildConfig(client: NexoClient, guildId: string, updates: Record, context: GuildConfigContext = {}): Promise { const currentConfig = await readGuildConfig(client, guildId, context); const merged = { ...currentConfig, ...updates }; const normalized = normalizeGuildConfig(merged, GUILD_CONFIG_DEFAULTS); @@ -39,7 +41,7 @@ export const updateGuildConfig = wrapServiceBoundary(async function updateGuildC userMessage: 'Failed to update server configuration. Please try again.', }); -export const getConfigValue = wrapServiceBoundary(async function getConfigValue(client, guildId, key, defaultValue = null, context = {}) { +export const getConfigValue = wrapServiceBoundary(async function getConfigValue(client: NexoClient, guildId: string, key: string, defaultValue: unknown = null, context: GuildConfigContext = {}): Promise { const config = await getGuildConfig(client, guildId, context); return config[key] !== undefined ? config[key] : defaultValue; }, { @@ -49,7 +51,7 @@ export const getConfigValue = wrapServiceBoundary(async function getConfigValue( userMessage: 'Failed to read a server setting. Please try again.', }); -export const setConfigValue = wrapServiceBoundary(async function setConfigValue(client, guildId, key, value, context = {}) { +export const setConfigValue = wrapServiceBoundary(async function setConfigValue(client: NexoClient, guildId: string, key: string, value: unknown, context: GuildConfigContext = {}): Promise { return await updateGuildConfig(client, guildId, { [key]: value }, context); }, { service: 'guildConfigService', @@ -61,7 +63,7 @@ export const setConfigValue = wrapServiceBoundary(async function setConfigValue( /** * Merge partial updates into a nested config object (e.g. verification, logging). */ -export const patchGuildConfig = wrapServiceBoundary(async function patchGuildConfig(client, guildId, patch, context = {}) { +export const patchGuildConfig = wrapServiceBoundary(async function patchGuildConfig(client: NexoClient, guildId: string, patch: Record, context: GuildConfigContext = {}): Promise { if (!patch || typeof patch !== 'object') { throw createError( 'Invalid guild config patch', @@ -83,8 +85,8 @@ export const patchGuildConfig = wrapServiceBoundary(async function patchGuildCon userMessage: 'Failed to update server configuration. Please try again.', }); -function deepMergeGuildConfig(base, patch) { - const result = { ...base }; +function deepMergeGuildConfig(base: Record, patch: Record): Record { + const result: Record = { ...base }; for (const [key, value] of Object.entries(patch)) { if ( @@ -95,7 +97,7 @@ function deepMergeGuildConfig(base, patch) { typeof base[key] === 'object' && !Array.isArray(base[key]) ) { - result[key] = { ...base[key], ...value }; + result[key] = { ...(base[key] as Record), ...value }; } else { result[key] = value; } diff --git a/src/services/countingGameService.js b/src/services/countingGameService.ts similarity index 77% rename from src/services/countingGameService.js rename to src/services/countingGameService.ts index 2325205426..f6042d6556 100644 --- a/src/services/countingGameService.js +++ b/src/services/countingGameService.ts @@ -1,8 +1,30 @@ import { logger } from '../utils/logger.js'; +import type { Guild } from 'discord.js'; +import type { NexoClient } from '../types/index.js'; + +interface CountingSystem { + label: string; + description: string; + alphabet?: string; + toString: (n: number) => string; + parse: (value: string) => number | null; +} + +/** Stored counting game state (`countingGame:{guildId}`). */ +export interface CountingGameState { + enabled: boolean; + channelId: string | null; + system: string; + nextNumber: number; + lastUserId: string | null; + currentStreak: number; + bestStreak: number; + leaderboard: Record; +} const COUNTING_GAME_KEY_PREFIX = 'countingGame:'; -const COUNTING_SYSTEMS = { +const COUNTING_SYSTEMS: Record = { decimal: { label: 'Decimal', description: 'Standard 10-number system using 0-9', @@ -45,7 +67,7 @@ const COUNTING_SYSTEMS = { alphabet: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/', toString: (n) => { if (n === 0) return 'A'; - const alphabet = COUNTING_SYSTEMS.base64.alphabet; + const alphabet = COUNTING_SYSTEMS.base64.alphabet!; let value = n; let result = ''; while (value > 0) { @@ -56,7 +78,7 @@ const COUNTING_SYSTEMS = { return result; }, parse: (value) => { - const alphabet = COUNTING_SYSTEMS.base64.alphabet; + const alphabet = COUNTING_SYSTEMS.base64.alphabet!; const normalized = value.replace(/=+$/, ''); if (!/^[A-Za-z0-9+/]+$/.test(normalized)) { return null; @@ -74,7 +96,7 @@ const COUNTING_SYSTEMS = { label: 'Roman', description: 'Roman numerals like I, II, III, IV, V', toString: (n) => { - const romanNumerals = [ + const romanNumerals: Array<[string, number]> = [ ['M', 1000], ['CM', 900], ['D', 500], ['CD', 400], ['C', 100], ['XC', 90], ['L', 50], ['XL', 40], ['X', 10], ['IX', 9], ['V', 5], ['IV', 4], ['I', 1], @@ -92,7 +114,7 @@ const COUNTING_SYSTEMS = { parse: (value) => { const roman = value.toUpperCase(); if (!/^[IVXLCDM]+$/.test(roman)) return null; - const romanValues = { + const romanValues: Record = { I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000, }; let total = 0; @@ -121,7 +143,7 @@ const COUNTING_SYSTEMS = { if (!/^[0-9+\-*/().=**]+$/.test(sanitized)) return null; const parts = sanitized.split('='); try { - const evaluate = (expr) => { + const evaluate = (expr: string): unknown => { if (!expr || /[^0-9+\-*/().]/.test(expr)) return null; // eslint-disable-next-line no-new-func return Function(`"use strict"; return (${expr});`)(); @@ -169,7 +191,7 @@ const COUNTING_SYSTEMS = { }, }; -const DEFAULT_COUNTING_GAME = { +const DEFAULT_COUNTING_GAME: CountingGameState = { enabled: false, channelId: null, system: 'decimal', @@ -180,8 +202,8 @@ const DEFAULT_COUNTING_GAME = { leaderboard: {}, }; -function normalizeCountingGame(state) { - const normalized = { +function normalizeCountingGame(state?: Partial | null): CountingGameState { + const normalized: CountingGameState = { ...DEFAULT_COUNTING_GAME, ...(state || {}), }; @@ -194,13 +216,13 @@ function normalizeCountingGame(state) { return normalized; } -function getStorageKey(guildId) { +function getStorageKey(guildId: string): string { return `${COUNTING_GAME_KEY_PREFIX}${guildId}`; } -export async function getCountingGameConfig(client, guildId) { +export async function getCountingGameConfig(client: NexoClient, guildId: string): Promise { try { - const rawState = await client.db.get(getStorageKey(guildId)); + const rawState = await client.db.get>(getStorageKey(guildId)); return normalizeCountingGame(rawState); } catch (error) { logger.error('Failed to load counting game config:', { guildId, error }); @@ -208,18 +230,18 @@ export async function getCountingGameConfig(client, guildId) { } } -export async function saveCountingGameConfig(client, guildId, state) { +export async function saveCountingGameConfig(client: NexoClient, guildId: string, state: Partial | null | undefined): Promise { const normalized = normalizeCountingGame(state); await client.db.set(getStorageKey(guildId), normalized); return normalized; } -export async function disableCountingGame(client, guildId) { +export async function disableCountingGame(client: NexoClient, guildId: string): Promise { const config = await getCountingGameConfig(client, guildId); return saveCountingGameConfig(client, guildId, { ...config, enabled: false }); } -export async function resetCountingGame(client, guildId, startNumber = 1) { +export async function resetCountingGame(client: NexoClient, guildId: string, startNumber = 1): Promise { const config = await getCountingGameConfig(client, guildId); return saveCountingGameConfig(client, guildId, { ...config, @@ -229,7 +251,7 @@ export async function resetCountingGame(client, guildId, startNumber = 1) { }); } -export async function recordCorrectCount(client, guildId, userId) { +export async function recordCorrectCount(client: NexoClient, guildId: string, userId: string): Promise { const config = await getCountingGameConfig(client, guildId); const leaderboard = { ...config.leaderboard }; leaderboard[userId] = (leaderboard[userId] || 0) + 1; @@ -253,16 +275,16 @@ export function getCountingSystemChoices() { })); } -export function getCountingSystemLabel(systemKey) { +export function getCountingSystemLabel(systemKey: string): string { return COUNTING_SYSTEMS[systemKey]?.label || COUNTING_SYSTEMS.decimal.label; } -export function getExpectedCountValue(config) { +export function getExpectedCountValue(config: CountingGameState): string { const system = COUNTING_SYSTEMS[config.system] ? config.system : 'decimal'; return COUNTING_SYSTEMS[system].toString(config.nextNumber || 1); } -export function isValidCountingMessage(content, config) { +export function isValidCountingMessage(content: string, config: CountingGameState): boolean { const trimmed = content.trim(); const system = COUNTING_SYSTEMS[config.system] ? config.system : 'decimal'; const current = COUNTING_SYSTEMS[system]; @@ -278,7 +300,7 @@ export function isValidCountingMessage(content, config) { return trimmed === expected; } -export async function activateCountingGame(client, guildId, channelId, system = 'decimal') { +export async function activateCountingGame(client: NexoClient, guildId: string, channelId: string, system = 'decimal'): Promise { const normalizedSystem = COUNTING_SYSTEMS[system] ? system : 'decimal'; const config = normalizeCountingGame({ enabled: true, @@ -294,7 +316,7 @@ export async function activateCountingGame(client, guildId, channelId, system = return saveCountingGameConfig(client, guildId, config); } -export function buildCountingLeaderboard(config, guild) { +export function buildCountingLeaderboard(config: CountingGameState, guild: Guild | null | undefined): string[] { const entries = Object.entries(config.leaderboard || {}); if (entries.length === 0) { return []; diff --git a/src/services/economyService.js b/src/services/economyService.ts similarity index 91% rename from src/services/economyService.js rename to src/services/economyService.ts index 9203754280..488b13f9ef 100644 --- a/src/services/economyService.js +++ b/src/services/economyService.ts @@ -4,6 +4,9 @@ import { logger } from '../utils/logger.js'; import { getEconomyData, setEconomyData, getMaxBankCapacity } from '../utils/economy.js'; import { createError, ErrorTypes } from '../utils/errorHandler.js'; import { wrapServiceClassMethods } from '../utils/serviceErrorBoundary.js'; +import type { ErrorContext } from '../utils/errorHandler.js'; +import type { EconomyData } from '../utils/economy.js'; +import type { NexoClient } from '../types/index.js'; class EconomyService { @@ -19,7 +22,7 @@ class EconomyService { static DAILY_AMOUNT = 1000; static MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER; - static assertSafeBalance(value, context = {}) { + static assertSafeBalance(value: number, context: ErrorContext = {}): void { if (!Number.isSafeInteger(value) || value < 0 || value > this.MAX_SAFE_INTEGER) { throw createError( "Invalid balance state", @@ -30,7 +33,7 @@ class EconomyService { } } - static async claimDaily(client, guildId, userId) { + static async claimDaily(client: NexoClient, guildId: string, userId: string) { logger.debug(`[ECONOMY_SERVICE] claimDaily requested`, { userId, guildId }); const userData = await getEconomyData(client, guildId, userId); @@ -99,7 +102,7 @@ class EconomyService { } } - static async transferMoney(client, guildId, senderId, receiverId, amount) { + static async transferMoney(client: NexoClient, guildId: string, senderId: string, receiverId: string, amount: number) { logger.debug(`[ECONOMY_SERVICE] transferMoney requested`, { senderId, receiverId, @@ -226,7 +229,7 @@ class EconomyService { } } - static async addMoney(client, guildId, userId, amount, source = 'unknown') { + static async addMoney(client: NexoClient, guildId: string, userId: string, amount: number, source = 'unknown'): Promise { if (amount <= 0) { throw createError( "Invalid amount", @@ -260,7 +263,7 @@ class EconomyService { return userData; } - static async removeMoney(client, guildId, userId, amount, reason = 'unknown') { + static async removeMoney(client: NexoClient, guildId: string, userId: string, amount: number, reason = 'unknown'): Promise { if (amount <= 0) { throw createError( "Invalid amount", @@ -302,7 +305,7 @@ class EconomyService { return userData; } - static async depositToBank(client, guildId, userId, amount) { + static async depositToBank(client: NexoClient, guildId: string, userId: string, amount: number): Promise { this.validateAmount(amount, { operation: 'deposit', userId }); const userData = await getEconomyData(client, guildId, userId); @@ -350,7 +353,7 @@ class EconomyService { return userData; } - static async withdrawFromBank(client, guildId, userId, amount) { + static async withdrawFromBank(client: NexoClient, guildId: string, userId: string, amount: number): Promise { this.validateAmount(amount, { operation: 'withdraw', userId }); const userData = await getEconomyData(client, guildId, userId); @@ -388,9 +391,9 @@ class EconomyService { return userData; } - static checkCooldown(userData, action, cooldownMs) { + static checkCooldown(userData: EconomyData, action: string, cooldownMs: number) { const lastActionField = `last${action.charAt(0).toUpperCase() + action.slice(1)}`; - const lastTime = userData[lastActionField] || 0; + const lastTime = (userData[lastActionField] as number | undefined) || 0; const now = Date.now(); const remaining = Math.max(0, lastTime + cooldownMs - now); @@ -402,7 +405,7 @@ class EconomyService { }; } - static validateAmount(amount, context = {}) { + static validateAmount(amount: number, context: ErrorContext = {}): void { if (!Number.isInteger(amount)) { throw createError( "Invalid amount - not an integer", @@ -432,7 +435,7 @@ class EconomyService { } } - static formatDuration(ms) { + static formatDuration(ms: number): string { const totalSeconds = Math.floor(ms / 1000); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); @@ -447,7 +450,7 @@ class EconomyService { return `${seconds}s`; } - static formatCooldownDisplay(ms) { + static formatCooldownDisplay(ms: number): string { const duration = this.formatDuration(ms); return `**${duration}**`; } diff --git a/src/services/giveawayService.js b/src/services/giveawayService.ts similarity index 88% rename from src/services/giveawayService.js rename to src/services/giveawayService.ts index e3334a8072..7da363f78a 100644 --- a/src/services/giveawayService.js +++ b/src/services/giveawayService.ts @@ -1,6 +1,9 @@ // giveawayService.js import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js'; +import type { GuildTextBasedChannel } from 'discord.js'; +import type { NexoClient } from '../types/index.js'; +import type { Giveaway } from '../utils/giveaways.js'; import { logger } from '../utils/logger.js'; import { NexoBotError, ErrorTypes } from '../utils/errorHandler.js'; import { getColor, botConfig } from '../config/bot.js'; @@ -11,11 +14,11 @@ import { logEvent, EVENT_TYPES } from './loggingService.js'; const GIVEAWAY_CONFIG = botConfig.giveaways || {}; const GIVEAWAY_INTERACTION_COOLDOWN = 1000; -function getGiveawayInteractionKey(userId, giveawayId) { +function getGiveawayInteractionKey(userId: string, giveawayId: string): string { return `giveaway:${userId}:${giveawayId}`; } -export function parseDuration(durationString) { +export function parseDuration(durationString: unknown): number { if (!durationString || typeof durationString !== 'string') { throw new NexoBotError( 'Invalid duration format provided', @@ -95,7 +98,7 @@ export function parseDuration(durationString) { return ms; } -export function validatePrize(prize) { +export function validatePrize(prize: unknown): string { if (!prize || typeof prize !== 'string') { throw new NexoBotError( 'Prize must be a non-empty string', @@ -118,7 +121,7 @@ export function validatePrize(prize) { return trimmed; } -export function validateWinnerCount(winnerCount) { +export function validateWinnerCount(winnerCount: number): void { const minimumWinners = GIVEAWAY_CONFIG.minimumWinners ?? 1; const maximumWinners = GIVEAWAY_CONFIG.maximumWinners ?? 10; @@ -132,7 +135,7 @@ export function validateWinnerCount(winnerCount) { } } -export function createGiveawayEmbed(giveaway, status, winners = []) { +export function createGiveawayEmbed(giveaway: Giveaway, status: string, winners: string[] = []): EmbedBuilder { try { const statusEmoji = status === 'ended' ? '🎉' : status === 'reroll' ? '🔄' : '🎉'; const isEnded = status === 'ended' || status === 'reroll'; @@ -144,7 +147,7 @@ export function createGiveawayEmbed(giveaway, status, winners = []) { .setColor(color) .addFields( { name: '👤 Hosted by', value: `<@${giveaway.hostId}>`, inline: true }, - { name: '🏆 Winners', value: giveaway.winnerCount.toString(), inline: true }, + { name: '🏆 Winners', value: giveaway.winnerCount!.toString(), inline: true }, { name: '👥 Entries', value: giveaway.participants?.length?.toString() || '0', inline: true } ); @@ -155,7 +158,7 @@ export function createGiveawayEmbed(giveaway, status, winners = []) { embed.addFields({ name: '🎯 Winners', value: winnerDisplay, inline: false }); } else { const endTime = giveaway.endsAt || giveaway.endTime; - embed.addFields({ name: '⏰ Ends', value: ``, inline: false }); + embed.addFields({ name: '⏰ Ends', value: ``, inline: false }); } embed.setTimestamp(); @@ -167,14 +170,14 @@ export function createGiveawayEmbed(giveaway, status, winners = []) { 'Failed to create giveaway embed', ErrorTypes.UNKNOWN, 'An internal error occurred while formatting the giveaway.', - { error: error.message } + { error: (error as Error).message } ); } } -export function createGiveawayButtons(ended = false) { +export function createGiveawayButtons(ended: boolean = false): ActionRowBuilder { try { - const row = new ActionRowBuilder(); + const row = new ActionRowBuilder(); if (ended) { row.addComponents( @@ -211,12 +214,12 @@ export function createGiveawayButtons(ended = false) { 'Failed to create giveaway buttons', ErrorTypes.UNKNOWN, 'An internal error occurred while creating interactive buttons.', - { error: error.message } + { error: (error as Error).message } ); } } -export function selectWinners(participants, winnerCount) { +export function selectWinners(participants: string[], winnerCount: number): string[] { if (!Array.isArray(participants) || participants.length === 0) { return []; } @@ -248,20 +251,20 @@ export function selectWinners(participants, winnerCount) { 'Failed to select winners', ErrorTypes.UNKNOWN, 'An error occurred while selecting winners.', - { error: error.message, participantCount: participants.length } + { error: (error as Error).message, participantCount: participants.length } ); } } -export function isUserRateLimited(userId, giveawayId) { +export function isUserRateLimited(userId: string, giveawayId: string): boolean { const status = getRateLimitStatus( getGiveawayInteractionKey(userId, giveawayId), GIVEAWAY_INTERACTION_COOLDOWN, ); - return status.attempts >= 1 && status.remaining > 0; + return (status.attempts ?? 0) >= 1 && status.remaining > 0; } -export async function recordUserInteraction(userId, giveawayId) { +export async function recordUserInteraction(userId: string, giveawayId: string): Promise { await checkRateLimit( getGiveawayInteractionKey(userId, giveawayId), 1, @@ -269,7 +272,12 @@ export async function recordUserInteraction(userId, giveawayId) { ); } -export async function endGiveaway(client, giveaway, guildId, endedBy) { +export async function endGiveaway( + client: NexoClient, + giveaway: Giveaway | null | undefined, + guildId: string | null, + endedBy: string, +): Promise<{ giveaway: Giveaway; winners: string[]; participantCount: number }> { try { if (!giveaway) { throw new NexoBotError( @@ -319,12 +327,12 @@ export async function endGiveaway(client, giveaway, guildId, endedBy) { 'Failed to end giveaway', ErrorTypes.UNKNOWN, 'An error occurred while ending the giveaway.', - { error: error.message, giveawayId: giveaway?.messageId } + { error: (error as Error).message, giveawayId: giveaway?.messageId } ); } } -export async function checkGiveaways(client) { +export async function checkGiveaways(client: NexoClient): Promise { try { if (!client.db) { logger.warn('Database not available for giveaway check'); @@ -342,7 +350,7 @@ export async function checkGiveaways(client) { for (const giveawayRecord of endedGiveaways) { try { const { id: giveawayId, guild_id: guildId, message_id: messageId, data: giveawayData } = giveawayRecord; - const giveaway = typeof giveawayData === 'string' ? JSON.parse(giveawayData) : giveawayData; + const giveaway: Giveaway = typeof giveawayData === 'string' ? JSON.parse(giveawayData) : giveawayData; const guild = client.guilds.cache.get(guildId); if (!guild) { @@ -350,7 +358,8 @@ export async function checkGiveaways(client) { continue; } - const channel = await guild.channels.fetch(giveaway.channelId).catch(() => null); + // Giveaways are only posted in text-based channels. + const channel = await guild.channels.fetch(giveaway.channelId!).catch(() => null) as GuildTextBasedChannel | null; if (!channel) { logger.debug(`Channel ${giveaway.channelId} not found for giveaway ${messageId}`); continue; diff --git a/src/services/joinToCreateService.js b/src/services/joinToCreateService.ts similarity index 83% rename from src/services/joinToCreateService.js rename to src/services/joinToCreateService.ts index 9e782d9cd8..fdcbfb7ff8 100644 --- a/src/services/joinToCreateService.js +++ b/src/services/joinToCreateService.ts @@ -12,6 +12,30 @@ import { NexoBotError, ErrorTypes } from '../utils/errorHandler.js'; import { logEvent, EVENT_TYPES } from './loggingService.js'; import { formatLogLine } from '../utils/logging/logEmbeds.js'; import { ChannelType, PermissionFlagsBits } from 'discord.js'; +import type { Guild, GuildMember } from 'discord.js'; +import type { NexoClient } from '../types/index.js'; +import type { ChannelNameVariables, JoinToCreateChannelOptions, JoinToCreateConfig } from '../utils/database.js'; + +export interface JoinToCreateInitOptions { + nameTemplate?: string; + userLimit?: number; + bitrate?: number; + categoryId?: string | null; +} + +export interface TemporaryChannelOptions { + nameTemplate?: string; + userLimit?: number; + bitrate?: number; + parentId?: string | null; +} + +/** Per-trigger options as actually stored (JoinToCreateChannelOptions plus bookkeeping fields). */ +type StoredChannelOptions = JoinToCreateChannelOptions & { + categoryId?: string | null; + createdAt?: number; + updatedAt?: number; +}; const CHANNEL_NAME_MAX_LENGTH = 100; const CHANNEL_VARIABLE_MAX_LENGTH = 32; @@ -27,7 +51,7 @@ const ALLOWED_TEMPLATE_PLACEHOLDERS = new Set([ '{channel_name}' ]); -export function validateChannelNameTemplate(template) { +export function validateChannelNameTemplate(template: unknown): boolean { if (!template || typeof template !== 'string') { throw new NexoBotError( 'Invalid channel template: must be a non-empty string', @@ -68,8 +92,8 @@ export function validateChannelNameTemplate(template) { return true; } -export function validateBitrate(bitrate) { - const bitrateNum = parseInt(bitrate); +export function validateBitrate(bitrate: string | number): boolean { + const bitrateNum = parseInt(String(bitrate)); if (isNaN(bitrateNum)) { throw new NexoBotError( @@ -90,8 +114,8 @@ export function validateBitrate(bitrate) { return true; } -export function validateUserLimit(limit) { - const limitNum = parseInt(limit); +export function validateUserLimit(limit: string | number): boolean { + const limitNum = parseInt(String(limit)); if (isNaN(limitNum)) { throw new NexoBotError( @@ -112,7 +136,7 @@ export function validateUserLimit(limit) { return true; } -export function formatChannelName(template, variables) { +export function formatChannelName(template: string, variables: ChannelNameVariables | null | undefined): string { try { const safeTemplate = template.normalize('NFKC').replace(CONTROL_AND_INVISIBLE_CHARS_REGEX, '').trim(); validateChannelNameTemplate(safeTemplate); @@ -124,8 +148,9 @@ export function formatChannelName(template, variables) { ); } - const sanitized = {}; - for (const [key, value] of Object.entries(variables)) { + const sanitized: Record = {}; + const entries: [string, unknown][] = Object.entries(variables); + for (const [key, value] of entries) { if (value === null || value === undefined) { sanitized[key] = 'Unknown'; } else { @@ -177,7 +202,12 @@ export function formatChannelName(template, variables) { } } -export async function initializeJoinToCreate(client, guildId, channelId, options = {}) { +export async function initializeJoinToCreate( + client: NexoClient, + guildId: string, + channelId: string, + options: JoinToCreateInitOptions = {}, +): Promise { try { if (!client || !client.db) { throw new NexoBotError( @@ -236,13 +266,14 @@ export async function initializeJoinToCreate(client, guildId, channelId, options if (!config.channelOptions) { config.channelOptions = {}; } - config.channelOptions[channelId] = { + const channelOptions: StoredChannelOptions = { nameTemplate: options.nameTemplate || config.channelNameTemplate, userLimit: options.userLimit !== undefined ? options.userLimit : config.userLimit, bitrate: options.bitrate || config.bitrate, categoryId: options.categoryId || null, createdAt: Date.now() }; + config.channelOptions[channelId] = channelOptions; } const saveResult = await saveJoinToCreateConfig(client, guildId, config); @@ -263,14 +294,19 @@ export async function initializeJoinToCreate(client, guildId, channelId, options throw error; } throw new NexoBotError( - `Failed to initialize Join to Create: ${error.message}`, + `Failed to initialize Join to Create: ${(error as Error).message}`, ErrorTypes.DATABASE, 'Failed to set up Join to Create system.' ); } } -export async function updateChannelConfig(client, guildId, channelId, updates) { +export async function updateChannelConfig( + client: NexoClient, + guildId: string, + channelId: string, + updates: Partial, +): Promise { try { if (!client || !client.db) { throw new NexoBotError( @@ -304,11 +340,12 @@ export async function updateChannelConfig(client, guildId, channelId, updates) { config.channelOptions = {}; } - config.channelOptions[channelId] = { + const channelOptions: StoredChannelOptions = { ...config.channelOptions[channelId], ...updates, updatedAt: Date.now() }; + config.channelOptions[channelId] = channelOptions; await saveJoinToCreateConfig(client, guildId, config); @@ -323,14 +360,14 @@ export async function updateChannelConfig(client, guildId, channelId, updates) { throw error; } throw new NexoBotError( - `Failed to update channel config: ${error.message}`, + `Failed to update channel config: ${(error as Error).message}`, ErrorTypes.DATABASE, 'Failed to update configuration.' ); } } -export async function removeTriggerChannel(client, guildId, channelId) { +export async function removeTriggerChannel(client: NexoClient, guildId: string, channelId: string): Promise { try { if (!client || !client.db) { throw new NexoBotError( @@ -377,14 +414,14 @@ export async function removeTriggerChannel(client, guildId, channelId) { throw error; } throw new NexoBotError( - `Failed to remove trigger channel: ${error.message}`, + `Failed to remove trigger channel: ${(error as Error).message}`, ErrorTypes.DATABASE, 'Failed to remove trigger channel.' ); } } -export async function getConfiguration(client, guildId) { +export async function getConfiguration(client: NexoClient, guildId: string): Promise { try { if (!client || !client.db) { throw new NexoBotError( @@ -401,24 +438,24 @@ export async function getConfiguration(client, guildId) { throw error; } throw new NexoBotError( - `Failed to retrieve configuration: ${error.message}`, + `Failed to retrieve configuration: ${(error as Error).message}`, ErrorTypes.DATABASE, 'Failed to retrieve settings.' ); } } -export async function isTriggerChannel(client, guildId, channelId) { +export async function isTriggerChannel(client: NexoClient, guildId: string, channelId: string): Promise { try { const config = await getConfiguration(client, guildId); return config.triggerChannels.includes(channelId); } catch (error) { - logger.error(`Error checking if channel is trigger: ${error.message}`); + logger.error(`Error checking if channel is trigger: ${(error as Error).message}`); return false; } } -export async function getChannelConfiguration(client, guildId, channelId) { +export async function getChannelConfiguration(client: NexoClient, guildId: string, channelId: string) { try { const config = await getConfiguration(client, guildId); @@ -440,14 +477,14 @@ export async function getChannelConfiguration(client, guildId, channelId) { throw error; } throw new NexoBotError( - `Failed to get channel configuration: ${error.message}`, + `Failed to get channel configuration: ${(error as Error).message}`, ErrorTypes.DATABASE, 'Failed to retrieve channel configuration. Please try again.' ); } } -export function hasManageGuildPermission(member) { +export function hasManageGuildPermission(member: GuildMember | null | undefined): boolean { try { if (!member || !member.permissions) { return false; @@ -459,7 +496,7 @@ export function hasManageGuildPermission(member) { } } -export async function logConfigurationChange(client, guildId, userId, action, details) { +export async function logConfigurationChange(client: NexoClient, guildId: string, userId: string, action: string, details: unknown): Promise { try { await logEvent({ client, @@ -475,11 +512,15 @@ export async function logConfigurationChange(client, guildId, userId, action, de }, }); } catch (error) { - logger.warn(`Failed to log Join to Create configuration change: ${error.message}`); + logger.warn(`Failed to log Join to Create configuration change: ${(error as Error).message}`); } } -export async function createTemporaryChannel(guild, member, options = {}) { +export async function createTemporaryChannel( + guild: Guild, + member: GuildMember, + options: TemporaryChannelOptions = {}, +): Promise<{ id: string; name: string; ownerId: string }> { try { if (!guild || !member) { throw new NexoBotError( @@ -543,7 +584,7 @@ export async function createTemporaryChannel(guild, member, options = {}) { throw error; } throw new NexoBotError( - `Failed to create temporary channel: ${error.message}`, + `Failed to create temporary channel: ${(error as Error).message}`, ErrorTypes.DISCORD_API, 'Failed to create your temporary voice channel. Please contact an administrator.' ); diff --git a/src/services/leveling/levelRoleSyncService.js b/src/services/leveling/levelRoleSyncService.ts similarity index 77% rename from src/services/leveling/levelRoleSyncService.js rename to src/services/leveling/levelRoleSyncService.ts index 8a27905fb6..6949d3cbbe 100644 --- a/src/services/leveling/levelRoleSyncService.js +++ b/src/services/leveling/levelRoleSyncService.ts @@ -1,21 +1,23 @@ +import type { Guild, GuildMember } from 'discord.js'; +import type { NexoClient } from '../../types/index.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) { +async function listLevelUserIds(client: NexoClient, guildId: string): Promise { if (!client.db?.list) return []; const prefixes = [getUserLevelPrefix(guildId), `${guildId}:leveling:users:`]; - const userIds = new Set(); + const userIds = new Set(); for (const prefix of prefixes) { - let keys = await client.db.list(prefix).catch(() => []); + let keys: unknown = await client.db.list(prefix).catch((): string[] => []); if (!Array.isArray(keys)) { keys = typeof keys === 'object' && keys !== null ? Object.keys(keys) : []; } - for (const key of keys) { + for (const key of keys as string[]) { if (!key.startsWith(prefix)) continue; const userId = key.slice(prefix.length); if (/^\d{17,19}$/.test(userId)) userIds.add(userId); @@ -25,7 +27,7 @@ async function listLevelUserIds(client, guildId) { return [...userIds]; } -async function tryAwardRole(member, roleId, level) { +async function tryAwardRole(member: GuildMember, roleId: string, level: number): Promise { const role = member.guild.roles.cache.get(roleId) || (await member.guild.roles.fetch(roleId).catch(() => null)); if (!role || member.roles.cache.has(roleId)) return false; @@ -33,8 +35,15 @@ async function tryAwardRole(member, roleId, level) { return true; } -export async function reconcileLevelRoles(client, guildId = null) { - const summary = { +export interface LevelRoleSyncSummary { + scannedGuilds: number; + prunedRewardEntries: number; + rolesReAwarded: number; + errors: number; +} + +export async function reconcileLevelRoles(client: NexoClient, guildId: string | null = null): Promise { + const summary: LevelRoleSyncSummary = { scannedGuilds: 0, prunedRewardEntries: 0, rolesReAwarded: 0, @@ -42,7 +51,7 @@ export async function reconcileLevelRoles(client, guildId = null) { }; const guilds = guildId - ? [client.guilds.cache.get(guildId)].filter(Boolean) + ? [client.guilds.cache.get(guildId)].filter((g): g is Guild => Boolean(g)) : [...client.guilds.cache.values()]; for (const guild of guilds) { @@ -95,14 +104,14 @@ export async function reconcileLevelRoles(client, guildId = null) { summary.errors += 1; logger.warn( `Could not re-award level ${requiredLevel} role to ${userId} in guild ${guild.id}:`, - awardError.message, + awardError instanceof Error ? awardError.message : String(awardError), ); } } } } catch (error) { summary.errors += 1; - logger.warn(`Level role sync failed for guild ${guild.id}:`, error.message); + logger.warn(`Level role sync failed for guild ${guild.id}:`, error instanceof Error ? error.message : String(error)); } } diff --git a/src/services/leveling/leveling.js b/src/services/leveling/leveling.ts similarity index 78% rename from src/services/leveling/leveling.js rename to src/services/leveling/leveling.ts index cbac144872..fa5f02f5b8 100644 --- a/src/services/leveling/leveling.js +++ b/src/services/leveling/leveling.ts @@ -1,6 +1,8 @@ // leveling.js import { EmbedBuilder } from 'discord.js'; +import type { Guild, GuildMember } from 'discord.js'; +import type { NexoClient } from '../../types/index.js'; import { logger } from '../../utils/logger.js'; import { getGuildConfig, setGuildConfig } from '../config/guildConfig.js'; import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; @@ -12,7 +14,40 @@ const XP_MULTIPLIER = 1.5; const MAX_LEVEL = 1000; const MIN_LEVEL = 0; -export function getXpForLevel(level) { +/** Leveling settings stored under `guildConfig.leveling`. */ +export interface GuildLevelingConfig { + enabled: boolean; + xpPerMessage: { min: number; max: number }; + xpCooldown: number; + levelUpMessage: string; + levelUpChannel: string | null; + ignoredChannels: string[]; + ignoredRoles: string[]; + blacklistedUsers: string[]; + roleRewards: Record; + announceLevelUp: boolean; + xpMultiplier: number; + xpRange?: { min: number; max: number }; + configured?: boolean; + [key: string]: unknown; +} + +/** Stored per-user level record (`guild:{guildId}:leveling:users:{userId}`). */ +export interface LevelData { + xp: number; + level: number; + totalXp: number; + lastMessage: number; + rank: number; +} + +export interface LeaderboardEntry extends LevelData { + userId: string; + username: string; + discriminator: string; +} + +export function getXpForLevel(level: number): number { if (!Number.isInteger(level) || level < 0 || level > MAX_LEVEL) { throw new NexoBotError( `Invalid level: ${level}. Must be between ${MIN_LEVEL} and ${MAX_LEVEL}`, @@ -23,7 +58,7 @@ export function getXpForLevel(level) { return 5 * Math.pow(level, 2) + 50 * level + 50; } -export function getLevelFromXp(xp) { +export function getLevelFromXp(xp: number): { level: number; currentXp: number; xpNeeded: number } { if (!Number.isInteger(xp) || xp < 0) { throw new NexoBotError( `Invalid XP: ${xp}`, @@ -48,7 +83,7 @@ export function getLevelFromXp(xp) { }; } -export function calculateTotalXp(level, currentXp = 0) { +export function calculateTotalXp(level: number, currentXp = 0): number { let total = currentXp; for (let i = 0; i < level; i++) { total += getXpForLevel(i); @@ -56,7 +91,7 @@ export function calculateTotalXp(level, currentXp = 0) { return total; } -export async function getLeaderboard(client, guildId, limit = 10) { +export async function getLeaderboard(client: NexoClient, guildId: string, limit = 10): Promise { try { if (!guildId || typeof guildId !== 'string') { @@ -79,10 +114,10 @@ export async function getLeaderboard(client, guildId, limit = 10) { const members = await guild.members.fetch().catch(error => { logger.error(`Failed to fetch members for guild ${guildId}:`, error); - return new Map(); + return new Map(); }); - const leaderboard = []; + const leaderboard: LeaderboardEntry[] = []; for (const [userId, member] of members) { if (member.user.bot) continue; @@ -110,14 +145,14 @@ export async function getLeaderboard(client, guildId, limit = 10) { logger.error('Error getting leaderboard:', error); if (error instanceof NexoBotError) throw error; throw new NexoBotError( - `Failed to fetch leaderboard: ${error.message}`, + `Failed to fetch leaderboard: ${error instanceof Error ? error.message : String(error)}`, ErrorTypes.DATABASE, 'Could not fetch the leaderboard at this time.' ); } } -export function createLeaderboardEmbed(leaderboard, guild) { +export function createLeaderboardEmbed(leaderboard: LeaderboardEntry[] | null | undefined, guild: Guild): EmbedBuilder { const embed = new EmbedBuilder() .setTitle(`🏆 ${guild.name} Leaderboard`) .setColor('#2ecc71') @@ -147,10 +182,10 @@ export function createLeaderboardEmbed(leaderboard, guild) { return embed; } -export async function getLevelingConfig(client, guildId) { +export async function getLevelingConfig(client: NexoClient, guildId: string): Promise { try { const guildConfig = await getGuildConfig(client, guildId); - return guildConfig.leveling || { + return (guildConfig.leveling as GuildLevelingConfig | undefined) || { enabled: true, xpPerMessage: { min: 15, max: 25 }, xpCooldown: 20, @@ -181,7 +216,7 @@ export async function getLevelingConfig(client, guildId) { } } -export async function getUserLevelData(client, guildId, userId) { +export async function getUserLevelData(client: NexoClient, guildId: string, userId: string): Promise { try { if (!guildId || !userId) { throw new NexoBotError( @@ -191,7 +226,7 @@ export async function getUserLevelData(client, guildId, userId) { } const key = getUserLevelKey(guildId, userId); - const data = await client.db.get(key); + const data = await client.db.get>(key); if (!data) { return { @@ -214,14 +249,14 @@ export async function getUserLevelData(client, guildId, userId) { logger.error(`Error getting user level data for ${userId}:`, error); if (error instanceof NexoBotError) throw error; throw new NexoBotError( - `Failed to fetch user data: ${error.message}`, + `Failed to fetch user data: ${error instanceof Error ? error.message : String(error)}`, ErrorTypes.DATABASE, 'Could not fetch level data at this time.' ); } } -export async function saveUserLevelData(client, guildId, userId, data) { +export async function saveUserLevelData(client: NexoClient, guildId: string, userId: string, data: LevelData): Promise { try { if (!guildId || !userId) { throw new NexoBotError( @@ -251,14 +286,14 @@ export async function saveUserLevelData(client, guildId, userId, data) { logger.error(`Error saving user level data for ${userId}:`, error); if (error instanceof NexoBotError) throw error; throw new NexoBotError( - `Failed to save user data: ${error.message}`, + `Failed to save user data: ${error instanceof Error ? error.message : String(error)}`, ErrorTypes.DATABASE, 'Could not save level data at this time.' ); } } -export async function saveLevelingConfig(client, guildId, config) { +export async function saveLevelingConfig(client: NexoClient, guildId: string, config: GuildLevelingConfig): Promise { try { if (!guildId || !config) { throw new NexoBotError( @@ -293,14 +328,14 @@ export async function saveLevelingConfig(client, guildId, config) { logger.error(`Error saving leveling config for guild ${guildId}:`, error); if (error instanceof NexoBotError) throw error; throw new NexoBotError( - `Failed to save config: ${error.message}`, + `Failed to save config: ${error instanceof Error ? error.message : String(error)}`, ErrorTypes.DATABASE, 'Could not save configuration at this time.' ); } } -export async function addLevels(client, guildId, userId, levels) { +export async function addLevels(client: NexoClient, guildId: string, userId: string, levels: number): Promise { try { const levelingConfig = await getLevelingConfig(client, guildId); if (!levelingConfig?.enabled) { @@ -345,14 +380,14 @@ export async function addLevels(client, guildId, userId, levels) { logger.error(`Error adding levels for user ${userId}:`, error); if (error instanceof NexoBotError) throw error; throw new NexoBotError( - `Failed to add levels: ${error.message}`, + `Failed to add levels: ${error instanceof Error ? error.message : String(error)}`, ErrorTypes.DATABASE, 'Could not add levels at this time.' ); } } -export async function removeLevels(client, guildId, userId, levels) { +export async function removeLevels(client: NexoClient, guildId: string, userId: string, levels: number): Promise { try { const levelingConfig = await getLevelingConfig(client, guildId); if (!levelingConfig?.enabled) { @@ -389,14 +424,14 @@ export async function removeLevels(client, guildId, userId, levels) { logger.error(`Error removing levels for user ${userId}:`, error); if (error instanceof NexoBotError) throw error; throw new NexoBotError( - `Failed to remove levels: ${error.message}`, + `Failed to remove levels: ${error instanceof Error ? error.message : String(error)}`, ErrorTypes.DATABASE, 'Could not remove levels at this time.' ); } } -export async function setUserLevel(client, guildId, userId, level) { +export async function setUserLevel(client: NexoClient, guildId: string, userId: string, level: number): Promise { try { const levelingConfig = await getLevelingConfig(client, guildId); if (!levelingConfig?.enabled) { @@ -432,14 +467,14 @@ export async function setUserLevel(client, guildId, userId, level) { logger.error(`Error setting level for user ${userId}:`, error); if (error instanceof NexoBotError) throw error; throw new NexoBotError( - `Failed to set level: ${error.message}`, + `Failed to set level: ${error instanceof Error ? error.message : String(error)}`, ErrorTypes.DATABASE, 'Could not set level at this time.' ); } } -export async function deleteUserLevelData(client, guildId, userId) { +export async function deleteUserLevelData(client: NexoClient, guildId: string, userId: string): Promise { try { if (!guildId || !userId) { throw new NexoBotError( diff --git a/src/services/leveling/xpSystem.js b/src/services/leveling/xpSystem.ts similarity index 79% rename from src/services/leveling/xpSystem.js rename to src/services/leveling/xpSystem.ts index 47d6eec293..f17c7f94b3 100644 --- a/src/services/leveling/xpSystem.js +++ b/src/services/leveling/xpSystem.ts @@ -1,19 +1,30 @@ // xpSystem.js +import type { Guild, GuildMember } from 'discord.js'; +import type { NexoClient } from '../../types/index.js'; import { logger } from '../../utils/logger.js'; import { getLevelingConfig, getXpForLevel, getUserLevelData, saveUserLevelData } from './leveling.js'; +import type { GuildLevelingConfig, LevelData } 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'; +export interface AddXpResult { + level: number; + xp: number; + totalXp: number; + xpNeeded: number; + leveledUp: boolean; +} + /** * 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) { +export const addXp = wrapServiceBoundary(async function addXp(client: NexoClient, guild: Guild, member: GuildMember, xpToAdd: number): Promise { const lockKey = `leveling:${guild.id}:${member.user.id}`; - return await Mutex.runExclusive(lockKey, async () => { + return await Mutex.runExclusive(lockKey, async (): Promise => { if (!xpToAdd || xpToAdd <= 0) { return null; } @@ -69,7 +80,7 @@ export const addXp = wrapServiceBoundary(async function addXp(client, guild, mem }, }); } catch (logError) { - logger.debug('Failed to log leveling event:', logError.message); + logger.debug('Failed to log leveling event:', logError instanceof Error ? logError.message : String(logError)); } } @@ -89,7 +100,7 @@ export const addXp = wrapServiceBoundary(async function addXp(client, guild, mem userMessage: 'Failed to award XP. Please try again.', }); -async function awardRoleReward(guild, member, roleId, level) { +async function awardRoleReward(guild: Guild, member: GuildMember, roleId: string, level: number): Promise { try { const role = guild.roles.cache.get(roleId); @@ -109,7 +120,7 @@ async function awardRoleReward(guild, member, roleId, level) { } } -async function sendLevelUpAnnouncement(guild, member, levelData, config) { +async function sendLevelUpAnnouncement(guild: Guild, member: GuildMember, levelData: LevelData, config: GuildLevelingConfig): Promise { try { const levelUpChannel = config.levelUpChannel ? guild.channels.cache.get(config.levelUpChannel) @@ -119,7 +130,7 @@ async function sendLevelUpAnnouncement(guild, member, levelData, config) { return; } - const permissions = levelUpChannel.permissionsFor(guild.members.me); + 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; @@ -127,9 +138,9 @@ async function sendLevelUpAnnouncement(guild, member, levelData, config) { 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)); + .replace(/{level}/g, String(levelData.level)) + .replace(/{xp}/g, String(levelData.xp)) + .replace(/{xpNeeded}/g, String(getXpForLevel(levelData.level + 1))); await levelUpChannel.send(message).catch(error => { logger.error(`Failed to send level up message in channel ${levelUpChannel.id}:`, error); diff --git a/src/services/loggingService.js b/src/services/loggingService.ts similarity index 78% rename from src/services/loggingService.js rename to src/services/loggingService.ts index 3eddcd307d..5bdb9ebba4 100644 --- a/src/services/loggingService.js +++ b/src/services/loggingService.ts @@ -1,6 +1,8 @@ // loggingService.js import { ChannelType } from 'discord.js'; +import type { ColorResolvable, Guild, Message, MessageCreateOptions } from 'discord.js'; +import type { GuildConfig, NexoClient } from '../types/index.js'; import { getGuildConfig, updateGuildConfig } from './config/guildConfig.js'; import { logger } from '../utils/logger.js'; import { @@ -10,8 +12,46 @@ import { fieldsToLines, splitComparisonFields, } from '../utils/logging/logEmbeds.js'; +import type { LogField, LogMetaEntry, StandardLogEmbedOptions } from '../utils/logging/logEmbeds.js'; + +/** Payload for logEvent; extra keys are allowed and ignored by the embed builder. */ +export interface LogEventData { + userId?: string; + channelId?: string; + color?: ColorResolvable | null; + title?: string | null; + description?: string | null; + headline?: string | null; + lines?: string[]; + quoted?: boolean; + meta?: LogMetaEntry[]; + fields?: LogField[]; + section?: { title?: string | null; body?: string | null } | null; + inlineFields?: LogField[]; + blockFields?: LogField[]; + thumbnail?: string | null; + author?: StandardLogEmbedOptions['author']; + footer?: StandardLogEmbedOptions['footer']; + [key: string]: unknown; +} + +export interface LogEventOptions { + client: NexoClient; + guildId: string; + eventType: string; + data?: LogEventData; + attachments?: NonNullable; + content?: string | null; + channelId?: string | null; +} + +interface ApplicationLogSettings { + logChannelId?: string | null; +} + +type LoggingSettings = NonNullable; -const LOG_DESTINATIONS = ['audit', 'applications', 'reports']; +const LOG_DESTINATIONS: string[] = ['audit', 'applications', 'reports']; const EVENT_TYPES = { MODERATION_BAN: 'moderation.ban', @@ -62,7 +102,7 @@ const EVENT_TYPES = { REPORT_FILE: 'report.file', }; -const EVENT_COLORS = { +const EVENT_COLORS: Record = { 'moderation.ban': 0x721919, 'moderation.kick': 0xFFA500, 'moderation.mute': 0xF1C40F, @@ -102,7 +142,7 @@ const EVENT_COLORS = { 'report.file': 0xED4245, }; -const EVENT_ICONS = { +const EVENT_ICONS: Record = { 'moderation.ban': '🔨', 'moderation.kick': '👢', 'moderation.mute': '🔇', @@ -142,13 +182,13 @@ const EVENT_ICONS = { 'report.file': '🚨', }; -const CATEGORY_DESTINATION = { +const CATEGORY_DESTINATION: Record = { application: 'applications', report: 'reports', }; -export function resolveLogChannel(config, destination) { - const channels = config?.logging?.channels || {}; +export function resolveLogChannel(config: GuildConfig | null | undefined, destination: string): string | null { + const channels: Record = config?.logging?.channels || {}; if (destination && channels[destination]) { return channels[destination]; } @@ -158,11 +198,11 @@ export function resolveLogChannel(config, destination) { return channels[destination] ?? null; } -export function getIgnoreList(config) { +export function getIgnoreList(config: GuildConfig | null | undefined): { users: string[]; channels: string[] } { return config?.logging?.ignore ?? config?.logIgnore ?? { users: [], channels: [] }; } -export function isEventEnabled(config, eventType) { +export function isEventEnabled(config: GuildConfig | null | undefined, eventType: string | null | undefined): boolean { if (!config?.logging?.enabled) { return false; } @@ -185,7 +225,7 @@ export function isEventEnabled(config, eventType) { return true; } -function getLogChannelForEvent(config, eventType, overrideChannelId = null) { +function getLogChannelForEvent(config: GuildConfig | null | undefined, eventType: string, overrideChannelId: string | null = null): string | null { if (overrideChannelId) { return overrideChannelId; } @@ -203,7 +243,7 @@ export async function logEvent({ attachments = [], content = null, channelId: overrideChannelId = null, -}) { +}: LogEventOptions): Promise | null> { try { const guild = client.guilds.cache.get(guildId) || await client.guilds.fetch(guildId).catch(() => null); @@ -240,7 +280,7 @@ export async function logEvent({ return null; } - const permissions = channel.permissionsFor(guild.members.me); + const permissions = channel.permissionsFor(guild.members.me!); if (!permissions || !permissions.has(['SendMessages', 'EmbedLinks'])) { logger.warn(`logEvent: Missing permissions in channel ${logChannelId}`); return null; @@ -248,7 +288,7 @@ export async function logEvent({ const embed = createLogEmbed(guild, eventType, data); - const messageOptions = { embeds: [embed] }; + const messageOptions: MessageCreateOptions = { embeds: [embed] }; if (content) { messageOptions.content = content; } @@ -265,12 +305,12 @@ export async function logEvent({ } } -function createLogEmbed(guild, eventType, data) { +function createLogEmbed(guild: Guild, eventType: string, data: LogEventData) { const color = data.color ?? EVENT_COLORS[eventType] ?? 0x0099ff; const icon = EVENT_ICONS[eventType] || '📌'; const title = data.title || `${icon} ${formatEventType(eventType)}`; - const inlineFields = []; + const inlineFields: LogField[] = []; let description = data.description || ''; if (data.lines?.length) { @@ -334,11 +374,11 @@ function createLogEmbed(guild, eventType, data) { fields: data.blockFields || [], author: data.author || null, timestamp: true, - footer: data.footer || { text: guild.name, iconURL: guild.iconURL({ dynamic: true }) || undefined }, + footer: data.footer || { text: guild.name, iconURL: guild.iconURL() || undefined }, }); } -function formatEventType(eventType) { +function formatEventType(eventType: string): string { if (!eventType || typeof eventType !== 'string') { return 'Unknown Event'; } @@ -349,9 +389,9 @@ function formatEventType(eventType) { .join(' '); } -export async function getLoggingStatus(client, guildId) { +export async function getLoggingStatus(client: NexoClient, guildId: string) { const config = await getGuildConfig(client, guildId); - const logging = config.logging || {}; + const logging: Partial = config.logging || {}; return { enabled: logging.enabled || false, @@ -363,7 +403,7 @@ export async function getLoggingStatus(client, guildId) { }; } -export async function toggleEventLogging(client, guildId, eventTypes, enabled) { +export async function toggleEventLogging(client: NexoClient, guildId: string, eventTypes: string | string[], enabled: boolean): Promise { try { const config = await getGuildConfig(client, guildId); const logging = { ...config.logging, enabledEvents: { ...(config.logging?.enabledEvents || {}) } }; @@ -392,7 +432,7 @@ export async function toggleEventLogging(client, guildId, eventTypes, enabled) { } } -export async function setLogChannel(client, guildId, destination, channelId) { +export async function setLogChannel(client: NexoClient, guildId: string, destination: string, channelId: string | null): Promise { if (!LOG_DESTINATIONS.includes(destination)) { throw new Error(`Invalid log destination: ${destination}`); } @@ -417,11 +457,11 @@ export async function setLogChannel(client, guildId, destination, channelId) { } /** @deprecated Use setLogChannel(client, guildId, 'audit', channelId) */ -export async function setLoggingChannel(client, guildId, channelId) { +export async function setLoggingChannel(client: NexoClient, guildId: string, channelId: string | null): Promise { return setLogChannel(client, guildId, 'audit', channelId); } -export async function setLoggingEnabled(client, guildId, enabled) { +export async function setLoggingEnabled(client: NexoClient, guildId: string, enabled: boolean): Promise { try { const config = await getGuildConfig(client, guildId); const logging = { ...config.logging, enabled }; @@ -433,7 +473,11 @@ export async function setLoggingEnabled(client, guildId, enabled) { } } -export async function updateIgnoreList(client, guildId, { action, type, id }) { +export async function updateIgnoreList( + client: NexoClient, + guildId: string, + { action, type, id }: { action: string; type: string; id: string }, +): Promise { try { const config = await getGuildConfig(client, guildId); const ignore = { ...getIgnoreList(config) }; @@ -460,7 +504,11 @@ export async function updateIgnoreList(client, guildId, { action, type, id }) { } } -export function resolveApplicationLogChannel(config, roleSettings = {}, appSettings = {}) { +export function resolveApplicationLogChannel( + config: GuildConfig | null | undefined, + roleSettings: ApplicationLogSettings = {}, + appSettings: ApplicationLogSettings = {}, +): string | null { return roleSettings.logChannelId || config?.logging?.channels?.applications || appSettings.logChannelId diff --git a/src/services/moderation/moderationService.js b/src/services/moderation/moderationService.ts similarity index 87% rename from src/services/moderation/moderationService.js rename to src/services/moderation/moderationService.ts index fb9abb99a8..6c7010fdeb 100644 --- a/src/services/moderation/moderationService.js +++ b/src/services/moderation/moderationService.ts @@ -1,21 +1,37 @@ // moderationService.js import { PermissionFlagsBits } from 'discord.js'; +import type { Guild, GuildMember, Role, User } from 'discord.js'; import { logger } from '../../utils/logger.js'; import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { logModerationAction } from '../../utils/moderation.js'; -function getTargetLabel(target) { +export type HierarchyCheck = { valid: true } | { valid: false; error: string }; +export type HierarchyActor = 'moderator' | 'bot'; + +export interface ModerationActionResult { + caseId: number; + user: string; + reason: string; +} + +function getTargetLabel(target: GuildMember): string { return target.user?.tag ?? target.displayName ?? 'this user'; } -function getHighestRole(member) { +function getHighestRole(member: GuildMember | null | undefined): Role | null { return member?.roles?.highest ?? null; } export class ModerationService { - static buildHierarchyMessage({ actor, actorRole, targetRole, targetLabel, action }) { + static buildHierarchyMessage({ actor, actorRole, targetRole, targetLabel, action }: { + actor: HierarchyActor; + actorRole: Role; + targetRole: Role; + targetLabel: string; + action: string; + }): string { if (actor === 'moderator') { return ( `You cannot ${action} **${targetLabel}** — their role **${targetRole.name}** is equal to or above yours (**${actorRole.name}**). ` + @@ -29,7 +45,7 @@ export class ModerationService { ); } - static buildHierarchySkipReason(moderator, target, action, actor = 'moderator') { + static buildHierarchySkipReason(moderator: GuildMember, target: GuildMember, action: string, actor: HierarchyActor = 'moderator'): string { const targetLabel = getTargetLabel(target); const targetRole = getHighestRole(target); @@ -49,7 +65,7 @@ export class ModerationService { return `Your role **${modRole.name}** is too low for **${targetRole.name}** — move your role higher`; } - static validateHierarchy(moderator, target, action) { + static validateHierarchy(moderator: GuildMember, target: GuildMember, action: string): HierarchyCheck { if (!moderator || !target) { return { valid: false, error: 'Invalid moderator or target' }; } @@ -84,7 +100,7 @@ export class ModerationService { return { valid: true }; } - static validateBotHierarchy(target, action) { + static validateBotHierarchy(target: GuildMember, action: string): HierarchyCheck { if (!target) { return { valid: false, error: 'Invalid target' }; } @@ -120,7 +136,7 @@ export class ModerationService { return { valid: true }; } - static assertModerationHierarchy(moderator, target, action) { + static assertModerationHierarchy(moderator: GuildMember, target: GuildMember, action: string): void { const botCheck = this.validateBotHierarchy(target, action); if (!botCheck.valid) { throw new NexoBotError(botCheck.error, ErrorTypes.PERMISSION, botCheck.error); @@ -138,7 +154,13 @@ export class ModerationService { moderator, reason = 'No reason provided', deleteDays = 0 - }) { + }: { + guild: Guild; + user: User; + moderator: GuildMember; + reason?: string; + deleteDays?: number; + }): Promise { try { if (!guild || !user || !moderator) { throw new NexoBotError( @@ -148,7 +170,7 @@ export class ModerationService { ); } - let targetMember = null; + let targetMember: GuildMember | null = null; try { targetMember = await guild.members.fetch(user.id).catch(() => null); } catch (err) { @@ -211,7 +233,12 @@ export class ModerationService { member, moderator, reason = 'No reason provided' - }) { + }: { + guild: Guild; + member: GuildMember; + moderator: GuildMember; + reason?: string; + }): Promise { try { if (!guild || !member || !moderator) { throw new NexoBotError( @@ -269,7 +296,13 @@ export class ModerationService { moderator, durationMs, reason = 'No reason provided' - }) { + }: { + guild: Guild; + member: GuildMember; + moderator: GuildMember; + durationMs: number; + reason?: string; + }): Promise<{ caseId: number; user: string; duration: number; reason: string }> { try { if (!guild || !member || !moderator || !durationMs) { throw new NexoBotError( @@ -330,7 +363,12 @@ export class ModerationService { member, moderator, reason = 'Timeout removed by moderator' - }) { + }: { + guild: Guild; + member: GuildMember; + moderator: GuildMember; + reason?: string; + }): Promise<{ user: string }> { try { if (!guild || !member || !moderator) { throw new NexoBotError( @@ -393,7 +431,12 @@ export class ModerationService { user, moderator, reason = 'No reason provided' - }) { + }: { + guild: Guild; + user: User; + moderator: GuildMember; + reason?: string; + }): Promise { try { if (!guild || !user || !moderator) { throw new NexoBotError( diff --git a/src/services/moderation/warningService.js b/src/services/moderation/warningService.ts similarity index 69% rename from src/services/moderation/warningService.js rename to src/services/moderation/warningService.ts index b0832ecfe3..a534c9874a 100644 --- a/src/services/moderation/warningService.js +++ b/src/services/moderation/warningService.ts @@ -4,6 +4,22 @@ import { db, getFromDb, setInDb, getWarningsKey, getWarningsPrefix } from '../.. import { logger } from '../../utils/logger.js'; import { createError, ErrorTypes, wrapServiceClassMethods } from '../../utils/errorHandler.js'; +/** Stored warning (`guild:{guildId}:warnings:{userId}` holds an array of these). */ +export interface Warning { + id: number; + guildId: string; + userId: string; + moderatorId: string; + reason: string; + timestamp: number; + status: 'active' | 'deleted'; +} + +export interface GuildWarningFilters { + moderatorId?: string; + limit?: number; +} + class WarningService { static async addWarning({ @@ -12,9 +28,15 @@ class WarningService { moderatorId, reason, timestamp = Date.now() - }) { + }: { + guildId: string; + userId: string; + moderatorId: string; + reason: string; + timestamp?: number; + }): Promise<{ id: number; totalCount: number }> { const key = getWarningsKey(guildId, userId); - const warnings = await getFromDb(key, []); + const warnings = await getFromDb(key, []); if (!Array.isArray(warnings)) { logger.warn(`Warnings for ${userId} in ${guildId} corrupted, resetting`); @@ -27,7 +49,7 @@ class WarningService { ); } - const warning = { + const warning: Warning = { id: Date.now(), guildId, userId, @@ -48,23 +70,23 @@ class WarningService { }; } - static async getWarnings(guildId, userId) { + static async getWarnings(guildId: string, userId: string): Promise { const key = getWarningsKey(guildId, userId); - const warnings = await getFromDb(key, []); + const warnings = await getFromDb(key, []); return Array.isArray(warnings) ? warnings.filter(w => w && w.status !== 'deleted') : []; } - static async getWarningCount(guildId, userId) { + static async getWarningCount(guildId: string, userId: string): Promise { const warnings = await this.getWarnings(guildId, userId); return warnings.length; } - static async removeWarning(guildId, userId, warningId) { + static async removeWarning(guildId: string, userId: string, warningId: number): Promise<{ removed: boolean }> { const key = getWarningsKey(guildId, userId); - const warnings = await getFromDb(key, []); + const warnings = await getFromDb(key, []); const index = warnings.findIndex(w => w.id === warningId); if (index === -1) { @@ -83,9 +105,9 @@ class WarningService { return { removed: true }; } - static async clearWarnings(guildId, userId) { + static async clearWarnings(guildId: string, userId: string): Promise<{ count: number }> { const key = getWarningsKey(guildId, userId); - const warnings = await getFromDb(key, []); + const warnings = await getFromDb(key, []); const count = warnings.length; await setInDb(key, []); @@ -94,15 +116,15 @@ class WarningService { return { count }; } - static async getGuildWarnings(guildId, filters = {}) { + static async getGuildWarnings(guildId: string, filters: GuildWarningFilters = {}): Promise { const { moderatorId, limit = 100 } = filters; const prefix = getWarningsPrefix(guildId); const keys = await db.list(prefix); - const allWarnings = []; + const allWarnings: Warning[] = []; for (const key of Array.isArray(keys) ? keys : []) { - const warnings = await getFromDb(key, []); + const warnings = await getFromDb(key, []); if (!Array.isArray(warnings)) continue; for (const warning of warnings) { diff --git a/src/services/music/musicActions.js b/src/services/music/musicActions.ts similarity index 66% rename from src/services/music/musicActions.js rename to src/services/music/musicActions.ts index 62b36ca96d..af2a692f9e 100644 --- a/src/services/music/musicActions.js +++ b/src/services/music/musicActions.ts @@ -1,10 +1,23 @@ import { once } from 'node:events'; import { MessageFlags, PermissionFlagsBits } from 'discord.js'; +import type { + ActionRowBuilder, + ButtonBuilder, + EmbedBuilder, + GuildMember, + GuildTextBasedChannel, + InteractionReplyOptions, + VoiceBasedChannel, +} from 'discord.js'; +import type { LoopOption, Node, Player, Riffy, Track } from 'riffy'; +import type { NexoClient } from '../../types/index.js'; import { successEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import type { NexoInteraction } from '../../utils/interactionHelper.js'; import { botHasPermission } from '../../utils/permissionGuard.js'; import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { getGuildMusicData, clearUpdateInterval } from './playerStore.js'; +import type { GuildMusicData } from './playerStore.js'; import { canControlMusic, requireVoiceChannel, VOICE_CHANNEL_DENIAL } from './permissions.js'; import { buildNowPlayingEmbed, @@ -12,12 +25,13 @@ import { buildQueuePaginationRow, getQueuePageSize, } from './musicEmbeds.js'; +import type { MusicTrackInfo } from './musicEmbeds.js'; import { refreshPlayerMessage } from './playerHandler.js'; const YOUTUBE_URL_PATTERN = /(?:youtube\.com|youtu\.be)/i; const PLAYER_CONNECT_TIMEOUT_MS = 12_000; -function getConnectedLavalinkNodes(client) { +function getConnectedLavalinkNodes(client: NexoClient): Node[] { if (!client.riffy?.nodeMap) { return []; } @@ -25,7 +39,7 @@ function getConnectedLavalinkNodes(client) { return [...client.riffy.nodeMap.values()].filter((node) => node.connected); } -export function assertLavalinkNodeAvailable(client) { +export function assertLavalinkNodeAvailable(client: NexoClient): void { if (!getConnectedLavalinkNodes(client).length) { throw new NexoBotError( 'Lavalink unavailable', @@ -35,7 +49,7 @@ export function assertLavalinkNodeAvailable(client) { } } -function assertBotVoicePermissions(channel) { +function assertBotVoicePermissions(channel: VoiceBasedChannel | null | undefined): asserts channel is VoiceBasedChannel { if (!channel) { throw new NexoBotError( 'Voice channel unavailable', @@ -53,7 +67,7 @@ function assertBotVoicePermissions(channel) { } } -async function waitForPlayerConnection(player) { +async function waitForPlayerConnection(player: Player): Promise { if (player.connected) { return; } @@ -85,16 +99,16 @@ async function waitForPlayerConnection(player) { } } -async function startPlayback(player) { +async function startPlayback(player: Player): Promise { await waitForPlayerConnection(player); await player.play(); } -export function getPlayer(client, guildId) { +export function getPlayer(client: NexoClient, guildId: string): Player | null { return client.riffy?.players?.get(guildId) || null; } -export function assertRiffyAvailable(client) { +export function assertRiffyAvailable(client: NexoClient): asserts client is NexoClient & { riffy: Riffy } { if (!client.riffy) { throw new NexoBotError( 'Lavalink not configured', @@ -104,7 +118,7 @@ export function assertRiffyAvailable(client) { } } -export function assertInVoice(member) { +export function assertInVoice(member: GuildMember | null | undefined): void { if (!requireVoiceChannel(member)) { throw new NexoBotError( 'Not in voice channel', @@ -114,7 +128,7 @@ export function assertInVoice(member) { } } -export function assertCanControl(member, player) { +export function assertCanControl(member: GuildMember | null | undefined, player: Player | null | undefined): void { if (!canControlMusic(member, player)) { throw new NexoBotError( 'Wrong voice channel', @@ -124,30 +138,30 @@ export function assertCanControl(member, player) { } } -export async function ensurePlayer(client, interaction) { +export async function ensurePlayer(client: NexoClient, interaction: NexoInteraction): Promise<{ player: Player; guildData: GuildMusicData }> { assertRiffyAvailable(client); assertLavalinkNodeAvailable(client); - assertInVoice(interaction.member); + assertInVoice(interaction.member as GuildMember); - const guildId = interaction.guild.id; + const guildId = interaction.guild!.id; const guildData = getGuildMusicData(guildId); let player = getPlayer(client, guildId); if (!player) { player = client.riffy.createConnection({ guildId, - voiceChannel: interaction.member.voice.channel.id, - textChannel: interaction.channel.id, + voiceChannel: (interaction.member as GuildMember).voice.channel!.id, + textChannel: interaction.channel!.id, deaf: true, }); - guildData.playerChannelId = interaction.channel.id; + guildData.playerChannelId = interaction.channel!.id; } player.setVolume(guildData.volume); return { player, guildData }; } -function isDuplicateTrack(player, track) { +function isDuplicateTrack(player: Player, track: Track | null | undefined): boolean { const uri = track?.info?.uri; if (!uri) { return false; @@ -158,13 +172,13 @@ function isDuplicateTrack(player, track) { return player.queue.some((existing) => existing.info?.uri === uri); } -export async function joinVoiceChannel(client, interaction) { +export async function joinVoiceChannel(client: NexoClient, interaction: NexoInteraction): Promise { assertRiffyAvailable(client); - assertInVoice(interaction.member); + assertInVoice(interaction.member as GuildMember); - const guildId = interaction.guild.id; + const guildId = interaction.guild!.id; const guildData = getGuildMusicData(guildId); - const channel = interaction.member.voice.channel; + const channel = (interaction.member as GuildMember).voice.channel; assertBotVoicePermissions(channel); let player = getPlayer(client, guildId); @@ -181,10 +195,10 @@ export async function joinVoiceChannel(client, interaction) { player = client.riffy.createConnection({ guildId, voiceChannel: channel.id, - textChannel: interaction.channel.id, + textChannel: interaction.channel!.id, deaf: true, }); - guildData.playerChannelId = interaction.channel.id; + guildData.playerChannelId = interaction.channel!.id; } player.setVolume(guildData.volume); @@ -195,7 +209,7 @@ export async function joinVoiceChannel(client, interaction) { ); } -export async function playQuery(client, interaction, query) { +export async function playQuery(client: NexoClient, interaction: NexoInteraction, query: string): Promise<{ embed: EmbedBuilder }> { if (YOUTUBE_URL_PATTERN.test(query)) { throw new NexoBotError( 'YouTube URL blocked', @@ -206,7 +220,7 @@ export async function playQuery(client, interaction, query) { const { player, guildData } = await ensurePlayer(client, interaction); - const result = await client.riffy.resolve({ + const result = await client.riffy!.resolve({ query, requester: interaction.user, }); @@ -281,12 +295,12 @@ export async function playQuery(client, interaction, query) { throw new NexoBotError('No results', ErrorTypes.USER_INPUT, `No results found. (loadType: ${loadType})`); } -export async function skipTrack(client, interaction) { - const player = getPlayer(client, interaction.guild.id); +export async function skipTrack(client: NexoClient, interaction: NexoInteraction): Promise { + const player = getPlayer(client, interaction.guild!.id); if (!player?.current) { throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'Nothing is playing right now.'); } - assertCanControl(interaction.member, player); + assertCanControl(interaction.member as GuildMember, 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. @@ -297,14 +311,14 @@ export async function skipTrack(client, interaction) { return successEmbed('Skipped', `Skipped **${title}**.`); } -export async function stopPlayback(client, interaction) { - const player = getPlayer(client, interaction.guild.id); +export async function stopPlayback(client: NexoClient, interaction: NexoInteraction): Promise { + const player = getPlayer(client, interaction.guild!.id); if (!player) { throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'No active music player.'); } - assertCanControl(interaction.member, player); + assertCanControl(interaction.member as GuildMember, player); - const guildData = getGuildMusicData(interaction.guild.id); + const guildData = getGuildMusicData(interaction.guild!.id); const queueLength = player.queue?.length || 0; if (queueLength >= 5 && guildData.stopConfirmPending !== interaction.user.id) { @@ -321,11 +335,11 @@ export async function stopPlayback(client, interaction) { } guildData.stopConfirmPending = null; - await destroyPlayerSession(client, interaction.guild.id, player, guildData); + await destroyPlayerSession(client, interaction.guild!.id, player, guildData); return successEmbed('Stopped', 'Playback stopped and the queue was cleared.'); } -export async function applyPause(client, guildId) { +export async function applyPause(client: NexoClient, guildId: string): Promise { const player = getPlayer(client, guildId); if (!player?.current || player.paused) { return false; @@ -336,7 +350,7 @@ export async function applyPause(client, guildId) { return true; } -export async function applyResume(client, guildId) { +export async function applyResume(client: NexoClient, guildId: string): Promise { const player = getPlayer(client, guildId); if (!player?.current || !player.paused) { return false; @@ -347,97 +361,97 @@ export async function applyResume(client, guildId) { return true; } -export async function pausePlayback(client, interaction) { - const player = getPlayer(client, interaction.guild.id); +export async function pausePlayback(client: NexoClient, interaction: NexoInteraction): Promise { + const player = getPlayer(client, interaction.guild!.id); if (!player?.current) { throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'Nothing is playing right now.'); } - assertCanControl(interaction.member, player); + assertCanControl(interaction.member as GuildMember, player); if (player.paused) { throw new NexoBotError('Already paused', ErrorTypes.USER_INPUT, 'Playback is already paused.'); } - await applyPause(client, interaction.guild.id); + await applyPause(client, interaction.guild!.id); return successEmbed('Paused', 'Playback paused.'); } -export async function resumePlayback(client, interaction) { - const player = getPlayer(client, interaction.guild.id); +export async function resumePlayback(client: NexoClient, interaction: NexoInteraction): Promise { + const player = getPlayer(client, interaction.guild!.id); if (!player?.current) { throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'Nothing is playing right now.'); } - assertCanControl(interaction.member, player); + assertCanControl(interaction.member as GuildMember, player); if (!player.paused) { throw new NexoBotError('Not paused', ErrorTypes.USER_INPUT, 'Playback is not paused.'); } - await applyResume(client, interaction.guild.id); + await applyResume(client, interaction.guild!.id); return successEmbed('Resumed', 'Playback resumed.'); } -export async function shuffleQueue(client, interaction) { - const player = getPlayer(client, interaction.guild.id); +export async function shuffleQueue(client: NexoClient, interaction: NexoInteraction): Promise { + const player = getPlayer(client, interaction.guild!.id); if (!player?.queue?.length) { throw new NexoBotError('Empty queue', ErrorTypes.USER_INPUT, 'The queue is empty.'); } - assertCanControl(interaction.member, player); + assertCanControl(interaction.member as GuildMember, player); player.queue.shuffle(); - getGuildMusicData(interaction.guild.id).shuffle = true; - await refreshPlayerMessage(client, interaction.guild.id); + getGuildMusicData(interaction.guild!.id).shuffle = true; + await refreshPlayerMessage(client, interaction.guild!.id); return successEmbed('Shuffled', 'The queue has been shuffled.'); } -export async function setLoopMode(client, interaction, mode) { - const player = getPlayer(client, interaction.guild.id); +export async function setLoopMode(client: NexoClient, interaction: NexoInteraction, mode: LoopOption): Promise { + const player = getPlayer(client, interaction.guild!.id); if (!player) { throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'No active music player.'); } - assertCanControl(interaction.member, player); + assertCanControl(interaction.member as GuildMember, player); - const guildData = getGuildMusicData(interaction.guild.id); + const guildData = getGuildMusicData(interaction.guild!.id); guildData.loop = mode; player.setLoop(mode); - const labels = { none: 'Off', track: 'Track', queue: 'Queue' }; - await refreshPlayerMessage(client, interaction.guild.id); + const labels: Record = { none: 'Off', track: 'Track', queue: 'Queue' }; + await refreshPlayerMessage(client, interaction.guild!.id); return successEmbed('Loop Updated', `Loop mode set to **${labels[mode] || mode}**.`); } -export async function toggleLoop(client, interaction) { - const guildData = getGuildMusicData(interaction.guild.id); +export async function toggleLoop(client: NexoClient, interaction: NexoInteraction): Promise { + const guildData = getGuildMusicData(interaction.guild!.id); const next = guildData.loop === 'none' ? 'track' : guildData.loop === 'track' ? 'queue' : 'none'; return setLoopMode(client, interaction, next); } -export async function setVolume(client, interaction, volume) { - const player = getPlayer(client, interaction.guild.id); +export async function setVolume(client: NexoClient, interaction: NexoInteraction, volume: number): Promise { + const player = getPlayer(client, interaction.guild!.id); if (!player) { throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'No active music player.'); } - assertCanControl(interaction.member, player); + assertCanControl(interaction.member as GuildMember, player); - const guildData = getGuildMusicData(interaction.guild.id); + const guildData = getGuildMusicData(interaction.guild!.id); guildData.volume = Math.max(0, Math.min(100, volume)); player.setVolume(guildData.volume); - await refreshPlayerMessage(client, interaction.guild.id); + await refreshPlayerMessage(client, interaction.guild!.id); return successEmbed('Volume Updated', `Volume set to **${guildData.volume}%**.`); } -export async function adjustVolume(client, interaction, delta) { - const guildData = getGuildMusicData(interaction.guild.id); +export async function adjustVolume(client: NexoClient, interaction: NexoInteraction, delta: number): Promise { + const guildData = getGuildMusicData(interaction.guild!.id); return setVolume(client, interaction, guildData.volume + delta); } -export async function seekTrack(client, interaction, seconds) { - const player = getPlayer(client, interaction.guild.id); +export async function seekTrack(client: NexoClient, interaction: NexoInteraction, seconds: number): Promise { + const player = getPlayer(client, interaction.guild!.id); if (!player?.current) { throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'Nothing is playing right now.'); } - assertCanControl(interaction.member, player); + assertCanControl(interaction.member as GuildMember, player); - const info = player.current.info || {}; + const info: Partial = player.current.info || {}; if (info.isStream || info.isSeekable === false) { throw new NexoBotError( 'Not seekable', @@ -456,16 +470,16 @@ export async function seekTrack(client, interaction, seconds) { } player.seek(position); - await refreshPlayerMessage(client, interaction.guild.id); + await refreshPlayerMessage(client, interaction.guild!.id); return successEmbed('Seeked', `Seeked to **${seconds}s**.`); } -export async function removeFromQueue(client, interaction, index) { - const player = getPlayer(client, interaction.guild.id); +export async function removeFromQueue(client: NexoClient, interaction: NexoInteraction, index: number): Promise { + const player = getPlayer(client, interaction.guild!.id); if (!player?.queue?.length) { throw new NexoBotError('Empty queue', ErrorTypes.USER_INPUT, 'The queue is empty.'); } - assertCanControl(interaction.member, player); + assertCanControl(interaction.member as GuildMember, player); const queueIndex = index - 1; if (queueIndex < 0 || queueIndex >= player.queue.length) { @@ -474,16 +488,16 @@ export async function removeFromQueue(client, interaction, index) { const removed = player.queue[queueIndex]; player.queue.remove(queueIndex); - await refreshPlayerMessage(client, interaction.guild.id); + await refreshPlayerMessage(client, interaction.guild!.id); return successEmbed('Removed', `Removed **${removed.info?.title || 'track'}** from the queue.`); } -export async function moveInQueue(client, interaction, from, to) { - const player = getPlayer(client, interaction.guild.id); +export async function moveInQueue(client: NexoClient, interaction: NexoInteraction, from: number, to: number): Promise { + const player = getPlayer(client, interaction.guild!.id); if (!player?.queue?.length) { throw new NexoBotError('Empty queue', ErrorTypes.USER_INPUT, 'The queue is empty.'); } - assertCanControl(interaction.member, player); + assertCanControl(interaction.member as GuildMember, player); const fromIndex = from - 1; const toIndex = to - 1; @@ -494,23 +508,23 @@ 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); + await refreshPlayerMessage(client, interaction.guild!.id); return successEmbed('Moved', `Moved **${track.info?.title || 'track'}** to position #${to}.`); } -export async function clearQueue(client, interaction) { - const player = getPlayer(client, interaction.guild.id); +export async function clearQueue(client: NexoClient, interaction: NexoInteraction): Promise { + const player = getPlayer(client, interaction.guild!.id); if (!player?.queue?.length) { throw new NexoBotError('Empty queue', ErrorTypes.USER_INPUT, 'The queue is already empty.'); } - assertCanControl(interaction.member, player); + assertCanControl(interaction.member as GuildMember, player); player.queue.clear(); - await refreshPlayerMessage(client, interaction.guild.id); + await refreshPlayerMessage(client, interaction.guild!.id); return successEmbed('Queue Cleared', 'All queued tracks were removed.'); } -export async function setTwentyFourSeven(client, interaction, enabled) { - const guildData = getGuildMusicData(interaction.guild.id); +export async function setTwentyFourSeven(client: NexoClient, interaction: NexoInteraction, enabled: boolean): Promise { + const guildData = getGuildMusicData(interaction.guild!.id); guildData.twentyFourSeven = enabled; return successEmbed( '24/7 Mode', @@ -520,7 +534,7 @@ export async function setTwentyFourSeven(client, interaction, enabled) { ); } -export function buildNowPlayingReply(client, guildId) { +export function buildNowPlayingReply(client: NexoClient, guildId: string): { embeds: EmbedBuilder[] } { const player = getPlayer(client, guildId); if (!player?.current) { throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'Nothing is playing right now.'); @@ -531,7 +545,12 @@ export function buildNowPlayingReply(client, guildId) { }; } -export function buildQueueReply(client, guildId, page = 0) { +export function buildQueueReply(client: NexoClient, guildId: string, page = 0): { + embeds: EmbedBuilder[]; + components: ActionRowBuilder[]; + page: number; + totalPages: number; +} { const player = getPlayer(client, guildId); if (!player) { throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'No active music player.'); @@ -548,7 +567,13 @@ export function buildQueueReply(client, guildId, page = 0) { }; } -export async function destroyPlayerSession(client, guildId, player, guildData, { forceDisconnect = false } = {}) { +export async function destroyPlayerSession( + client: NexoClient, + guildId: string, + player: Player | null | undefined, + guildData: GuildMusicData, + { forceDisconnect = false }: { forceDisconnect?: boolean } = {}, +): Promise { clearUpdateInterval(guildData); if (guildData.idleTimeout) { clearTimeout(guildData.idleTimeout); @@ -562,7 +587,7 @@ export async function destroyPlayerSession(client, guildId, player, guildData, { if (guildData.playerMessageId && guildData.playerChannelId) { try { - const channel = client.channels.cache.get(guildData.playerChannelId); + const channel = client.channels.cache.get(guildData.playerChannelId) as GuildTextBasedChannel | undefined; if (channel) { const msg = await channel.messages.fetch(guildData.playerMessageId); await msg.delete(); @@ -584,17 +609,17 @@ export async function destroyPlayerSession(client, guildId, player, guildData, { } } -export async function leaveVoiceChannel(client, interaction) { +export async function leaveVoiceChannel(client: NexoClient, interaction: NexoInteraction): Promise { assertRiffyAvailable(client); - const guildId = interaction.guild.id; + const guildId = interaction.guild!.id; const player = getPlayer(client, guildId); if (!player) { throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'I am not in a voice channel.'); } - assertCanControl(interaction.member, player); + assertCanControl(interaction.member as GuildMember, player); - const channel = interaction.guild.channels.cache.get(player.voiceChannel); + const channel = interaction.guild!.channels.cache.get(player.voiceChannel); const channelName = channel?.name || 'voice channel'; const guildData = getGuildMusicData(guildId); @@ -603,8 +628,8 @@ export async function leaveVoiceChannel(client, interaction) { return successEmbed('Left Voice Channel', `Disconnected from **${channelName}**.`); } -export async function replyMusicSuccess(interaction, embed) { - const options = { embeds: [embed] }; +export async function replyMusicSuccess(interaction: NexoInteraction, embed: EmbedBuilder): Promise { + const options: InteractionReplyOptions = { embeds: [embed] }; if (!interaction._isPrefixCommand) { options.flags = MessageFlags.Ephemeral; } diff --git a/src/services/music/musicEmbeds.js b/src/services/music/musicEmbeds.ts similarity index 77% rename from src/services/music/musicEmbeds.js rename to src/services/music/musicEmbeds.ts index e57a74abe0..82d7986e4c 100644 --- a/src/services/music/musicEmbeds.js +++ b/src/services/music/musicEmbeds.ts @@ -1,6 +1,16 @@ import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; +import type { EmbedBuilder, User } from 'discord.js'; +import type { Player, Track } from 'riffy'; import { createEmbed } from '../../utils/embeds.js'; import { getPaginationRow } from '../../utils/components.js'; +import type { GuildMusicData } from './playerStore.js'; + +/** Track info fields read here that riffy's typings do not declare (absent on most nodes). */ +export type MusicTrackInfo = Track['info'] & { + artworkUrl?: string | null; + isStream?: boolean; + isSeekable?: boolean; +}; const QUEUE_PAGE_SIZE = 10; @@ -20,7 +30,7 @@ export const MUSIC_BUTTON_IDS = { QUEUE_LAST: 'music_queue_last', }; -export function formatDuration(ms) { +export function formatDuration(ms: number): string { if (!ms || Number.isNaN(ms)) { return 'Live'; } @@ -34,11 +44,12 @@ export function formatDuration(ms) { return `${minutes}:${String(seconds).padStart(2, '0')}`; } -function getTrackArtwork(track) { - return track?.info?.artworkUrl || track?.info?.thumbnail || null; +function getTrackArtwork(track: Track | null | undefined): string | null { + const info = track?.info as MusicTrackInfo | undefined; + return info?.artworkUrl || info?.thumbnail || null; } -function getLoopLabel(loop) { +function getLoopLabel(loop: string | undefined): string { switch (loop) { case 'track': return 'Track'; @@ -49,8 +60,12 @@ function getLoopLabel(loop) { } } -export function buildNowPlayingEmbed(track, player, guildData) { - const requester = track?.info?.requester; +export function buildNowPlayingEmbed( + track: Track | null | undefined, + player: Player | null | undefined, + guildData: GuildMusicData | null | undefined, +): EmbedBuilder { + const requester = track?.info?.requester as Partial> | null | undefined; const requesterLabel = requester ? (requester.username || requester.tag || 'Unknown') : 'Unknown'; @@ -75,7 +90,7 @@ export function buildNowPlayingEmbed(track, player, guildData) { }); } -export function buildQueueEmbed(queue, currentTrack, page = 0) { +export function buildQueueEmbed(queue: Track[] | null | undefined, currentTrack: Track | null | undefined, page = 0): EmbedBuilder { const totalTracks = queue?.length || 0; const totalPages = Math.max(1, Math.ceil(totalTracks / QUEUE_PAGE_SIZE)); const safePage = Math.min(Math.max(page, 0), totalPages - 1); @@ -106,9 +121,9 @@ export function buildQueueEmbed(queue, currentTrack, page = 0) { }); } -export function buildPlayerButtonRows(player, guildData) { +export function buildPlayerButtonRows(player: Player | null | undefined, guildData: GuildMusicData | null | undefined): ActionRowBuilder[] { const paused = player?.paused; - const row1 = new ActionRowBuilder().addComponents( + const row1 = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(MUSIC_BUTTON_IDS.PAUSE) .setLabel('Pause') @@ -138,7 +153,7 @@ export function buildPlayerButtonRows(player, guildData) { .setEmoji('🔀'), ); - const row2 = new ActionRowBuilder().addComponents( + const row2 = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(MUSIC_BUTTON_IDS.LOOP) .setLabel('Loop') @@ -164,10 +179,10 @@ export function buildPlayerButtonRows(player, guildData) { return [row1, row2]; } -export function buildQueuePaginationRow(page, totalPages) { +export function buildQueuePaginationRow(page: number, totalPages: number): ActionRowBuilder { return getPaginationRow('music_queue', page + 1, totalPages); } -export function getQueuePageSize() { +export function getQueuePageSize(): number { return QUEUE_PAGE_SIZE; } diff --git a/src/services/music/musicVoiceState.js b/src/services/music/musicVoiceState.ts similarity index 79% rename from src/services/music/musicVoiceState.js rename to src/services/music/musicVoiceState.ts index f6ba98d518..a7a9c55e6c 100644 --- a/src/services/music/musicVoiceState.js +++ b/src/services/music/musicVoiceState.ts @@ -1,8 +1,10 @@ +import type { GuildTextBasedChannel, VoiceBasedChannel, VoiceState } from 'discord.js'; +import type { NexoClient } from '../../types/index.js'; import { successEmbed } from '../../utils/embeds.js'; import { getGuildMusicData } from './playerStore.js'; import { applyPause, applyResume, getPlayer } from './musicActions.js'; -export async function handleMusicVoiceState(client, oldState, newState) { +export async function handleMusicVoiceState(client: NexoClient, oldState: VoiceState, newState: VoiceState): Promise { if (!client.riffy) { return; } @@ -17,7 +19,7 @@ export async function handleMusicVoiceState(client, oldState, newState) { return; } - const voiceChannel = client.channels.cache.get(player.voiceChannel); + const voiceChannel = client.channels.cache.get(player.voiceChannel) as VoiceBasedChannel | undefined; if (!voiceChannel) { return; } @@ -30,7 +32,7 @@ export async function handleMusicVoiceState(client, oldState, newState) { guildData.autoPaused = true; await applyPause(client, guildId); if (guildData.playerChannelId) { - const channel = client.channels.cache.get(guildData.playerChannelId); + const channel = client.channels.cache.get(guildData.playerChannelId) as GuildTextBasedChannel | undefined; if (channel) { channel.send({ embeds: [successEmbed('Paused', 'Voice channel is empty. Music paused until someone joins.')] }).catch(() => null); } @@ -42,7 +44,7 @@ export async function handleMusicVoiceState(client, oldState, newState) { await applyResume(client, guildId); guildData.autoPaused = false; if (guildData.playerChannelId) { - const channel = client.channels.cache.get(guildData.playerChannelId); + const channel = client.channels.cache.get(guildData.playerChannelId) as GuildTextBasedChannel | undefined; if (channel) { channel.send({ embeds: [successEmbed('Resumed', 'Someone joined the voice channel. Playback resumed.')] }).catch(() => null); } diff --git a/src/services/music/permissions.js b/src/services/music/permissions.ts similarity index 54% rename from src/services/music/permissions.js rename to src/services/music/permissions.ts index 2c0a09d908..a4d657ed50 100644 --- a/src/services/music/permissions.js +++ b/src/services/music/permissions.ts @@ -1,7 +1,10 @@ +import type { GuildMember } from 'discord.js'; +import type { Player } from 'riffy'; + export const VOICE_CHANNEL_DENIAL = 'You need to be in the same voice channel as the bot to use music controls.'; -export function canControlMusic(member, player) { +export function canControlMusic(member: GuildMember | null | undefined, player: Player | null | undefined): boolean { const memberChannel = member?.voice?.channel; if (!memberChannel || !player?.voiceChannel) { return false; @@ -9,6 +12,6 @@ export function canControlMusic(member, player) { return memberChannel.id === player.voiceChannel; } -export function requireVoiceChannel(member) { +export function requireVoiceChannel(member: GuildMember | null | undefined): boolean { return Boolean(member?.voice?.channel); } diff --git a/src/services/music/playerHandler.js b/src/services/music/playerHandler.ts similarity index 86% rename from src/services/music/playerHandler.js rename to src/services/music/playerHandler.ts index b16290fbc3..ca7dc72f26 100644 --- a/src/services/music/playerHandler.js +++ b/src/services/music/playerHandler.ts @@ -1,7 +1,9 @@ // Player event handlers for Riffy. Adapted from Musicify playerHandler (Apache-2.0). +import type { ActionRowBuilder, ButtonBuilder, Client, EmbedBuilder, GuildTextBasedChannel } from 'discord.js'; import { logger } from '../../utils/logger.js'; import { getGuildMusicData, clearUpdateInterval } from './playerStore.js'; +import type { GuildMusicData } from './playerStore.js'; import { buildNowPlayingEmbed, buildPlayerButtonRows, @@ -10,8 +12,14 @@ import { const UPDATE_INTERVAL_MS = 15 * 1000; const IDLE_DISCONNECT_MS = 30 * 1000; -async function editOrSendPlayerMessage(client, guildData, channelId, embed, components) { - const channel = client.channels.cache.get(channelId); +async function editOrSendPlayerMessage( + client: Client, + guildData: GuildMusicData, + channelId: string, + embed: EmbedBuilder, + components: ActionRowBuilder[], +): Promise { + const channel = client.channels.cache.get(channelId) as GuildTextBasedChannel | undefined; if (!channel) { guildData.playerMessageId = null; guildData.playerChannelId = null; @@ -41,7 +49,7 @@ async function editOrSendPlayerMessage(client, guildData, channelId, embed, comp } } -export async function refreshPlayerMessage(client, guildId) { +export async function refreshPlayerMessage(client: Client, guildId: string): Promise { try { const player = client.riffy?.players?.get(guildId); if (!player?.current) { @@ -58,7 +66,7 @@ export async function refreshPlayerMessage(client, guildId) { } } -function startUpdateInterval(client, guildId) { +function startUpdateInterval(client: Client, guildId: string): void { const guildData = getGuildMusicData(guildId); clearUpdateInterval(guildData); guildData.updateInterval = setInterval(() => { @@ -66,7 +74,7 @@ function startUpdateInterval(client, guildId) { }, UPDATE_INTERVAL_MS); } -export function setupPlayerHandler(client) { +export function setupPlayerHandler(client: Client): void { if (!client.riffy) { logger.warn('Riffy not initialized; music player handlers not attached.'); return; @@ -75,10 +83,10 @@ export function setupPlayerHandler(client) { // 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 nodeLogState = new Map(); const NODE_LOG_INTERVAL_MS = 5 * 60 * 1000; - const shouldLogNodeEvent = (nodeName) => { + const shouldLogNodeEvent = (nodeName: string | undefined): boolean => { const prev = nodeLogState.get(nodeName) ?? { lastLogAt: 0, hasConnected: false }; const now = Date.now(); if (now - prev.lastLogAt < NODE_LOG_INTERVAL_MS) { @@ -88,7 +96,7 @@ export function setupPlayerHandler(client) { return true; }; - const markNodeConnected = (nodeName) => { + const markNodeConnected = (nodeName: string | undefined): void => { const prev = nodeLogState.get(nodeName) ?? { lastLogAt: 0, hasConnected: false }; nodeLogState.set(nodeName, { ...prev, hasConnected: true }); }; @@ -164,7 +172,7 @@ export function setupPlayerHandler(client) { if (guildData.playerMessageId && guildData.playerChannelId) { try { - const channel = client.channels.cache.get(guildData.playerChannelId); + const channel = client.channels.cache.get(guildData.playerChannelId) as GuildTextBasedChannel | undefined; if (channel) { const msg = await channel.messages.fetch(guildData.playerMessageId); await msg.delete(); @@ -182,7 +190,7 @@ export function setupPlayerHandler(client) { } guildData.idleTimeout = setTimeout(() => { try { - const currentPlayer = client.riffy.players.get(player.guildId); + const currentPlayer = client.riffy!.players.get(player.guildId); if (currentPlayer && !currentPlayer.playing && !currentPlayer.paused && !currentPlayer.current) { currentPlayer.destroy(); } @@ -203,7 +211,7 @@ export function setupPlayerHandler(client) { if (guildData.playerMessageId && guildData.playerChannelId) { try { - const channel = client.channels.cache.get(guildData.playerChannelId); + const channel = client.channels.cache.get(guildData.playerChannelId) as GuildTextBasedChannel | undefined; if (channel) { const msg = await channel.messages.fetch(guildData.playerMessageId); await msg.delete(); @@ -227,7 +235,7 @@ export function setupPlayerHandler(client) { logger.error(`Track error in ${player.guildId} for "${track?.info?.title}":`, payload?.error || payload); const guildData = getGuildMusicData(player.guildId); if (guildData.playerChannelId) { - const channel = client.channels.cache.get(guildData.playerChannelId); + const channel = client.channels.cache.get(guildData.playerChannelId) as GuildTextBasedChannel | undefined; if (channel) { channel.send(`Failed to play **${track?.info?.title || 'track'}**. Skipping...`).catch(() => null); } @@ -239,7 +247,7 @@ export function setupPlayerHandler(client) { }); } -export async function shutdownMusic(client) { +export async function shutdownMusic(client: Client): Promise { if (!client.riffy?.players) { return; } @@ -248,7 +256,7 @@ export async function shutdownMusic(client) { try { player.destroy(); } catch (error) { - logger.debug('Error destroying music player during shutdown:', error.message); + logger.debug('Error destroying music player during shutdown:', error instanceof Error ? error.message : String(error)); } } } diff --git a/src/services/music/playerStore.js b/src/services/music/playerStore.ts similarity index 54% rename from src/services/music/playerStore.js rename to src/services/music/playerStore.ts index 009ad3e686..4c26a5c1c7 100644 --- a/src/services/music/playerStore.js +++ b/src/services/music/playerStore.ts @@ -1,6 +1,24 @@ // Per-guild music session state (in-memory). Adapted from Musicify playerStore (Apache-2.0). +import type { LoopOption, Track } from 'riffy'; + export class GuildMusicData { + playerMessageId: string | null; + playerChannelId: string | null; + autoplay: boolean; + loop: LoopOption; + volume: number; + shuffle: boolean; + previousTracks: Track[]; + twentyFourSeven: boolean; + /** userId -> queue page currently shown to that user. */ + queuePages: Map; + updateInterval: ReturnType | null; + idleTimeout: ReturnType | null; + autoPaused: boolean; + /** userId of the member who has to confirm a large-queue stop. */ + stopConfirmPending: string | null; + constructor() { this.playerMessageId = null; this.playerChannelId = null; @@ -18,23 +36,23 @@ export class GuildMusicData { } } -export function clearUpdateInterval(guildData) { +export function clearUpdateInterval(guildData: GuildMusicData): void { if (guildData.updateInterval) { clearInterval(guildData.updateInterval); guildData.updateInterval = null; } } -const guildStore = new Map(); +const guildStore = new Map(); -export function getGuildMusicData(guildId) { +export function getGuildMusicData(guildId: string): GuildMusicData { if (!guildStore.has(guildId)) { guildStore.set(guildId, new GuildMusicData()); } - return guildStore.get(guildId); + return guildStore.get(guildId)!; } -export function deleteGuildMusicData(guildId) { +export function deleteGuildMusicData(guildId: string): void { const guildData = guildStore.get(guildId); if (guildData) { clearUpdateInterval(guildData); diff --git a/src/services/music/prefixSupport.js b/src/services/music/prefixSupport.js deleted file mode 100644 index ade295648a..0000000000 --- a/src/services/music/prefixSupport.js +++ /dev/null @@ -1,10 +0,0 @@ -import { MessageFlags } from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export function getMusicDeferOptions(interaction) { - return interaction._isPrefixCommand ? {} : { flags: MessageFlags.Ephemeral }; -} - -export async function deferMusicCommand(interaction) { - return InteractionHelper.safeDefer(interaction, getMusicDeferOptions(interaction)); -} diff --git a/src/services/music/prefixSupport.ts b/src/services/music/prefixSupport.ts new file mode 100644 index 0000000000..ad2b130f9e --- /dev/null +++ b/src/services/music/prefixSupport.ts @@ -0,0 +1,12 @@ +import { MessageFlags } from 'discord.js'; +import type { InteractionDeferReplyOptions } from 'discord.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import type { NexoInteraction } from '../../utils/interactionHelper.js'; + +export function getMusicDeferOptions(interaction: NexoInteraction): InteractionDeferReplyOptions { + return interaction._isPrefixCommand ? {} : { flags: MessageFlags.Ephemeral }; +} + +export async function deferMusicCommand(interaction: NexoInteraction): Promise { + return InteractionHelper.safeDefer(interaction, getMusicDeferOptions(interaction)); +} diff --git a/src/services/music/riffySetup.js b/src/services/music/riffySetup.ts similarity index 72% rename from src/services/music/riffySetup.js rename to src/services/music/riffySetup.ts index 70fde72d50..b70c411fdc 100644 --- a/src/services/music/riffySetup.js +++ b/src/services/music/riffySetup.ts @@ -1,13 +1,16 @@ import { createRequire } from 'module'; +import type { EventEmitter } from 'node:events'; import { GatewayDispatchEvents } from 'discord.js'; +import type { Client, GatewayDispatchPayload } from 'discord.js'; +import type { Player } from 'riffy'; import { logger } from '../../utils/logger.js'; import lavalinkConfig from '../../config/music/lavalink.js'; import { setupPlayerHandler } from './playerHandler.js'; const require = createRequire(import.meta.url); -const { Riffy } = require('riffy'); +const { Riffy } = require('riffy') as typeof import('riffy'); -export function initializeMusic(client) { +export function initializeMusic(client: Client): void { if (!lavalinkConfig.nodes?.length) { logger.error('No Lavalink nodes configured. Add lavalink/nodes.json, set LAVALINK_NODES, or set LAVALINK_HOST in your environment.'); return; @@ -39,26 +42,27 @@ export function initializeMusic(client) { setupPlayerHandler(client); - client.on('raw', (packet) => { + client.on('raw', (packet: GatewayDispatchPayload) => { if ( - ![ + !([ GatewayDispatchEvents.VoiceStateUpdate, GatewayDispatchEvents.VoiceServerUpdate, - ].includes(packet.t) + ] as GatewayDispatchEvents[]).includes(packet.t) ) { return; } - client.riffy.updateVoiceState(packet); + client.riffy!.updateVoiceState(packet); }); - client.riffy.on('playerError', (player, error) => { + // 'playerError' is not in riffy's typed event map; listen through the base EventEmitter signature. + (client.riffy as EventEmitter).on('playerError', (player: Player, error: unknown) => { logger.error(`Music player error in guild ${player.guildId}:`, error); }); logger.info(`Music initialized with ${lavalinkConfig.nodes.length} Lavalink node(s).`); } -export function initRiffyAfterReady(client) { +export function initRiffyAfterReady(client: Client): void { if (client.riffy && client.user?.id) { client.riffy.init(client.user.id); logger.info('Riffy voice connection manager initialized.'); diff --git a/src/services/panelHealthService.js b/src/services/panelHealthService.ts similarity index 78% rename from src/services/panelHealthService.js rename to src/services/panelHealthService.ts index 1a5a32196e..46a8ebf6d6 100644 --- a/src/services/panelHealthService.js +++ b/src/services/panelHealthService.ts @@ -7,25 +7,43 @@ import { getReactionRolePanelStatus, } from '../utils/panelStatus.js'; import { getAllReactionRoleMessages } from './reactionRoleService.js'; +import type { GuildConfig, NexoClient } from '../types/index.js'; -async function persistVerificationMessageId(client, guildId, config, messageId) { +/** Stored reaction-role panel fields this module reads/updates. */ +interface ReactionRolePanelRef { + channelId?: string | null; + messageId?: string | null; +} + +export interface PanelHealthSummary { + scannedGuilds: number; + scannedPanels?: number; + healthyPanels: number; + deletedPanels: number; + missingChannels: number; + recoveredIds: number; + errors: number; +} + +async function persistVerificationMessageId(client: NexoClient, guildId: string, config: GuildConfig, messageId: string): Promise { if (!messageId || config.verification?.messageId === messageId) return; await patchGuildConfig(client, guildId, { verification: { ...config.verification, messageId }, }); } -async function persistReactionRoleMessageId(client, guildId, panelData, messageId) { +async function persistReactionRoleMessageId(client: NexoClient, guildId: string, panelData: ReactionRolePanelRef, messageId: string): Promise { if (!messageId || panelData.messageId === messageId) return; - const oldKey = getReactionRoleKey(guildId, panelData.messageId); + // Callers only pass panels with a stored messageId. + const oldKey = getReactionRoleKey(guildId, panelData.messageId!); panelData.messageId = messageId; const newKey = getReactionRoleKey(guildId, messageId); await client.db.set(newKey, panelData); await client.db.delete(oldKey).catch(() => {}); } -export async function reconcileTicketPanels(client) { - const summary = { +export async function reconcileTicketPanels(client: NexoClient): Promise { + const summary: PanelHealthSummary = { scannedGuilds: 0, healthyPanels: 0, deletedPanels: 0, @@ -62,15 +80,15 @@ export async function reconcileTicketPanels(client) { } } catch (error) { summary.errors += 1; - logger.warn(`Ticket panel health check failed for guild ${guild.id}:`, error.message); + logger.warn(`Ticket panel health check failed for guild ${guild.id}:`, (error as Error).message); } } return summary; } -export async function reconcileVerificationPanels(client) { - const summary = { +export async function reconcileVerificationPanels(client: NexoClient): Promise { + const summary: PanelHealthSummary = { scannedGuilds: 0, healthyPanels: 0, deletedPanels: 0, @@ -107,15 +125,15 @@ export async function reconcileVerificationPanels(client) { } } catch (error) { summary.errors += 1; - logger.warn(`Verification panel health check failed for guild ${guild.id}:`, error.message); + logger.warn(`Verification panel health check failed for guild ${guild.id}:`, (error as Error).message); } } return summary; } -export async function reconcileReactionRolePanelHealth(client) { - const summary = { +export async function reconcileReactionRolePanelHealth(client: NexoClient): Promise> { + const summary: Required = { scannedGuilds: 0, scannedPanels: 0, healthyPanels: 0, @@ -129,7 +147,7 @@ export async function reconcileReactionRolePanelHealth(client) { summary.scannedGuilds += 1; try { - const panels = await getAllReactionRoleMessages(client, guild.id); + const panels: ReactionRolePanelRef[] = await getAllReactionRoleMessages(client, guild.id); if (!panels?.length) continue; for (const panelData of panels) { @@ -159,7 +177,7 @@ export async function reconcileReactionRolePanelHealth(client) { } } catch (error) { summary.errors += 1; - logger.warn(`Reaction role panel health check failed for guild ${guild.id}:`, error.message); + logger.warn(`Reaction role panel health check failed for guild ${guild.id}:`, (error as Error).message); } } diff --git a/src/services/reactionRoleService.js b/src/services/reactionRoleService.ts similarity index 74% rename from src/services/reactionRoleService.js rename to src/services/reactionRoleService.ts index 7bd2863f3e..1b8efbf510 100644 --- a/src/services/reactionRoleService.js +++ b/src/services/reactionRoleService.ts @@ -1,12 +1,36 @@ // reactionRoleService.js +import type { PermissionsString, Role } from 'discord.js'; +import type { NexoClient } from '../types/index.js'; 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; -const DANGEROUS_PERMISSIONS = [ +/** Stored reaction role panel (`guild:{guildId}:reaction_roles:{messageId}` style key from getReactionRoleKey). */ +export interface ReactionRoleMessage { + guildId: string; + channelId: string; + messageId: string; + /** Role ids (select-menu panels), or emoji -> role id (legacy reaction mapping). */ + roles: string[] | Record; + createdAt?: string; +} + +export interface ReactionRoleReconcileSummary { + scannedGuilds: number; + scannedMessages: number; + removedMessages: number; + errors: number; +} + +/** Replit-era list/get results wrapped as `{ ok, value }`. */ +interface WrappedListResult { + value?: string[]; +} + +const DANGEROUS_PERMISSIONS: PermissionsString[] = [ 'Administrator', 'ManageGuild', 'ManageRoles', @@ -16,7 +40,7 @@ const DANGEROUS_PERMISSIONS = [ 'KickMembers' ]; -function validateGuildId(guildId) { +function validateGuildId(guildId: string): void { if (!guildId || typeof guildId !== 'string' || !/^\d{17,19}$/.test(guildId)) { throw createError( `Invalid guild ID: ${guildId}`, @@ -27,7 +51,7 @@ function validateGuildId(guildId) { } } -function validateMessageId(messageId) { +function validateMessageId(messageId: string): void { if (!messageId || typeof messageId !== 'string' || !/^\d{17,19}$/.test(messageId)) { throw createError( `Invalid message ID: ${messageId}`, @@ -38,7 +62,7 @@ function validateMessageId(messageId) { } } -function validateRoleId(roleId) { +function validateRoleId(roleId: string): void { if (!roleId || typeof roleId !== 'string' || !/^\d{17,19}$/.test(roleId)) { throw createError( `Invalid role ID: ${roleId}`, @@ -49,7 +73,7 @@ function validateRoleId(roleId) { } } -export function hasDangerousPermissions(role) { +export function hasDangerousPermissions(role: Role | null | undefined): boolean { if (!role || !role.permissions) return false; for (const permission of DANGEROUS_PERMISSIONS) { @@ -60,7 +84,7 @@ export function hasDangerousPermissions(role) { return false; } -async function validateRoleSafety(client, guildId, roleId) { +async function validateRoleSafety(client: NexoClient, guildId: string, roleId: string): Promise { const guild = client.guilds?.cache?.get(guildId) || await client.guilds?.fetch?.(guildId).catch(() => null); if (!guild) { throw createError( @@ -101,16 +125,16 @@ async function validateRoleSafety(client, guildId, roleId) { } } -export async function getReactionRoleMessage(client, guildId, messageId) { +export async function getReactionRoleMessage(client: NexoClient, guildId: string, messageId: string): Promise { try { validateGuildId(guildId); validateMessageId(messageId); const key = getReactionRoleKey(guildId, messageId); - const data = await client.db.get(key); + const data = await client.db.get(key); return data || null; } catch (error) { - if (error.name === 'NexoBotError') { + if ((error as Error).name === 'NexoBotError') { throw error; } logger.error(`Error getting reaction role message ${messageId} in guild ${guildId}:`, error); @@ -118,12 +142,18 @@ export async function getReactionRoleMessage(client, guildId, messageId) { `Database error retrieving reaction role message`, ErrorTypes.DATABASE, 'Failed to retrieve reaction role data. Please try again.', - { guildId, messageId, originalError: error.message } + { guildId, messageId, originalError: (error as Error).message } ); } } -export async function createReactionRoleMessage(client, guildId, channelId, messageId, roleIds) { +export async function createReactionRoleMessage( + client: NexoClient, + guildId: string, + channelId: string, + messageId: string, + roleIds: string[], +): Promise { try { validateGuildId(guildId); validateMessageId(messageId); @@ -160,7 +190,7 @@ export async function createReactionRoleMessage(client, guildId, channelId, mess await validateRoleSafety(client, guildId, roleId); } - const reactionRoleData = { + const reactionRoleData: ReactionRoleMessage = { guildId, channelId, messageId, @@ -174,7 +204,7 @@ export async function createReactionRoleMessage(client, guildId, channelId, mess logger.info(`Created reaction role message ${messageId} in guild ${guildId} with ${roleIds.length} roles`); return reactionRoleData; } catch (error) { - if (error.name === 'NexoBotError') { + if ((error as Error).name === 'NexoBotError') { throw error; } logger.error(`Error creating reaction role message in guild ${guildId}:`, error); @@ -182,12 +212,12 @@ export async function createReactionRoleMessage(client, guildId, channelId, mess `Database error creating reaction role message`, ErrorTypes.DATABASE, 'Failed to save reaction role data. Please try again.', - { guildId, messageId, originalError: error.message } + { guildId, messageId, originalError: (error as Error).message } ); } } -export async function addReactionRole(client, guildId, messageId, emoji, roleId) { +export async function addReactionRole(client: NexoClient, guildId: string, messageId: string, emoji: string, roleId: string): Promise { try { validateGuildId(guildId); validateMessageId(messageId); @@ -195,20 +225,20 @@ export async function addReactionRole(client, guildId, messageId, emoji, roleId) await validateRoleSafety(client, guildId, roleId); const key = getReactionRoleKey(guildId, messageId); - const data = await getReactionRoleMessage(client, guildId, messageId) || { + const data: ReactionRoleMessage = await getReactionRoleMessage(client, guildId, messageId) || { messageId, guildId, channelId: '', roles: {} }; - data.roles[emoji] = roleId; + (data.roles as Record)[emoji] = roleId; await client.db.set(key, data); logger.info(`Added reaction role for emoji ${emoji} to message ${messageId} in guild ${guildId}`); return true; } catch (error) { - if (error.name === 'NexoBotError') { + if ((error as Error).name === 'NexoBotError') { throw error; } logger.error(`Error adding reaction role in guild ${guildId}:`, error); @@ -216,12 +246,12 @@ export async function addReactionRole(client, guildId, messageId, emoji, roleId) `Database error adding reaction role`, ErrorTypes.DATABASE, 'Failed to add reaction role. Please try again.', - { guildId, messageId, originalError: error.message } + { guildId, messageId, originalError: (error as Error).message } ); } } -export async function deleteReactionRoleMessage(client, guildId, messageId) { +export async function deleteReactionRoleMessage(client: NexoClient, guildId: string, messageId: string): Promise { try { validateGuildId(guildId); validateMessageId(messageId); @@ -239,7 +269,7 @@ export async function deleteReactionRoleMessage(client, guildId, messageId) { logger.info(`Deleted reaction role message ${messageId} in guild ${guildId}`); return true; } catch (error) { - if (error.name === 'NexoBotError') { + if ((error as Error).name === 'NexoBotError') { throw error; } logger.error(`Error deleting reaction role message in guild ${guildId}:`, error); @@ -247,12 +277,12 @@ export async function deleteReactionRoleMessage(client, guildId, messageId) { `Database error deleting reaction role message`, ErrorTypes.DATABASE, 'Failed to delete reaction role message. Please try again.', - { guildId, messageId, originalError: error.message } + { guildId, messageId, originalError: (error as Error).message } ); } } -export async function removeReactionRole(client, guildId, messageId, emoji) { +export async function removeReactionRole(client: NexoClient, guildId: string, messageId: string, emoji: string): Promise { try { validateGuildId(guildId); validateMessageId(messageId); @@ -260,11 +290,11 @@ export async function removeReactionRole(client, guildId, messageId, emoji) { const key = getReactionRoleKey(guildId, messageId); const data = await getReactionRoleMessage(client, guildId, messageId); - if (!data || !data.roles[emoji]) { + if (!data || !(data.roles as Record)[emoji]) { return false; } - delete data.roles[emoji]; + delete (data.roles as Record)[emoji]; if (Object.keys(data.roles).length === 0) { await client.db.delete(key); @@ -276,7 +306,7 @@ export async function removeReactionRole(client, guildId, messageId, emoji) { return true; } catch (error) { - if (error.name === 'NexoBotError') { + if ((error as Error).name === 'NexoBotError') { throw error; } logger.error(`Error removing reaction role in guild ${guildId}:`, error); @@ -284,33 +314,34 @@ export async function removeReactionRole(client, guildId, messageId, emoji) { `Database error removing reaction role`, ErrorTypes.DATABASE, 'Failed to remove reaction role. Please try again.', - { guildId, messageId, originalError: error.message } + { guildId, messageId, originalError: (error as Error).message } ); } } -export async function getAllReactionRoleMessages(client, guildId) { +export async function getAllReactionRoleMessages(client: NexoClient, guildId: string): Promise { try { validateGuildId(guildId); const prefix = getReactionRolesPrefix(guildId); - let keys; + let keys: unknown; try { keys = await client.db.list(prefix); if (keys && typeof keys === 'object') { if (Array.isArray(keys)) { - } else if (keys.value && Array.isArray(keys.value)) { - keys = keys.value; + } else if ((keys as WrappedListResult).value && Array.isArray((keys as WrappedListResult).value)) { + keys = (keys as WrappedListResult).value; } else { - const allKeys = await client.db.list(); + // list() without a prefix is outside the typed store contract; legacy fallback kept as is. + const allKeys: unknown = await (client.db.list as (prefix?: string) => Promise)(); if (Array.isArray(allKeys)) { - keys = allKeys.filter(key => key.startsWith(prefix)); - } else if (allKeys.value && Array.isArray(allKeys.value)) { - keys = allKeys.value.filter(key => key.startsWith(prefix)); + keys = (allKeys as string[]).filter(key => key.startsWith(prefix)); + } else if ((allKeys as WrappedListResult).value && Array.isArray((allKeys as WrappedListResult).value)) { + keys = (allKeys as WrappedListResult).value!.filter(key => key.startsWith(prefix)); } else { return []; } @@ -324,22 +355,22 @@ export async function getAllReactionRoleMessages(client, guildId) { 'Database error listing reaction roles', ErrorTypes.DATABASE, 'Failed to retrieve reaction role list. Please try again.', - { guildId, originalError: listError.message } + { guildId, originalError: (listError as Error).message } ); } - if (!keys || keys.length === 0) { + if (!keys || (keys as string[]).length === 0) { return []; } - const messages = []; + const messages: ReactionRoleMessage[] = []; - for (const key of keys) { + for (const key of keys as string[]) { try { - const data = await client.db.get(key); + const data = await client.db.get(key); if (data) { - let actualData; + let actualData: ReactionRoleMessage | undefined; if (data && data.ok && data.value) { actualData = data.value; } else if (data && data.value) { @@ -362,7 +393,7 @@ export async function getAllReactionRoleMessages(client, guildId) { return messages; } catch (error) { - if (error.name === 'NexoBotError') { + if ((error as Error).name === 'NexoBotError') { throw error; } logger.error(`Error getting all reaction role messages for guild ${guildId}:`, error); @@ -370,12 +401,12 @@ export async function getAllReactionRoleMessages(client, guildId) { 'Database error retrieving reaction roles', ErrorTypes.DATABASE, 'Failed to retrieve reaction role messages. Please try again.', - { guildId, originalError: error.message } + { guildId, originalError: (error as Error).message } ); } } -export async function setReactionRoleChannel(client, guildId, messageId, channelId) { +export async function setReactionRoleChannel(client: NexoClient, guildId: string, messageId: string, channelId: string): Promise { try { validateGuildId(guildId); validateMessageId(messageId); @@ -390,7 +421,7 @@ export async function setReactionRoleChannel(client, guildId, messageId, channel } const key = getReactionRoleKey(guildId, messageId); - const data = await getReactionRoleMessage(client, guildId, messageId) || { + const data: ReactionRoleMessage = await getReactionRoleMessage(client, guildId, messageId) || { messageId, guildId, channelId: '', @@ -402,7 +433,7 @@ export async function setReactionRoleChannel(client, guildId, messageId, channel logger.info(`Set channel ${channelId} for reaction role message ${messageId}`); return true; } catch (error) { - if (error.name === 'NexoBotError') { + if ((error as Error).name === 'NexoBotError') { throw error; } logger.error(`Error setting channel for reaction role message ${messageId}:`, error); @@ -410,13 +441,13 @@ export async function setReactionRoleChannel(client, guildId, messageId, channel `Database error setting reaction role channel`, ErrorTypes.DATABASE, 'Failed to update reaction role channel. Please try again.', - { guildId, messageId, channelId, originalError: error.message } + { guildId, messageId, channelId, originalError: (error as Error).message } ); } } -export async function reconcileReactionRoleMessages(client, guildId = null) { - const summary = { +export async function reconcileReactionRoleMessages(client: NexoClient, guildId: string | null = null): Promise { + const summary: ReactionRoleReconcileSummary = { scannedGuilds: 0, scannedMessages: 0, removedMessages: 0, @@ -431,7 +462,7 @@ export async function reconcileReactionRoleMessages(client, guildId = null) { for (const targetGuildId of targetGuildIds) { summary.scannedGuilds += 1; - let reactionRoleMessages = []; + let reactionRoleMessages: ReactionRoleMessage[] = []; try { reactionRoleMessages = await getAllReactionRoleMessages(client, targetGuildId); } catch (error) { diff --git a/src/services/serverstatsService.js b/src/services/serverstatsService.ts similarity index 72% rename from src/services/serverstatsService.js rename to src/services/serverstatsService.ts index ef8f7e8a6d..c30636a8e0 100644 --- a/src/services/serverstatsService.js +++ b/src/services/serverstatsService.ts @@ -5,8 +5,38 @@ import { logEvent, EVENT_TYPES } from './loggingService.js'; import { formatLogLine } from '../utils/logging/logEmbeds.js'; import { getServerCountersKey } from '../utils/database/keys.js'; import botConfig from '../config/bot.js'; +import type { Guild, GuildBasedChannel } from 'discord.js'; +import type { NexoClient } from '../types/index.js'; -export const COUNTER_TYPE_CONFIG = { +export interface CounterTypeConfig { + label: string; + baseName: string; + emoji: string; +} + +/** Stored server counter (`guild:{guildId}:...` counters key). */ +export interface ServerCounter { + id: string; + type: string; + channelId: string; + guildId: string; + createdAt: string; + enabled: boolean; + updatedAt?: string; +} + +/** A counter as passed in or read back before normalization. */ +export interface ServerCounterInput { + id: string; + type: string; + channelId: string; + guildId?: string; + createdAt?: string; + enabled?: boolean; + updatedAt?: string; +} + +export const COUNTER_TYPE_CONFIG: Record = { members: { label: 'Members + Bots', baseName: 'Members & Bots', @@ -24,7 +54,7 @@ export const COUNTER_TYPE_CONFIG = { } }; -function getCounterConfig(type) { +function getCounterConfig(type: string): CounterTypeConfig { return COUNTER_TYPE_CONFIG[type] || { label: 'Unknown', baseName: 'Counter', @@ -32,19 +62,19 @@ function getCounterConfig(type) { }; } -export function getCounterTypeLabel(type) { +export function getCounterTypeLabel(type: string): string { return getCounterConfig(type).label; } -export function getCounterBaseName(type) { +export function getCounterBaseName(type: string): string { return getCounterConfig(type).baseName; } -export function getCounterEmoji(type) { +export function getCounterEmoji(type: string): string { return getCounterConfig(type).emoji; } -export function formatCounterChannelName(type, count) { +export function formatCounterChannelName(type: string, count: number | string): string { const template = botConfig.counters?.defaults?.channelName || '{name}-{count}'; const baseName = getCounterBaseName(type); return template @@ -52,8 +82,9 @@ export function formatCounterChannelName(type, count) { .replaceAll('{count}', String(count)); } -export function getCounterActionMessage(action, values = {}) { - const template = botConfig.counters?.messages?.[action]; +export function getCounterActionMessage(action: string, values: Record = {}): string | null { + const messages: Record | undefined = botConfig.counters?.messages; + const template = messages?.[action]; if (!template) { return null; } @@ -64,7 +95,7 @@ export function getCounterActionMessage(action, values = {}) { ); } -export async function getGuildCounterStats(guild) { +export async function getGuildCounterStats(guild: Guild): Promise<{ totalCount: number; botCount: number; humanCount: number }> { let memberCollection = guild.members.cache; try { @@ -86,7 +117,7 @@ export async function getGuildCounterStats(guild) { }; } -export async function getCounterCount(guild, type) { +export async function getCounterCount(guild: Guild, type: string): Promise { const stats = await getGuildCounterStats(guild); switch (type) { @@ -101,7 +132,8 @@ export async function getCounterCount(guild, type) { } } -function isValidCounterShape(counter) { +function isValidCounterShape(value: unknown): value is ServerCounterInput { + const counter = value as Partial> | null | undefined; return Boolean( counter && typeof counter === 'object' && @@ -113,8 +145,8 @@ function isValidCounterShape(counter) { ); } -function normalizeCounter(counter, guildId) { - const normalized = { +function normalizeCounter(counter: ServerCounterInput, guildId: string): ServerCounter { + const normalized: ServerCounter = { id: String(counter.id), type: String(counter.type), channelId: String(counter.channelId), @@ -130,7 +162,7 @@ function normalizeCounter(counter, guildId) { return normalized; } -function sanitizeCounters(counters, guildId) { +function sanitizeCounters(counters: unknown, guildId: string): ServerCounter[] { if (!Array.isArray(counters)) { return []; } @@ -140,7 +172,7 @@ function sanitizeCounters(counters, guildId) { .map(counter => normalizeCounter(counter, guildId)); } -export async function updateCounter(client, guild, counter) { +export async function updateCounter(client: NexoClient, guild: Guild, counter: ServerCounterInput | null | undefined): Promise { try { if (!counter || !counter.type || !counter.channelId) { logger.warn('Skipping invalid counter in updateCounter:', counter); @@ -148,7 +180,7 @@ export async function updateCounter(client, guild, counter) { } const { type, channelId } = counter; - let channel = guild.channels.cache.get(channelId); + let channel: GuildBasedChannel | null | undefined = guild.channels.cache.get(channelId); if (!channel) { try { channel = await guild.channels.fetch(channelId); @@ -219,16 +251,16 @@ export async function updateCounter(client, guild, counter) { } } -export async function getServerCounters(client, guildId) { +export async function getServerCounters(client: NexoClient, guildId: string): Promise { try { if (!client || !client.db) { logger.warn('Database not available for getServerCounters'); return []; } - const data = await client.db.get(getServerCountersKey(guildId)); + const data = await client.db.get<{ ok?: unknown; value?: unknown }>(getServerCountersKey(guildId)); - let counters = []; + let counters: unknown[] = []; if (data && typeof data === 'object' && data.ok && Array.isArray(data.value)) { counters = data.value; @@ -257,7 +289,7 @@ export async function getServerCounters(client, guildId) { } } -export async function saveServerCounters(client, guildId, counters) { +export async function saveServerCounters(client: NexoClient, guildId: string, counters: ServerCounterInput[]): Promise { try { if (!client || !client.db) { logger.warn('Database not available for saveServerCounters'); diff --git a/src/services/ticket.js b/src/services/ticket.ts similarity index 89% rename from src/services/ticket.js rename to src/services/ticket.ts index 158a364d1c..62894e43ae 100644 --- a/src/services/ticket.js +++ b/src/services/ticket.ts @@ -8,24 +8,43 @@ import { PermissionFlagsBits, AttachmentBuilder, } from 'discord.js'; +import type { + ActionRow, + Collection, + Guild, + GuildMember, + Message, + MessageActionRowComponent, + TextChannel, + User, +} from 'discord.js'; import { buildStandardLogEmbed, formatLogLine } from '../utils/logging/logEmbeds.js'; import { getGuildConfig } from './config/guildConfig.js'; import { getTicketData, saveTicketData, deleteTicketData, getOpenTicketCountForUser, incrementTicketCounter } from '../utils/database.js'; +import type { TicketData } from '../utils/database/tickets.js'; import { logger } from '../utils/logger.js'; import { createEmbed, errorEmbed } from '../utils/embeds.js'; import { logTicketEvent } from '../utils/ticket/ticketLogging.js'; import { createError, ErrorTypes } from '../utils/errorHandler.js'; +import type { ErrorContext, ErrorLike, ErrorType } 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 = {}) { +/** Who deleted a ticket: a GuildMember in practice; `username` is only present on a User. */ +interface TicketDeleter { + id: string; + username?: string; + displayAvatarURL?(): string; +} + +function ticketUserError(message: string, userMessage: string, type: ErrorType = ErrorTypes.VALIDATION, context: ErrorContext = {}): never { throw createError(message, type, userMessage, { service: TICKET_SERVICE, ...context }); } -function requireTicket(ticketData, channel) { +function requireTicket(ticketData: TicketData | null, channel: TextChannel | null | undefined): TicketData { if (!ticketData) { ticketUserError( 'Not a ticket channel', @@ -37,7 +56,7 @@ function requireTicket(ticketData, channel) { return ticketData; } -function rethrowTicketError(error, operation, userMessage, context = {}) { +function rethrowTicketError(error: unknown, operation: string, userMessage: string, context: ErrorContext = {}): never { throw ensureTypedServiceError(error, { service: TICKET_SERVICE, operation, @@ -49,8 +68,8 @@ function rethrowTicketError(error, operation, userMessage, context = {}) { -function buildTicketControlRow({ claimedBy = null } = {}) { - return new ActionRowBuilder().addComponents( +function buildTicketControlRow({ claimedBy = null }: { claimedBy?: string | null } = {}): ActionRowBuilder { + return new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('ticket_claim') .setLabel(claimedBy ? 'Claimed' : 'Claim') @@ -70,7 +89,7 @@ function buildTicketControlRow({ claimedBy = null } = {}) { ); } -export const getUserTicketCount = wrapServiceBoundary(async function getUserTicketCount(guildId, userId) { +export const getUserTicketCount = wrapServiceBoundary(async function getUserTicketCount(guildId: string, userId: string): Promise { return await getOpenTicketCountForUser(guildId, userId); }, { service: TICKET_SERVICE, @@ -79,7 +98,13 @@ export const getUserTicketCount = wrapServiceBoundary(async function getUserTick context: {}, }); -export async function createTicket(guild, member, categoryId, reason = 'No reason provided', priority = 'none') { +export async function createTicket( + guild: Guild, + member: GuildMember, + categoryId: string | null | undefined, + reason: string = 'No reason provided', + priority: string = 'none', +): Promise<{ channel: TextChannel; ticketData: TicketData }> { try { const config = await getGuildConfig(guild.client, guild.id); const ticketConfig = config.tickets || {}; @@ -157,7 +182,7 @@ export async function createTicket(guild, member, categoryId, reason = 'No reaso ], }); - const ticketData = { + const ticketData: TicketData = { id: channel.id, userId: member.id, guildId: guild.id, @@ -236,7 +261,7 @@ export async function createTicket(guild, member, categoryId, reason = 'No reaso } } -export async function closeTicket(channel, closer, reason = 'No reason provided') { +export async function closeTicket(channel: TextChannel, closer: User, reason: string = 'No reason provided'): Promise { try { const ticketData = requireTicket(await getTicketData(channel.guild.id, channel.id), channel); @@ -261,7 +286,7 @@ export async function closeTicket(channel, closer, reason = 'No reason provided' await channel.setParent(closedCategoryId, { lockPermissions: false }); movedToClosedCategory = true; } catch (moveError) { - logger.warn(`Could not move ticket ${channel.id} to closed category ${closedCategoryId}: ${moveError.message}`); + logger.warn(`Could not move ticket ${channel.id} to closed category ${closedCategoryId}: ${(moveError as Error).message}`); } } else { logger.warn(`Configured closed category is invalid for guild ${channel.guild.id}: ${closedCategoryId}`); @@ -290,14 +315,14 @@ export async function closeTicket(channel, closer, reason = 'No reason provided' }); const base = `ticket_feedback:${channel.guild.id}:${channel.id}`; - const starsRow = new ActionRowBuilder().addComponents( + const starsRow = new ActionRowBuilder().addComponents( new ButtonBuilder().setCustomId(`${base}:1`).setLabel('⭐ 1').setStyle(ButtonStyle.Secondary), new ButtonBuilder().setCustomId(`${base}:2`).setLabel('⭐ 2').setStyle(ButtonStyle.Secondary), new ButtonBuilder().setCustomId(`${base}:3`).setLabel('⭐ 3').setStyle(ButtonStyle.Secondary), new ButtonBuilder().setCustomId(`${base}:4`).setLabel('⭐ 4').setStyle(ButtonStyle.Secondary), new ButtonBuilder().setCustomId(`${base}:5`).setLabel('⭐ 5').setStyle(ButtonStyle.Primary), ); - const declineRow = new ActionRowBuilder().addComponents( + const declineRow = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`ticket_feedback_comment:${channel.guild.id}:${channel.id}`) .setLabel('✍️ Add Comment') @@ -313,11 +338,11 @@ export async function closeTicket(channel, closer, reason = 'No reason provided' components: [starsRow, declineRow], }); } catch (feedbackError) { - logger.warn(`Could not send feedback survey to ticket creator ${ticketData.userId}: ${feedbackError.message}`); + logger.warn(`Could not send feedback survey to ticket creator ${ticketData.userId}: ${(feedbackError as Error).message}`); } } } catch (dmError) { - logger.warn(`Could not send DM to ticket creator ${ticketData.userId}: ${dmError.message}`); + logger.warn(`Could not send DM to ticket creator ${ticketData.userId}: ${(dmError as Error).message}`); } } @@ -340,7 +365,7 @@ export async function closeTicket(channel, closer, reason = 'No reason provided' } } } catch (permError) { - logger.warn(`Could not update user permissions for closed ticket: ${permError.message}`); + logger.warn(`Could not update user permissions for closed ticket: ${(permError as Error).message}`); } const messages = await channel.messages.fetch(); @@ -378,7 +403,7 @@ components: [] footer: { text: `Ticket ID: ${ticketData.id}` } }); - const controlRow = new ActionRowBuilder().addComponents( + const controlRow = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('ticket_reopen') .setLabel('Reopen Ticket') @@ -418,7 +443,7 @@ components: [] } } -export async function claimTicket(channel, claimer) { +export async function claimTicket(channel: TextChannel, claimer: User): Promise { try { const ticketData = requireTicket(await getTicketData(channel.guild.id, channel.id), channel); @@ -464,7 +489,7 @@ export async function claimTicket(channel, claimer) { color: '#2ecc71' }); - const unclaimRow = new ActionRowBuilder().addComponents( + const unclaimRow = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('ticket_unclaim') .setLabel('Unclaim') @@ -505,7 +530,10 @@ export async function claimTicket(channel, claimer) { } } -export async function reopenTicket(channel, reopener) { +export async function reopenTicket( + channel: TextChannel, + reopener: GuildMember | User, +): Promise<{ ticketData: TicketData; movedToOpenCategory: boolean; openCategoryMoveFailed: boolean }> { try { const ticketData = requireTicket(await getTicketData(channel.guild.id, channel.id), channel); @@ -540,7 +568,7 @@ export async function reopenTicket(channel, reopener) { movedToOpenCategory = true; } catch (moveError) { openCategoryMoveFailed = true; - logger.warn(`Could not move reopened ticket ${channel.id} to open category ${openCategoryId}: ${moveError.message}`); + logger.warn(`Could not move reopened ticket ${channel.id} to open category ${openCategoryId}: ${(moveError as Error).message}`); } } else { openCategoryMoveFailed = true; @@ -559,7 +587,7 @@ export async function reopenTicket(channel, reopener) { }); } } catch (error) { - logger.warn(`Could not restore access for user ${ticketData.userId}:`, error.message); + logger.warn(`Could not restore access for user ${ticketData.userId}:`, (error as Error).message); } const messages = await channel.messages.fetch(); @@ -594,7 +622,8 @@ export async function reopenTicket(channel, reopener) { m.embeds.length > 0 && m.embeds[0].title === 'Ticket Closed' && m.components.length > 0 && - m.components[0].components.some(c => c.customId === 'ticket_reopen') + // Ticket messages only carry classic action rows. + (m.components[0] as ActionRow).components.some(c => c.customId === 'ticket_reopen') ); if (closeStatusMessage) { @@ -610,7 +639,7 @@ export async function reopenTicket(channel, reopener) { } } -function escapeHtml(text) { +function escapeHtml(text: unknown): string { if (!text) return ''; return String(text) .replace(/&/g, '&') @@ -620,16 +649,16 @@ function escapeHtml(text) { .replace(/'/g, '''); } -async function generateTranscript(channel) { +async function generateTranscript(channel: TextChannel): Promise { try { logger.debug('Generating transcript for channel', { channelId: channel.id, channelName: channel.name }); - const messages = []; - let before = undefined; - let batch; + const messages: Message[] = []; + let before: string | undefined = undefined; + let batch: Collection>; do { batch = await channel.messages.fetch({ limit: 100, ...(before ? { before } : {}) }); if (batch.size === 0) break; @@ -639,7 +668,7 @@ async function generateTranscript(channel) { messages.sort((a, b) => a.createdTimestamp - b.createdTimestamp); - const escape = (str) => + const escape = (str: unknown): string => String(str ?? '') .replace(/&/g, '&') .replace(/ { try { const ticketData = requireTicket(await getTicketData(channel.guild.id, channel.id), channel); @@ -740,7 +769,7 @@ export async function deleteTicket(channel, deleter) { ticketId: ticketData.id }); - let attachment = null; + let attachment: AttachmentBuilder | null = null; try { attachment = await generateTranscript(channel); if (attachment) { @@ -758,7 +787,7 @@ export async function deleteTicket(channel, deleter) { logger.error('Error during transcript generation', { channelId: channel.id, ticketNumber: ticketData.id, - error: transcriptError.message + error: (transcriptError as Error).message }); } @@ -815,7 +844,7 @@ export async function deleteTicket(channel, deleter) { logger.error('Failed to send transcript to channel:', { channelId: channel.id, ticketNumber: ticketData.id, - error: sendError.message + error: (sendError as Error).message }); } } @@ -832,9 +861,9 @@ export async function deleteTicket(channel, deleter) { channelId: channel.id, channelName: channel.name, ticketNumber: ticketData.id, - errorMessage: deleteError.message, - errorCode: deleteError.code, - errorName: deleteError.name + errorMessage: (deleteError as Error).message, + errorCode: (deleteError as ErrorLike).code, + errorName: (deleteError as Error).name }); } } catch (error) { @@ -842,9 +871,9 @@ export async function deleteTicket(channel, deleter) { channelId: channel.id, channelName: channel?.name, ticketNumber: ticketData?.id, - errorMessage: error.message, - errorName: error.name, - errorStack: error.stack + errorMessage: (error as Error).message, + errorName: (error as Error).name, + errorStack: (error as Error).stack }); } }, TICKET_DELETE_DELAY_MS); @@ -856,7 +885,7 @@ export async function deleteTicket(channel, deleter) { } } -export async function unclaimTicket(channel, unclaimer) { +export async function unclaimTicket(channel: TextChannel, unclaimer: GuildMember): Promise { try { const ticketData = requireTicket(await getTicketData(channel.guild.id, channel.id), channel); @@ -954,11 +983,11 @@ export async function unclaimTicket(channel, unclaimer) { } } -async function getNextTicketNumber(guildId) { +async function getNextTicketNumber(guildId: string): Promise { return await incrementTicketCounter(guildId); } -export async function updateTicketPriority(channel, priority, updater) { +export async function updateTicketPriority(channel: TextChannel, priority: string, updater: User): Promise { try { const ticketData = requireTicket(await getTicketData(channel.guild.id, channel.id), channel); @@ -990,7 +1019,7 @@ export async function updateTicketPriority(channel, priority, updater) { try { await channel.setName(newName); } catch (nameError) { - logger.warn(`Could not update channel name for priority: ${nameError.message}`); + logger.warn(`Could not update channel name for priority: ${(nameError as Error).message}`); } } diff --git a/src/services/verificationService.js b/src/services/verificationService.ts similarity index 87% rename from src/services/verificationService.js rename to src/services/verificationService.ts index 49a548c07d..7cc3749dc5 100644 --- a/src/services/verificationService.js +++ b/src/services/verificationService.ts @@ -1,6 +1,8 @@ // verificationService.js import { PermissionFlagsBits } from 'discord.js'; +import type { Guild, GuildMember, PermissionsString, Role } from 'discord.js'; +import type { GuildConfig, NexoClient } from '../types/index.js'; import { botConfig } from '../config/bot.js'; import { logger } from '../utils/logger.js'; import { getGuildConfig, setGuildConfig } from './config/guildConfig.js'; @@ -8,8 +10,29 @@ import { createError, ErrorTypes } from '../utils/errorHandler.js'; import { insertVerificationAudit } from '../utils/database.js'; import { ensureTypedServiceError } from '../utils/serviceErrorBoundary.js'; -const verificationCooldowns = new Map(); -const attemptTracker = new Map(); +export type VerificationConfig = NonNullable; +export type AutoVerifyConfig = NonNullable; + +export interface VerifyUserOptions { + source?: string; + moderatorId?: string | null; +} + +export interface RemoveVerificationOptions { + moderatorId?: string | null; + reason?: string; +} + +/** Audit metadata; moderatorId/removedBy/source are lifted into the audit record. */ +interface VerificationActionMetadata { + source?: string; + moderatorId?: string | null; + removedBy?: string | null; + [key: string]: unknown; +} + +const verificationCooldowns = new Map(); +const attemptTracker = new Map(); const verificationDefaults = botConfig?.verification || {}; const autoVerifyDefaults = verificationDefaults.autoVerify || {}; @@ -28,7 +51,7 @@ const shouldLogVerifications = verificationDefaults.logAllVerifications ?? true; const shouldKeepAuditTrail = verificationDefaults.keepAuditTrail ?? false; let lastCleanupAt = 0; -export async function verifyUser(client, guildId, userId, options = {}) { +export async function verifyUser(client: NexoClient, guildId: string, userId: string, options: VerifyUserOptions = {}) { const { source = 'manual', moderatorId = null } = options; try { @@ -43,7 +66,7 @@ export async function verifyUser(client, guildId, userId, options = {}) { ); } - let member; + let member: GuildMember; try { member = await guild.members.fetch(userId); } catch (error) { @@ -68,7 +91,8 @@ export async function verifyUser(client, guildId, userId, options = {}) { await validateVerificationSetup(guild, guildConfig.verification); - const verifiedRole = guild.roles.cache.get(guildConfig.verification.roleId); + // validateVerificationSetup above guarantees the role exists. + const verifiedRole = guild.roles.cache.get(guildConfig.verification.roleId!)!; const canAssignRole = await validateBotCanAssignRole(guild, verifiedRole.id); if (!canAssignRole) { throw createError( @@ -135,7 +159,7 @@ export async function verifyUser(client, guildId, userId, options = {}) { } } -function pruneVerificationTrackers(now = Date.now()) { +function pruneVerificationTrackers(now = Date.now()): void { if (now - lastCleanupAt < cooldownCleanupIntervalMs) { return; } @@ -174,7 +198,7 @@ function pruneVerificationTrackers(now = Date.now()) { } } -export async function autoVerifyOnJoin(client, guild, member, verificationConfig) { +export async function autoVerifyOnJoin(client: NexoClient, guild: Guild, member: GuildMember, verificationConfig: VerificationConfig) { try { if (!verificationConfig.autoVerify?.enabled) { @@ -212,7 +236,8 @@ export async function autoVerifyOnJoin(client, guild, member, verificationConfig }; } - const verifiedRole = guild.roles.cache.get(autoVerifyRoleId); + // validateVerificationSetup above guarantees the role exists. + const verifiedRole = guild.roles.cache.get(autoVerifyRoleId)!; const canAssign = await validateBotCanAssignRole(guild, verifiedRole.id); if (!canAssign) { @@ -289,7 +314,7 @@ export async function autoVerifyOnJoin(client, guild, member, verificationConfig } } -export async function removeVerification(client, guildId, userId, options = {}) { +export async function removeVerification(client: NexoClient, guildId: string, userId: string, options: RemoveVerificationOptions = {}) { const { moderatorId = null, reason = 'admin_removal' } = options; try { @@ -303,7 +328,7 @@ export async function removeVerification(client, guildId, userId, options = {}) ); } - let member; + let member: GuildMember; try { member = await guild.members.fetch(userId); } catch (error) { @@ -326,7 +351,7 @@ export async function removeVerification(client, guildId, userId, options = {}) ); } - const verifiedRole = guild.roles.cache.get(guildConfig.verification.roleId); + const verifiedRole = guild.roles.cache.get(guildConfig.verification.roleId!); if (!verifiedRole) { throw createError( "Verified role not found", @@ -397,7 +422,7 @@ export async function removeVerification(client, guildId, userId, options = {}) } } -export async function validateVerificationSetup(guild, verificationConfig) { +export async function validateVerificationSetup(guild: Guild, verificationConfig: VerificationConfig): Promise { const botMember = guild.members.me; if (!botMember) { throw createError( @@ -408,7 +433,7 @@ export async function validateVerificationSetup(guild, verificationConfig) { ); } - const verifiedRole = guild.roles.cache.get(verificationConfig.roleId); + const verifiedRole = guild.roles.cache.get(verificationConfig.roleId!); if (!verifiedRole) { throw createError( "Verified role not found", @@ -430,7 +455,7 @@ export async function validateVerificationSetup(guild, verificationConfig) { } const botPerms = channel.permissionsFor(botMember); - const requiredPerms = ['ViewChannel', 'SendMessages', 'EmbedLinks']; + const requiredPerms: PermissionsString[] = ['ViewChannel', 'SendMessages', 'EmbedLinks']; const missingPerms = requiredPerms.filter(perm => !botPerms.has(perm)); if (missingPerms.length > 0) { @@ -446,7 +471,7 @@ export async function validateVerificationSetup(guild, verificationConfig) { return true; } -export async function validateBotCanAssignRole(guild, roleId) { +export async function validateBotCanAssignRole(guild: Guild, roleId: string): Promise { const role = guild.roles.cache.get(roleId); if (!role) { @@ -488,13 +513,13 @@ export async function validateBotCanAssignRole(guild, roleId) { return true; } -function evaluateAutoVerifyCriteria(member, autoVerifyConfig) { +function evaluateAutoVerifyCriteria(member: GuildMember, autoVerifyConfig: AutoVerifyConfig): boolean { const { criteria, accountAgeDays } = autoVerifyConfig; switch (criteria) { case 'account_age': { const accountAge = Date.now() - member.user.createdTimestamp; - const requiredAge = accountAgeDays * 24 * 60 * 60 * 1000; + const requiredAge = accountAgeDays! * 24 * 60 * 60 * 1000; return accountAge >= requiredAge; } @@ -510,7 +535,7 @@ function evaluateAutoVerifyCriteria(member, autoVerifyConfig) { } } -export async function checkVerificationCooldown(userId, guildId, cooldownMs = defaultCooldownMs) { +export async function checkVerificationCooldown(userId: string, guildId: string, cooldownMs: number = defaultCooldownMs): Promise { pruneVerificationTrackers(); const key = `${guildId}:${userId}`; @@ -530,11 +555,11 @@ export async function checkVerificationCooldown(userId, guildId, cooldownMs = de } export async function trackVerificationAttempt( - userId, - guildId, - maxAttempts = defaultMaxAttempts, - windowMs = defaultAttemptWindowMs -) { + userId: string, + guildId: string, + maxAttempts: number = defaultMaxAttempts, + windowMs: number = defaultAttemptWindowMs +): Promise { pruneVerificationTrackers(); const key = `${guildId}:${userId}`; @@ -556,7 +581,7 @@ export async function trackVerificationAttempt( attemptTracker.set(key, recentAttempts); } -async function sendAutoVerifyNotification(member, role, guild) { +async function sendAutoVerifyNotification(member: GuildMember, role: Role, guild: Guild): Promise { try { const { createEmbed } = await import('../utils/embeds.js'); @@ -583,13 +608,13 @@ async function sendAutoVerifyNotification(member, role, guild) { logger.debug('Could not send auto-verify DM notification', { userId: member.id, guildId: guild.id, - reason: error.message + reason: error instanceof Error ? error.message : String(error) }); } } -function logVerificationAction(client, guildId, userId, action, metadata = {}) { +function logVerificationAction(client: NexoClient, guildId: string, userId: string, action: string, metadata: VerificationActionMetadata = {}): void { if (!shouldLogVerifications) { return; } @@ -622,9 +647,9 @@ function logVerificationAction(client, guildId, userId, action, metadata = {}) { }); } -function sanitizeAuditMetadata(metadata = {}) { +function sanitizeAuditMetadata(metadata: unknown = {}): Record { try { - const payload = metadata && typeof metadata === 'object' ? metadata : { value: metadata }; + const payload = metadata && typeof metadata === 'object' ? (metadata as Record) : { value: metadata }; const json = JSON.stringify(payload); if (!json) { @@ -648,7 +673,7 @@ function sanitizeAuditMetadata(metadata = {}) { } } -export function validateAutoVerifyCriteria(criteria, accountAgeDays) { +export function validateAutoVerifyCriteria(criteria: string, accountAgeDays: number | null | undefined): { criteria: string; accountAgeDays: number | null | undefined } { const validCriteria = ['account_age', 'server_size', 'none']; if (!validCriteria.includes(criteria)) { diff --git a/src/types/index.ts b/src/types/index.ts index 5a93b9481e..1c0dd07a3c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -26,6 +26,7 @@ export type DatabaseWrapper = typeof db; export interface GuildConfigExtras { enabledCommands?: Record; setupWizardCompleted?: boolean; + birthdayRoleId?: string | null; ticketPanelChannelId?: string | null; ticketPanelMessageId?: string | null; ticketPanelMessage?: string | null; @@ -36,6 +37,7 @@ export interface GuildConfigExtras { ticketLogsChannelId?: string | null; ticketTranscriptChannelId?: string | null; maxTicketsPerUser?: number; + tickets?: { enablePriority?: boolean }; } /** Stored per-guild configuration (passthrough: unknown keys are kept). */ diff --git a/src/utils/database/tickets.ts b/src/utils/database/tickets.ts index 85e2990be3..53e62350c2 100644 --- a/src/utils/database/tickets.ts +++ b/src/utils/database/tickets.ts @@ -14,7 +14,7 @@ export interface TicketData { claimedBy: string | null; priority: string; reason?: string; - claimedAt?: string; + claimedAt?: string | null; closedBy?: string | null; closedAt?: string | null; closeReason?: string | null; From 9e814f7809e9d1de91dbbdbc6bc84dd953a2b07d Mon Sep 17 00:00:00 2001 From: robertwalter Date: Sat, 17 Oct 2026 16:39:14 +0200 Subject: [PATCH 06/20] refactor(ts): convert handlers, interactions, events, commands and app to TypeScript Completes the migration: no .js files left in src, allowJs removed, strict mode without any. Slash command payloads and loaded handlers are identical to the JS version. Behaviour-relevant fixes found during conversion: - calculate modal used non-existent fields.first() and always failed - help: missing logger import on an error path - economy dashboard: currency name regexes never matched bot config; config path works for both tsx (src) and dist Co-Authored-By: Claude Opus 5.5 --- src/{app.js => app.ts} | 100 +++++++---- .../Birthday/{birthday.js => birthday.ts} | 3 +- .../{birthday_info.js => birthday_info.ts} | 5 +- .../{birthday_list.js => birthday_list.ts} | 9 +- ...{birthday_remove.js => birthday_remove.ts} | 5 +- .../{birthday_set.js => birthday_set.ts} | 9 +- ...y_setchannel.js => birthday_setchannel.ts} | 9 +- .../{next_birthdays.js => next_birthdays.ts} | 13 +- .../Community/{app-admin.js => app-admin.ts} | 81 ++++----- src/commands/Community/{apply.js => apply.ts} | 81 ++++----- .../{app_dashboard.js => app_dashboard.ts} | 139 ++++++++------- .../Core/{commands.js => commands.ts} | 30 ++-- .../Core/{configWizard.js => configWizard.ts} | 117 ++++++++----- src/commands/Core/{help.js => help.ts} | 14 +- ...nds_dashboard.js => commands_dashboard.ts} | 89 ++++++---- src/commands/Core/{ping.js => ping.ts} | 14 +- src/commands/Core/{stats.js => stats.ts} | 3 +- src/commands/Core/{support.js => support.ts} | 5 +- src/commands/Core/{uptime.js => uptime.ts} | 3 +- .../Economy/{balance.js => balance.ts} | 7 +- src/commands/Economy/{beg.js => beg.ts} | 8 +- src/commands/Economy/{buy.js => buy.ts} | 19 +- src/commands/Economy/{crime.js => crime.ts} | 14 +- src/commands/Economy/{daily.js => daily.ts} | 9 +- .../Economy/{deposit.js => deposit.ts} | 7 +- .../Economy/{economy.js => economy.ts} | 3 +- .../{eleaderboard.js => eleaderboard.ts} | 12 +- src/commands/Economy/{fish.js => fish.ts} | 14 +- src/commands/Economy/{gamble.js => gamble.ts} | 10 +- .../Economy/{inventory.js => inventory.ts} | 7 +- src/commands/Economy/{mine.js => mine.ts} | 8 +- ...nomy_dashboard.js => economy_dashboard.ts} | 66 ++++--- .../{shop_browse.js => shop_browse.ts} | 15 +- ...nfig_setrole.js => shop_config_setrole.ts} | 11 +- src/commands/Economy/{pay.js => pay.ts} | 11 +- src/commands/Economy/{rob.js => rob.ts} | 7 +- .../{shop-config.js => shop-config.ts} | 3 +- src/commands/Economy/{shop.js => shop.ts} | 3 +- src/commands/Economy/{slut.js => slut.ts} | 14 +- .../Economy/{withdraw.js => withdraw.ts} | 7 +- src/commands/Economy/{work.js => work.ts} | 5 +- src/commands/Fun/{count.js => count.ts} | 7 +- src/commands/Fun/{fight.js => fight.ts} | 9 +- src/commands/Fun/{flip.js => flip.ts} | 3 +- src/commands/Fun/{roll.js => roll.ts} | 5 +- .../Giveaway/{gcreate.js => gcreate.ts} | 14 +- .../Giveaway/{gdelete.js => gdelete.ts} | 15 +- src/commands/Giveaway/{gend.js => gend.ts} | 13 +- .../Giveaway/{greroll.js => greroll.ts} | 24 +-- .../{jointocreate.js => jointocreate.ts} | 106 +++++------ .../{config_setup.js => config_setup.ts} | 47 ++--- .../modules/{setup.js => setup.ts} | 20 ++- .../{leaderboard.js => leaderboard.ts} | 9 +- src/commands/Leveling/{level.js => level.ts} | 10 +- .../Leveling/{leveladd.js => leveladd.ts} | 13 +- .../{levelremove.js => levelremove.ts} | 15 +- .../Leveling/{levelset.js => levelset.ts} | 13 +- ...{level_dashboard.js => level_dashboard.ts} | 67 +++---- src/commands/Leveling/{rank.js => rank.ts} | 13 +- .../Logging/{logging.js => logging.ts} | 3 +- ...{logging_channel.js => logging_channel.ts} | 17 +- ...ging_dashboard.js => logging_dashboard.ts} | 52 +++--- src/commands/Moderation/{ban.js => ban.ts} | 9 +- .../Moderation/{cases.js => cases.ts} | 13 +- src/commands/Moderation/{dm.js => dm.ts} | 13 +- src/commands/Moderation/{kick.js => kick.ts} | 11 +- src/commands/Moderation/{lock.js => lock.ts} | 11 +- .../Moderation/{massban.js => massban.ts} | 31 ++-- .../Moderation/{masskick.js => masskick.ts} | 29 +-- .../Moderation/{purge.js => purge.ts} | 9 +- src/commands/Moderation/{say.js => say.ts} | 21 ++- .../Moderation/{timeout.js => timeout.ts} | 13 +- .../Moderation/{unban.js => unban.ts} | 11 +- .../Moderation/{unlock.js => unlock.ts} | 11 +- .../Moderation/{untimeout.js => untimeout.ts} | 11 +- .../Moderation/{usernotes.js => usernotes.ts} | 50 +++--- src/commands/Moderation/{warn.js => warn.ts} | 11 +- .../Moderation/{warnings.js => warnings.ts} | 9 +- src/commands/Music/{join.js => join.ts} | 3 +- src/commands/Music/{music.js => music.ts} | 18 +- .../Music/{nowplaying.js => nowplaying.ts} | 5 +- src/commands/Music/{play.js => play.ts} | 5 +- src/commands/Music/{queue.js => queue.ts} | 5 +- .../{reactroles.js => reactroles.ts} | 165 ++++++++++-------- .../{search_define.js => search_define.ts} | 32 +++- .../{search_google.js => search_google.ts} | 5 +- .../{search_urban.js => search_urban.ts} | 38 ++-- src/commands/Search/{search.js => search.ts} | 9 +- ...rstats_create.js => serverstats_create.ts} | 13 +- ...rstats_delete.js => serverstats_delete.ts} | 16 +- ...erverstats_list.js => serverstats_list.ts} | 19 +- ...rstats_update.js => serverstats_update.ts} | 10 +- .../{serverstats.js => serverstats.ts} | 3 +- src/commands/Ticket/{claim.js => claim.ts} | 11 +- src/commands/Ticket/{close.js => close.ts} | 11 +- ...icket_dashboard.js => ticket_dashboard.ts} | 144 +++++++-------- .../Ticket/{priority.js => priority.ts} | 13 +- src/commands/Ticket/{ticket.js => ticket.ts} | 19 +- .../Tools/{baseconvert.js => baseconvert.ts} | 24 ++- .../Tools/{calculate.js => calculate.ts} | 55 ++++-- .../Tools/{countdown.js => countdown.ts} | 24 ++- .../{embedbuilder.js => embedbuilder.ts} | 151 ++++++++++------ ...eneratepassword.js => generatepassword.ts} | 3 +- .../Tools/{hexcolor.js => hexcolor.ts} | 19 +- src/commands/Tools/{poll.js => poll.ts} | 7 +- .../Tools/{randomuser.js => randomuser.ts} | 24 +-- src/commands/Tools/{shorten.js => shorten.ts} | 7 +- src/commands/Tools/{time.js => time.ts} | 3 +- .../Tools/{unixtime.js => unixtime.ts} | 3 +- src/commands/Utility/{avatar.js => avatar.ts} | 5 +- .../Utility/{firstmsg.js => firstmsg.ts} | 9 +- .../Utility/modules/{report.js => report.ts} | 15 +- ...ort_setchannel.js => report_setchannel.ts} | 11 +- src/commands/Utility/{report.js => report.ts} | 3 +- .../Utility/{serverinfo.js => serverinfo.ts} | 5 +- src/commands/Utility/{todo.js => todo.ts} | 76 +++++--- .../Utility/{userinfo.js => userinfo.ts} | 5 +- .../Utility/{weather.js => weather.ts} | 42 ++++- .../Utility/{wipedata.js => wipedata.ts} | 3 +- .../{autoverify.js => autoverify.ts} | 0 .../modules/{autoVerify.js => autoVerify.ts} | 23 ++- ...ifyDashboard.js => autoVerifyDashboard.ts} | 89 +++++----- ...dashboard.js => verification_dashboard.ts} | 112 ++++++------ .../{verification.js => verification.ts} | 20 ++- .../Verification/{verify.js => verify.ts} | 5 +- .../Welcome/{autorole.js => autorole.ts} | 22 +-- .../Welcome/{goodbye.js => goodbye.ts} | 12 +- src/commands/Welcome/{greet.js => greet.ts} | 3 +- ...{greet_dashboard.js => greet_dashboard.ts} | 94 +++++----- .../Welcome/{welcome.js => welcome.ts} | 10 +- .../{channelDelete.js => channelDelete.ts} | 3 +- src/events/{guildCreate.js => guildCreate.ts} | 3 +- .../{guildMemberAdd.js => guildMemberAdd.ts} | 32 ++-- ...ldMemberRemove.js => guildMemberRemove.ts} | 21 ++- ...ldMemberUpdate.js => guildMemberUpdate.ts} | 5 +- ...eractionCreate.js => interactionCreate.ts} | 60 ++++--- .../{messageCreate.js => messageCreate.ts} | 23 +-- .../{messageDelete.js => messageDelete.ts} | 8 +- .../{messageUpdate.js => messageUpdate.ts} | 6 +- src/events/{ready.js => ready.ts} | 3 +- src/events/{roleCreate.js => roleCreate.ts} | 3 +- src/events/{roleDelete.js => roleDelete.ts} | 3 +- src/events/{userUpdate.js => userUpdate.ts} | 3 +- ...oiceStateUpdate.js => voiceStateUpdate.ts} | 52 +++--- ...{calculateModals.js => calculateModals.ts} | 17 +- ...ountdownButtons.js => countdownButtons.ts} | 25 +-- .../{counterButtons.js => counterButtons.ts} | 5 +- ...{giveawayButtons.js => giveawayButtons.ts} | 22 +-- .../help/{helpButtons.js => helpButtons.ts} | 28 +-- ...{helpSelectMenus.js => helpSelectMenus.ts} | 86 ++++++--- ...lectMenu.js => reactionRolesSelectMenu.ts} | 12 +- .../{commandLoader.js => commandLoader.ts} | 88 +++++++--- src/handlers/loaders/{events.js => events.ts} | 20 ++- .../{interactions.js => interactions.ts} | 27 ++- .../{loggingButtons.js => loggingButtons.ts} | 77 ++++---- .../{musicButtons.js => musicButtons.ts} | 36 ++-- .../{ticketButtons.js => ticketButtons.ts} | 99 +++++++---- .../{todoButtons.js => todoButtons.ts} | 49 ++++-- ...ationButtons.js => verificationButtons.ts} | 6 +- ...{warningHandlers.js => warningHandlers.ts} | 20 ++- ...{wipedataButtons.js => wipedataButtons.ts} | 13 +- .../countdown/{countdown.js => countdown.ts} | 3 +- .../{counterDelete.js => counterDelete.ts} | 0 .../giveaway/{giveaway.js => giveaway.ts} | 5 +- .../buttons/help/{help.js => help.ts} | 3 +- .../logging/{logging.js => logging.ts} | 3 +- .../buttons/music/{music.js => music.ts} | 3 +- .../buttons/ticket/{ticket.js => ticket.ts} | 3 +- .../{ticketFeedback.js => ticketFeedback.ts} | 19 +- .../todo/{todoShared.js => todoShared.ts} | 3 +- .../{verification.js => verification.ts} | 3 +- .../warnings/{warnings.js => warnings.ts} | 3 +- src/interactions/buttons/wipedata/wipedata.js | 6 - src/interactions/buttons/wipedata/wipedata.ts | 7 + .../calculate/{calculate.js => calculate.ts} | 3 +- .../{config_modal.js => config_modal.ts} | 22 +-- .../modals/ticket/{ticket.js => ticket.ts} | 3 +- ...ackComment.js => ticketFeedbackComment.ts} | 12 +- .../todo/{todoShared.js => todoShared.ts} | 3 +- .../modals/warnings/warningDelete.js | 20 --- .../modals/warnings/warningDelete.ts | 18 ++ .../help/{helpCategory.js => helpCategory.ts} | 0 .../logging/{logging.js => logging.ts} | 3 +- .../reactionRoles/reaction_roles.js | 10 -- .../reactionRoles/reaction_roles.ts | 12 ++ .../{ticketFeedback.js => ticketFeedback.ts} | 11 +- src/types/index.ts | 1 + src/utils/database/guildConfigStorage.ts | 6 +- src/utils/ticket/ticketPermissions.ts | 6 +- tsconfig.json | 2 - 190 files changed, 2465 insertions(+), 1738 deletions(-) rename src/{app.js => app.ts} (81%) rename src/commands/Birthday/{birthday.js => birthday.ts} (98%) rename src/commands/Birthday/modules/{birthday_info.js => birthday_info.ts} (92%) rename src/commands/Birthday/modules/{birthday_list.js => birthday_list.ts} (89%) rename src/commands/Birthday/modules/{birthday_remove.js => birthday_remove.ts} (89%) rename src/commands/Birthday/modules/{birthday_set.js => birthday_set.ts} (74%) rename src/commands/Birthday/modules/{birthday_setchannel.js => birthday_setchannel.ts} (87%) rename src/commands/Birthday/modules/{next_birthdays.js => next_birthdays.ts} (86%) rename src/commands/Community/{app-admin.js => app-admin.ts} (89%) rename src/commands/Community/{apply.js => apply.ts} (86%) rename src/commands/Community/modules/{app_dashboard.js => app_dashboard.ts} (86%) rename src/commands/Core/{commands.js => commands.ts} (89%) rename src/commands/Core/{configWizard.js => configWizard.ts} (85%) rename src/commands/Core/{help.js => help.ts} (92%) rename src/commands/Core/modules/{commands_dashboard.js => commands_dashboard.ts} (79%) rename src/commands/Core/{ping.js => ping.ts} (84%) rename src/commands/Core/{stats.js => stats.ts} (95%) rename src/commands/Core/{support.js => support.ts} (89%) rename src/commands/Core/{uptime.js => uptime.ts} (95%) rename src/commands/Economy/{balance.js => balance.ts} (93%) rename src/commands/Economy/{beg.js => beg.ts} (92%) rename src/commands/Economy/{buy.js => buy.ts} (91%) rename src/commands/Economy/{crime.js => crime.ts} (92%) rename src/commands/Economy/{daily.js => daily.ts} (93%) rename src/commands/Economy/{deposit.js => deposit.ts} (97%) rename src/commands/Economy/{economy.js => economy.ts} (93%) rename src/commands/Economy/{eleaderboard.js => eleaderboard.ts} (88%) rename src/commands/Economy/{fish.js => fish.ts} (93%) rename src/commands/Economy/{gamble.js => gamble.ts} (94%) rename src/commands/Economy/{inventory.js => inventory.ts} (93%) rename src/commands/Economy/{mine.js => mine.ts} (92%) rename src/commands/Economy/modules/{economy_dashboard.js => economy_dashboard.ts} (85%) rename src/commands/Economy/modules/{shop_browse.js => shop_browse.ts} (88%) rename src/commands/Economy/modules/{shop_config_setrole.js => shop_config_setrole.ts} (78%) rename src/commands/Economy/{pay.js => pay.ts} (96%) rename src/commands/Economy/{rob.js => rob.ts} (98%) rename src/commands/Economy/{shop-config.js => shop-config.ts} (93%) rename src/commands/Economy/{shop.js => shop.ts} (83%) rename src/commands/Economy/{slut.js => slut.ts} (92%) rename src/commands/Economy/{withdraw.js => withdraw.ts} (95%) rename src/commands/Economy/{work.js => work.ts} (97%) rename src/commands/Fun/{count.js => count.ts} (97%) rename src/commands/Fun/{fight.js => fight.ts} (91%) rename src/commands/Fun/{flip.js => flip.ts} (92%) rename src/commands/Fun/{roll.js => roll.ts} (96%) rename src/commands/Giveaway/{gcreate.js => gcreate.ts} (92%) rename src/commands/Giveaway/{gdelete.js => gdelete.ts} (92%) rename src/commands/Giveaway/{gend.js => gend.ts} (93%) rename src/commands/Giveaway/{greroll.js => greroll.ts} (93%) rename src/commands/JoinToCreate/{jointocreate.js => jointocreate.ts} (85%) rename src/commands/JoinToCreate/modules/{config_setup.js => config_setup.ts} (90%) rename src/commands/JoinToCreate/modules/{setup.js => setup.ts} (84%) rename src/commands/Leveling/{leaderboard.js => leaderboard.ts} (92%) rename src/commands/Leveling/{level.js => level.ts} (97%) rename src/commands/Leveling/{leveladd.js => leveladd.ts} (90%) rename src/commands/Leveling/{levelremove.js => levelremove.ts} (90%) rename src/commands/Leveling/{levelset.js => levelset.ts} (88%) rename src/commands/Leveling/modules/{level_dashboard.js => level_dashboard.ts} (87%) rename src/commands/Leveling/{rank.js => rank.ts} (91%) rename src/commands/Logging/{logging.js => logging.ts} (97%) rename src/commands/Logging/modules/{logging_channel.js => logging_channel.ts} (76%) rename src/commands/Logging/modules/{logging_dashboard.js => logging_dashboard.ts} (77%) rename src/commands/Moderation/{ban.js => ban.ts} (89%) rename src/commands/Moderation/{cases.js => cases.ts} (95%) rename src/commands/Moderation/{dm.js => dm.ts} (92%) rename src/commands/Moderation/{kick.js => kick.ts} (89%) rename src/commands/Moderation/{lock.js => lock.ts} (89%) rename src/commands/Moderation/{massban.js => massban.ts} (87%) rename src/commands/Moderation/{masskick.js => masskick.ts} (87%) rename src/commands/Moderation/{purge.js => purge.ts} (90%) rename src/commands/Moderation/{say.js => say.ts} (86%) rename src/commands/Moderation/{timeout.js => timeout.ts} (91%) rename src/commands/Moderation/{unban.js => unban.ts} (88%) rename src/commands/Moderation/{unlock.js => unlock.ts} (90%) rename src/commands/Moderation/{untimeout.js => untimeout.ts} (87%) rename src/commands/Moderation/{usernotes.js => usernotes.ts} (86%) rename src/commands/Moderation/{warn.js => warn.ts} (92%) rename src/commands/Moderation/{warnings.js => warnings.ts} (93%) rename src/commands/Music/{join.js => join.ts} (91%) rename src/commands/Music/{music.js => music.ts} (93%) rename src/commands/Music/{nowplaying.js => nowplaying.ts} (88%) rename src/commands/Music/{play.js => play.ts} (88%) rename src/commands/Music/{queue.js => queue.ts} (91%) rename src/commands/Reaction_roles/{reactroles.js => reactroles.ts} (85%) rename src/commands/Search/modules/{search_define.js => search_define.ts} (82%) rename src/commands/Search/modules/{search_google.js => search_google.ts} (84%) rename src/commands/Search/modules/{search_urban.js => search_urban.ts} (82%) rename src/commands/Search/{search.js => search.ts} (87%) rename src/commands/ServerStats/modules/{serverstats_create.js => serverstats_create.ts} (88%) rename src/commands/ServerStats/modules/{serverstats_delete.js => serverstats_delete.ts} (87%) rename src/commands/ServerStats/modules/{serverstats_list.js => serverstats_list.ts} (88%) rename src/commands/ServerStats/modules/{serverstats_update.js => serverstats_update.ts} (89%) rename src/commands/ServerStats/{serverstats.js => serverstats.ts} (98%) rename src/commands/Ticket/{claim.js => claim.ts} (83%) rename src/commands/Ticket/{close.js => close.ts} (87%) rename src/commands/Ticket/modules/{ticket_dashboard.js => ticket_dashboard.ts} (84%) rename src/commands/Ticket/{priority.js => priority.ts} (86%) rename src/commands/Ticket/{ticket.js => ticket.ts} (95%) rename src/commands/Tools/{baseconvert.js => baseconvert.ts} (93%) rename src/commands/Tools/{calculate.js => calculate.ts} (88%) rename src/commands/Tools/{countdown.js => countdown.ts} (79%) rename src/commands/Tools/{embedbuilder.js => embedbuilder.ts} (87%) rename src/commands/Tools/{generatepassword.js => generatepassword.ts} (98%) rename src/commands/Tools/{hexcolor.js => hexcolor.ts} (89%) rename src/commands/Tools/{poll.js => poll.ts} (94%) rename src/commands/Tools/{randomuser.js => randomuser.ts} (88%) rename src/commands/Tools/{shorten.js => shorten.ts} (94%) rename src/commands/Tools/{time.js => time.ts} (97%) rename src/commands/Tools/{unixtime.js => unixtime.ts} (95%) rename src/commands/Utility/{avatar.js => avatar.ts} (88%) rename src/commands/Utility/{firstmsg.js => firstmsg.ts} (86%) rename src/commands/Utility/modules/{report.js => report.ts} (86%) rename src/commands/Utility/modules/{report_setchannel.js => report_setchannel.ts} (80%) rename src/commands/Utility/{report.js => report.ts} (96%) rename src/commands/Utility/{serverinfo.js => serverinfo.ts} (94%) rename src/commands/Utility/{todo.js => todo.ts} (91%) rename src/commands/Utility/{userinfo.js => userinfo.ts} (94%) rename src/commands/Utility/{weather.js => weather.ts} (81%) rename src/commands/Utility/{wipedata.js => wipedata.ts} (95%) rename src/commands/Verification/{autoverify.js => autoverify.ts} (100%) rename src/commands/Verification/modules/{autoVerify.js => autoVerify.ts} (89%) rename src/commands/Verification/modules/{autoVerifyDashboard.js => autoVerifyDashboard.ts} (83%) rename src/commands/Verification/modules/{verification_dashboard.js => verification_dashboard.ts} (84%) rename src/commands/Verification/{verification.js => verification.ts} (93%) rename src/commands/Verification/{verify.js => verify.ts} (91%) rename src/commands/Welcome/{autorole.js => autorole.ts} (93%) rename src/commands/Welcome/{goodbye.js => goodbye.ts} (91%) rename src/commands/Welcome/{greet.js => greet.ts} (96%) rename src/commands/Welcome/modules/{greet_dashboard.js => greet_dashboard.ts} (86%) rename src/commands/Welcome/{welcome.js => welcome.ts} (94%) rename src/events/{channelDelete.js => channelDelete.ts} (98%) rename src/events/{guildCreate.js => guildCreate.ts} (87%) rename src/events/{guildMemberAdd.js => guildMemberAdd.ts} (82%) rename src/events/{guildMemberRemove.js => guildMemberRemove.ts} (87%) rename src/events/{guildMemberUpdate.js => guildMemberUpdate.ts} (87%) rename src/events/{interactionCreate.js => interactionCreate.ts} (88%) rename src/events/{messageCreate.js => messageCreate.ts} (91%) rename src/events/{messageDelete.js => messageDelete.ts} (88%) rename src/events/{messageUpdate.js => messageUpdate.ts} (86%) rename src/events/{ready.js => ready.ts} (97%) rename src/events/{roleCreate.js => roleCreate.ts} (88%) rename src/events/{roleDelete.js => roleDelete.ts} (89%) rename src/events/{userUpdate.js => userUpdate.ts} (95%) rename src/events/{voiceStateUpdate.js => voiceStateUpdate.ts} (86%) rename src/handlers/{calculateModals.js => calculateModals.ts} (84%) rename src/handlers/{countdownButtons.js => countdownButtons.ts} (85%) rename src/handlers/{counterButtons.js => counterButtons.ts} (95%) rename src/handlers/{giveawayButtons.js => giveawayButtons.ts} (94%) rename src/handlers/help/{helpButtons.js => helpButtons.ts} (80%) rename src/handlers/help/{helpSelectMenus.js => helpSelectMenus.ts} (80%) rename src/handlers/interactionHandlers/{reactionRolesSelectMenu.js => reactionRolesSelectMenu.ts} (94%) rename src/handlers/loaders/{commandLoader.js => commandLoader.ts} (78%) rename src/handlers/loaders/{events.js => events.ts} (60%) rename src/handlers/loaders/{interactions.js => interactions.ts} (64%) rename src/handlers/{loggingButtons.js => loggingButtons.ts} (80%) rename src/handlers/{musicButtons.js => musicButtons.ts} (85%) rename src/handlers/{ticketButtons.js => ticketButtons.ts} (85%) rename src/handlers/{todoButtons.js => todoButtons.ts} (88%) rename src/handlers/{verificationButtons.js => verificationButtons.ts} (87%) rename src/handlers/{warningHandlers.js => warningHandlers.ts} (90%) rename src/handlers/{wipedataButtons.js => wipedataButtons.ts} (94%) rename src/interactions/buttons/countdown/{countdown.js => countdown.ts} (72%) rename src/interactions/buttons/counter/{counterDelete.js => counterDelete.ts} (100%) rename src/interactions/buttons/giveaway/{giveaway.js => giveaway.ts} (66%) rename src/interactions/buttons/help/{help.js => help.ts} (79%) rename src/interactions/buttons/logging/{logging.js => logging.ts} (84%) rename src/interactions/buttons/music/{music.js => music.ts} (87%) rename src/interactions/buttons/ticket/{ticket.js => ticket.ts} (82%) rename src/interactions/buttons/ticket/{ticketFeedback.js => ticketFeedback.ts} (90%) rename src/interactions/buttons/todo/{todoShared.js => todoShared.ts} (70%) rename src/interactions/buttons/verification/{verification.js => verification.ts} (68%) rename src/interactions/buttons/warnings/{warnings.js => warnings.ts} (67%) delete mode 100644 src/interactions/buttons/wipedata/wipedata.js create mode 100644 src/interactions/buttons/wipedata/wipedata.ts rename src/interactions/modals/calculate/{calculate.js => calculate.ts} (73%) rename src/interactions/modals/config/{config_modal.js => config_modal.ts} (76%) rename src/interactions/modals/ticket/{ticket.js => ticket.ts} (65%) rename src/interactions/modals/ticket/{ticketFeedbackComment.js => ticketFeedbackComment.ts} (88%) rename src/interactions/modals/todo/{todoShared.js => todoShared.ts} (74%) delete mode 100644 src/interactions/modals/warnings/warningDelete.js create mode 100644 src/interactions/modals/warnings/warningDelete.ts rename src/interactions/selectMenus/help/{helpCategory.js => helpCategory.ts} (100%) rename src/interactions/selectMenus/logging/{logging.js => logging.ts} (61%) delete mode 100644 src/interactions/selectMenus/reactionRoles/reaction_roles.js create mode 100644 src/interactions/selectMenus/reactionRoles/reaction_roles.ts rename src/interactions/selectMenus/ticket/{ticketFeedback.js => ticketFeedback.ts} (91%) diff --git a/src/app.js b/src/app.ts similarity index 81% rename from src/app.js rename to src/app.ts index 1f2e5db5c5..4ff9b190d7 100644 --- a/src/app.js +++ b/src/app.ts @@ -1,11 +1,14 @@ -import 'dotenv/config'; +import 'dotenv/config'; import { Client, Collection, GatewayIntentBits } from 'discord.js'; +import type { Request, Response, NextFunction } from 'express'; import { REST } from '@discordjs/rest'; import express from 'express'; import cron from 'node-cron'; import config from './config/application.js'; import { initializeDatabase } from './utils/database.js'; +import { db } from './utils/database/wrapper.js'; +import { validateBotEnv } from './config/env.js'; import { getGuildConfig } from './services/config/guildConfig.js'; import { getServerCounters, saveServerCounters, updateCounter } from './services/serverstatsService.js'; import { logger, startupLog, shutdownLog } from './utils/logger.js'; @@ -13,15 +16,33 @@ import { checkBirthdays } from './services/birthdayService.js'; import { checkGiveaways } from './services/giveawayService.js'; import { loadCommands, registerCommands as registerSlashCommands } from './handlers/loaders/commandLoader.js'; import { runSafeTask, handleTaskError, ErrorCodes } from './utils/errorHandler.js'; +import type { ErrorLike } from './utils/errorHandler.js'; import { initializeMusic } from './services/music/riffySetup.js'; import { shutdownMusic } from './services/music/playerHandler.js'; import { readFileSync } from 'node:fs'; import { EXPECTED_SCHEMA_VERSION, EXPECTED_SCHEMA_LABEL } from './config/database/schemaVersion.js'; +import type { ServerCounter } from './services/serverstatsService.js'; +import type { NexoClient } from './types/index.js'; + +/** Shape read by /health and /ready, including their fallbacks when no status is available. */ +interface DbStatusView { + isDegraded: boolean | 'unknown'; + connectionType?: string; + degradedReason?: string | null; +} + +type HandlerLoader = (client: NexoClient) => unknown; // package.json sits outside rootDir; read it at runtime (works from src/ and dist/). -const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); +const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }; class NexoBot extends Client { + // Handlers/services are typed for a logged-in client (Client); they only touch + // ready-only state after login, so this narrow cast mirrors the JS behaviour. + private get ready(): NexoClient { + return this as NexoClient; + } + constructor() { super({ intents: [ @@ -42,17 +63,18 @@ class NexoBot extends Client { this.config = config; this.commands = new Collection(); - this.events = new Collection(); this.buttons = new Collection(); this.selectMenus = new Collection(); this.modals = new Collection(); this.cooldowns = new Collection(); - this.db = null; - this.rest = new REST({ version: '10' }).setToken(config.bot.token); + this.db = db; + // Token presence is enforced by validateBotEnv() in start(); setToken only stores it. + this.rest = new REST({ version: '10' }).setToken(config.bot.token!); } async start() { try { + validateBotEnv(); startupLog('Starting NexoBot...'); await new Promise(resolve => setTimeout(resolve, 1000)); @@ -80,7 +102,7 @@ class NexoBot extends Client { this.startWebServer(); startupLog('Loading commands...'); - await loadCommands(this); + await loadCommands(this.ready); startupLog(`Commands loaded: ${this.commands.size}`); startupLog('Loading handlers...'); @@ -119,8 +141,8 @@ class NexoBot extends Client { const host = process.env.WEB_HOST || '0.0.0.0'; const corsOrigin = this.config.api?.cors?.origin || '*'; - app.use((req, res, next) => { - const allowedOrigins = Array.isArray(corsOrigin) ? corsOrigin : [corsOrigin]; + app.use((req: Request, res: Response, next: NextFunction) => { + const allowedOrigins: (string | undefined)[] = Array.isArray(corsOrigin) ? corsOrigin : [corsOrigin]; const origin = req.headers.origin; if (allowedOrigins.includes('*') || allowedOrigins.includes(origin)) { @@ -135,11 +157,11 @@ class NexoBot extends Client { next(); }); - const requestCounts = new Map(); + const requestCounts = new Map(); const windowMs = this.config.api?.rateLimit?.windowMs || 60000; const maxRequests = this.config.api?.rateLimit?.max || 100; - app.use((req, res, next) => { + app.use((req: Request, res: Response, next: NextFunction) => { const ip = req.ip; const now = Date.now(); const windowStart = now - windowMs; @@ -148,7 +170,7 @@ class NexoBot extends Client { requestCounts.set(ip, []); } - const times = requestCounts.get(ip).filter(t => t > windowStart); + const times = requestCounts.get(ip)!.filter(t => t > windowStart); if (times.length >= maxRequests) { return res.status(429).json({ error: 'Too many requests' }); @@ -159,8 +181,8 @@ class NexoBot extends Client { next(); }); - app.get('/health', (req, res) => { - const dbStatus = this.db?.getStatus?.() || { isDegraded: 'unknown' }; + app.get('/health', (req: Request, res: Response) => { + const dbStatus: DbStatusView = this.db?.getStatus?.() || { isDegraded: 'unknown' }; const status = { status: 'healthy', timestamp: new Date().toISOString(), @@ -174,8 +196,8 @@ class NexoBot extends Client { res.status(200).json(status); }); - app.get('/ready', (req, res) => { - const dbStatus = this.db?.getStatus?.() || { isDegraded: true, connectionType: 'none' }; + app.get('/ready', (req: Request, res: Response) => { + const dbStatus: DbStatusView = this.db?.getStatus?.() || { isDegraded: true, connectionType: 'none' }; const isReady = this.isReady() && !dbStatus.isDegraded; const metrics = { @@ -205,7 +227,7 @@ class NexoBot extends Client { }); }); - app.get('/', (req, res) => { + app.get('/', (req: Request, res: Response) => { res.status(200).json({ message: 'NexoBot System Online', version: pkg.version, @@ -213,7 +235,7 @@ class NexoBot extends Client { }); }); - const startServer = (port, attempt = 0) => { + const startServer = (port: number, attempt = 0) => { let hasStartedListening = false; const server = app.listen(port, host, () => { hasStartedListening = true; @@ -223,7 +245,7 @@ class NexoBot extends Client { startupLog(`Ready endpoint: http://${host}:${port}/ready`); }); - server.on('error', (error) => { + server.on('error', (error: NodeJS.ErrnoException) => { const errorCode = error?.code || 'UNKNOWN_ERROR'; const errorMessage = error?.message || 'Unknown server error'; @@ -251,8 +273,8 @@ class NexoBot extends Client { } setupCronJobs() { - cron.schedule('0 6 * * *', runSafeTask('birthday_check', () => checkBirthdays(this))); - cron.schedule('* * * * *', runSafeTask('giveaway_check', () => checkGiveaways(this))); + cron.schedule('0 6 * * *', runSafeTask('birthday_check', () => checkBirthdays(this.ready))); + cron.schedule('* * * * *', runSafeTask('giveaway_check', () => checkGiveaways(this.ready))); cron.schedule('*/15 * * * *', runSafeTask('counter_update', () => this.updateAllCounters())); } @@ -264,16 +286,16 @@ class NexoBot extends Client { for (const [guildId, guild] of this.guilds.cache) { try { - const counters = await getServerCounters(this, guildId); - const validCounters = []; - const orphanedCounters = []; + const counters = await getServerCounters(this.ready, guildId); + const validCounters: ServerCounter[] = []; + const orphanedCounters: ServerCounter[] = []; for (const counter of counters) { if (counter && counter.type && counter.channelId && counter.enabled !== false) { const channel = guild.channels.cache.get(counter.channelId); if (channel) { validCounters.push(counter); - await updateCounter(this, guild, counter); + await updateCounter(this.ready, guild, counter); } else { orphanedCounters.push(counter); logger.info(`Removing orphaned counter ${counter.id} (type: ${counter.type}, deleted channel: ${counter.channelId}) from guild ${guildId}`); @@ -284,7 +306,7 @@ class NexoBot extends Client { // Save cleaned counters if any were orphaned // Save cleaned counters if any were orphaned if (orphanedCounters.length > 0) { - await saveServerCounters(this, guildId, validCounters); + await saveServerCounters(this.ready, guildId, validCounters); logger.info(`Cleaned up ${orphanedCounters.length} orphaned counter(s) from guild ${guildId} during scheduled update`); } } catch (error) { @@ -303,23 +325,24 @@ class NexoBot extends Client { for (const handler of handlers) { try { startupLog(`Loading handler: ${handler.path}`); - const module = await import(`./handlers/loaders/${handler.path}.js`); + const module = (await import(`./handlers/loaders/${handler.path}.js`)) as Record; const loaderFn = handler.type.startsWith('named:') ? module[handler.type.split(':')[1]] : module.default; if (typeof loaderFn === 'function') { - await loaderFn(this); + await loaderFn(this.ready); startupLog(`✅ Loaded ${handler.path}`); } else { throw new Error(`Invalid loader export from ${handler.path}`); } } catch (error) { + const err = error as ErrorLike; if (handler.required) { - logger.error(`❌ Failed to load required handler ${handler.path}:`, error.message); + logger.error(`❌ Failed to load required handler ${handler.path}:`, err.message); throw error; - } else if (error.code !== 'MODULE_NOT_FOUND') { - logger.warn(`⚠️ Failed to load optional handler ${handler.path}:`, error.message); + } else if (err.code !== 'MODULE_NOT_FOUND') { + logger.warn(`⚠️ Failed to load optional handler ${handler.path}:`, err.message); } } } @@ -327,7 +350,7 @@ class NexoBot extends Client { async registerCommands() { try { - await registerSlashCommands(this, { clientId: this.config.bot.clientId }); + await registerSlashCommands(this.ready, { clientId: this.config.bot.clientId }); } catch (error) { logger.error('Error registering commands:', error); } @@ -351,7 +374,7 @@ class NexoBot extends Client { if (this.webServer) { logger.info('Closing web server...'); - await new Promise((resolve) => this.webServer.close(resolve)); + await new Promise((resolve) => this.webServer!.close(resolve)); logger.info('✅ Web server closed'); } @@ -365,7 +388,7 @@ class NexoBot extends Client { logger.info('✅ Database connection closed'); } } catch (error) { - logger.warn('Error closing database pool:', error.message); + logger.warn('Error closing database pool:', (error as ErrorLike).message); } } @@ -376,7 +399,7 @@ class NexoBot extends Client { logger.info('✅ Discord client destroyed'); } catch (error) { - logger.warn('Discord client destroy warning (non-critical):', error.message); + logger.warn('Discord client destroy warning (non-critical):', (error as ErrorLike).message); } } @@ -403,13 +426,14 @@ try { bot.shutdown('UNCAUGHT_EXCEPTION'); }); - process.on('unhandledRejection', (reason) => { - const code = reason?.code; + process.on('unhandledRejection', (reason: unknown) => { + const rejection = reason as ErrorLike | null | undefined; + const code = rejection?.code; if (code === 10062 || code === 40060 || code === 50027) { - logger.warn('Recoverable Discord interaction rejection:', reason?.message || reason); + logger.warn('Recoverable Discord interaction rejection:', rejection?.message || reason); return; } - if (reason?.message?.includes('Queue is empty')) { + if (rejection?.message?.includes('Queue is empty')) { return; } diff --git a/src/commands/Birthday/birthday.js b/src/commands/Birthday/birthday.ts similarity index 98% rename from src/commands/Birthday/birthday.js rename to src/commands/Birthday/birthday.ts index c1f8672d00..8fa638cae4 100644 --- a/src/commands/Birthday/birthday.js +++ b/src/commands/Birthday/birthday.ts @@ -10,6 +10,7 @@ import nextBirthdays from './modules/next_birthdays.js'; import birthdaySetchannel from './modules/birthday_setchannel.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import type { Command } from '../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('birthday') @@ -94,4 +95,4 @@ export default { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Unknown subcommand' }); } } -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Birthday/modules/birthday_info.js b/src/commands/Birthday/modules/birthday_info.ts similarity index 92% rename from src/commands/Birthday/modules/birthday_info.js rename to src/commands/Birthday/modules/birthday_info.ts index 07b8c20020..a60f3a23a1 100644 --- a/src/commands/Birthday/modules/birthday_info.js +++ b/src/commands/Birthday/modules/birthday_info.ts @@ -3,13 +3,14 @@ import { getUserBirthday } from '../../../services/birthdayService.js'; import { logger } from '../../../utils/logger.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import type { SubcommandModule } from '../../../types/index.js'; export default { async execute(interaction, config, client) { await InteractionHelper.safeDefer(interaction); const targetUser = interaction.options.getUser("user") || interaction.user; const userId = targetUser.id; - const guildId = interaction.guildId; + const guildId = interaction.guildId!; const birthdayData = await getUserBirthday(client, guildId, userId); @@ -41,4 +42,4 @@ export default { commandName: 'birthday_info' }); } -}; \ No newline at end of file +} satisfies SubcommandModule; \ No newline at end of file diff --git a/src/commands/Birthday/modules/birthday_list.js b/src/commands/Birthday/modules/birthday_list.ts similarity index 89% rename from src/commands/Birthday/modules/birthday_list.js rename to src/commands/Birthday/modules/birthday_list.ts index ce7ee064da..785b24c3c7 100644 --- a/src/commands/Birthday/modules/birthday_list.js +++ b/src/commands/Birthday/modules/birthday_list.ts @@ -4,11 +4,12 @@ import { deleteBirthday } from '../../../utils/database.js'; import { logger } from '../../../utils/logger.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import type { SubcommandModule } from '../../../types/index.js'; export default { async execute(interaction, config, client) { await InteractionHelper.safeDefer(interaction); - const guildId = interaction.guildId; + const guildId = interaction.guildId!; const sortedBirthdays = await getAllBirthdays(client, guildId); @@ -23,7 +24,7 @@ export default { } const userIds = sortedBirthdays.map(b => b.userId); - const fetchedMembers = await interaction.guild.members.fetch({ user: userIds }).catch(() => null); + const fetchedMembers = await interaction.guild!.members.fetch({ user: userIds }).catch(() => null); let birthdayList = ''; let displayIndex = 0; @@ -54,7 +55,7 @@ export default { }); } - birthdayList = `**${displayIndex} birthday${displayIndex !== 1 ? 's' : ''} in ${interaction.guild.name}**\n\n` + birthdayList; + birthdayList = `**${displayIndex} birthday${displayIndex !== 1 ? 's' : ''} in ${interaction.guild!.name}**\n\n` + birthdayList; const embed = new EmbedBuilder() .setColor(0x00FF00) @@ -73,4 +74,4 @@ export default { commandName: 'birthday_list' }); } -}; \ No newline at end of file +} satisfies SubcommandModule; \ No newline at end of file diff --git a/src/commands/Birthday/modules/birthday_remove.js b/src/commands/Birthday/modules/birthday_remove.ts similarity index 89% rename from src/commands/Birthday/modules/birthday_remove.js rename to src/commands/Birthday/modules/birthday_remove.ts index 2494c42768..53f83c0f3d 100644 --- a/src/commands/Birthday/modules/birthday_remove.js +++ b/src/commands/Birthday/modules/birthday_remove.ts @@ -2,12 +2,13 @@ import { EmbedBuilder } from 'discord.js'; import { deleteBirthday } from '../../../services/birthdayService.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import type { SubcommandModule } from '../../../types/index.js'; export default { async execute(interaction, config, client) { await InteractionHelper.safeDefer(interaction); const userId = interaction.user.id; - const guildId = interaction.guildId; + const guildId = interaction.guildId!; const result = await deleteBirthday(client, guildId, userId); @@ -30,4 +31,4 @@ export default { embeds: [embed] }); } -}; \ No newline at end of file +} satisfies SubcommandModule; \ No newline at end of file diff --git a/src/commands/Birthday/modules/birthday_set.js b/src/commands/Birthday/modules/birthday_set.ts similarity index 74% rename from src/commands/Birthday/modules/birthday_set.js rename to src/commands/Birthday/modules/birthday_set.ts index bf968b065d..c47ac0aed1 100644 --- a/src/commands/Birthday/modules/birthday_set.js +++ b/src/commands/Birthday/modules/birthday_set.ts @@ -2,14 +2,15 @@ import { EmbedBuilder } from 'discord.js'; import { setBirthday } from '../../../services/birthdayService.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import type { SubcommandModule } from '../../../types/index.js'; export default { async execute(interaction, config, client) { await InteractionHelper.safeDefer(interaction); - const month = interaction.options.getInteger("month"); - const day = interaction.options.getInteger("day"); + const month = interaction.options.getInteger("month")!; + const day = interaction.options.getInteger("day")!; const userId = interaction.user.id; - const guildId = interaction.guildId; + const guildId = interaction.guildId!; const result = await setBirthday(client, guildId, userId, month, day); @@ -22,4 +23,4 @@ export default { embeds: [embed] }); } -}; \ No newline at end of file +} satisfies SubcommandModule; \ No newline at end of file diff --git a/src/commands/Birthday/modules/birthday_setchannel.js b/src/commands/Birthday/modules/birthday_setchannel.ts similarity index 87% rename from src/commands/Birthday/modules/birthday_setchannel.js rename to src/commands/Birthday/modules/birthday_setchannel.ts index 09083bfa9f..d18f8b4e30 100644 --- a/src/commands/Birthday/modules/birthday_setchannel.js +++ b/src/commands/Birthday/modules/birthday_setchannel.ts @@ -1,11 +1,12 @@ -import { PermissionsBitField, EmbedBuilder, MessageFlags } from 'discord.js'; +import { PermissionsBitField, EmbedBuilder, MessageFlags, type GuildMember } from 'discord.js'; import { getGuildConfig, setGuildConfig } from '../../../services/config/guildConfig.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; import { logger } from '../../../utils/logger.js'; +import type { SubcommandModule } from '../../../types/index.js'; export default { async execute(interaction, config, client) { - if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { + if (!(interaction.member as GuildMember).permissions.has(PermissionsBitField.Flags.ManageGuild)) { const embed = new EmbedBuilder() .setColor(0xFF0000) .setTitle('Permission Denied') @@ -18,7 +19,7 @@ export default { try { const channel = interaction.options.getChannel('channel'); - const guildId = interaction.guildId; + const guildId = interaction.guildId!; const guildConfig = await getGuildConfig(client, guildId); if (channel) { @@ -56,4 +57,4 @@ export default { }); } }, -}; \ No newline at end of file +} satisfies SubcommandModule; \ No newline at end of file diff --git a/src/commands/Birthday/modules/next_birthdays.js b/src/commands/Birthday/modules/next_birthdays.ts similarity index 86% rename from src/commands/Birthday/modules/next_birthdays.js rename to src/commands/Birthday/modules/next_birthdays.ts index 0695347c16..57e5107697 100644 --- a/src/commands/Birthday/modules/next_birthdays.js +++ b/src/commands/Birthday/modules/next_birthdays.ts @@ -4,11 +4,12 @@ import { deleteBirthday } from '../../../utils/database.js'; import { logger } from '../../../utils/logger.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import type { SubcommandModule } from '../../../types/index.js'; export default { async execute(interaction, config, client) { 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() @@ -22,9 +23,9 @@ export default { let displayIndex = 0; for (const birthday of next5) { - const member = await interaction.guild.members.fetch(birthday.userId).catch(() => null); + const member = await interaction.guild!.members.fetch(birthday.userId).catch(() => null); if (!member) { - deleteBirthday(client, interaction.guildId, birthday.userId).catch(() => null); + deleteBirthday(client, interaction.guildId!, birthday.userId).catch(() => null); continue; } displayIndex++; @@ -49,10 +50,10 @@ export default { }); } - let birthdayList = `🎂 **Next 5 Upcoming Birthdays**\n\nHere are the next 5 birthdays in ${interaction.guild.name}:\n\n`; + 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); + const member = await interaction.guild!.members.fetch(birthday.userId).catch(() => null); if (!member) { continue; } @@ -88,4 +89,4 @@ export default { commandName: 'next_birthdays' }); } -}; \ No newline at end of file +} satisfies SubcommandModule; \ No newline at end of file diff --git a/src/commands/Community/app-admin.js b/src/commands/Community/app-admin.ts similarity index 89% rename from src/commands/Community/app-admin.js rename to src/commands/Community/app-admin.ts index 6ffc02b3f8..d653d8264b 100644 --- a/src/commands/Community/app-admin.js +++ b/src/commands/Community/app-admin.ts @@ -1,4 +1,6 @@ import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ComponentType, LabelBuilder, RoleSelectMenuBuilder } from 'discord.js'; +import type { ChatInputCommandInteraction, GuildMember, TextChannel } from 'discord.js'; +import type { Command } from '../../types/index.js'; import { createEmbed, successEmbed } from '../../utils/embeds.js'; import { getColor, getApplicationStatusColor } from '../../config/bot.js'; import { logger } from '../../utils/logger.js'; @@ -16,10 +18,11 @@ import { saveApplicationRoleSettings, deleteApplication } from '../../utils/database.js'; +import type { Application, ApplicationFilters } from '../../utils/database.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import appDashboard from './modules/app_dashboard.js'; -function getApplicationStatusPresentation(statusValue) { +function getApplicationStatusPresentation(statusValue: unknown) { const normalized = typeof statusValue === 'string' ? statusValue.trim().toLowerCase() : 'unknown'; const statusLabel = normalized === 'pending' ? 'In Progress' : @@ -101,7 +104,7 @@ export default { category: "Community", - execute: withErrorHandling(async (interaction) => { + execute: withErrorHandling(async (interaction: ChatInputCommandInteraction) => { if (!interaction.inGuild()) { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This command can only be used in a server.' }); } @@ -115,11 +118,11 @@ export default { logger.info(`App-admin command executed: ${subcommand}`, { userId: interaction.user.id, - guildId: guild.id, + guildId: guild!.id, subcommand }); - await ApplicationService.checkManagerPermission(interaction.client, guild.id, member); + await ApplicationService.checkManagerPermission(interaction.client, guild!.id, member as GuildMember); if (subcommand === "setup") { await handleSetup(interaction); @@ -132,9 +135,9 @@ export default { await appDashboard.execute(interaction, null, interaction.client, selectedAppName); } }, { type: 'command', commandName: 'app-admin' }) -}; +} satisfies Command; -async function handleSetup(interaction) { +async function handleSetup(interaction: ChatInputCommandInteraction) { if (interaction.deferred || interaction.replied) { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This interaction has already been processed. Please try the command again.' }); @@ -211,13 +214,13 @@ async function handleSetup(interaction) { }).catch(() => null); if (!submitted) { - logger.info('App setup modal dismissed or timed out', { guildId: interaction.guild.id, userId: interaction.user.id }); + logger.info('App setup modal dismissed or timed out', { guildId: interaction.guild!.id, userId: interaction.user.id }); return; } const appName = submitted.fields.getTextInputValue('app_name').trim(); const selectedRoles = submitted.fields.getSelectedRoles('role_id'); - const roleId = selectedRoles.first()?.id; + const roleId = selectedRoles!.first()?.id; if (!roleId) { await replyUserError(submitted, { type: ErrorTypes.USER_INPUT, message: 'You must select a role for the application.' }); @@ -230,13 +233,13 @@ async function handleSetup(interaction) { submitted.fields.getTextInputValue('app_question_3').trim(), ].filter(q => q.length > 0); - const role = await interaction.guild.roles.fetch(roleId).catch(() => null); + const role = await interaction.guild!.roles.fetch(roleId).catch(() => null); if (!role) { await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'The selected role could not be found.' }); return; } - const existingRoles = await getApplicationRoles(interaction.client, interaction.guild.id); + 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.` }); return; @@ -248,14 +251,14 @@ async function handleSetup(interaction) { enabled: true, }); - await saveApplicationRoles(interaction.client, interaction.guild.id, existingRoles); + await saveApplicationRoles(interaction.client, interaction.guild!.id, existingRoles); - const settings = await getApplicationSettings(interaction.client, interaction.guild.id); + const settings = await getApplicationSettings(interaction.client, interaction.guild!.id); if (!settings.enabled) { - await ApplicationService.updateSettings(interaction.client, interaction.guild.id, { enabled: true }); + await ApplicationService.updateSettings(interaction.client, interaction.guild!.id, { enabled: true }); } - await saveApplicationRoleSettings(interaction.client, interaction.guild.id, roleId, { questions }); + await saveApplicationRoleSettings(interaction.client, interaction.guild!.id, roleId, { questions }); await submitted.reply({ embeds: [successEmbed( @@ -270,12 +273,12 @@ async function handleSetup(interaction) { }, 500); } -async function handleReview(interaction) { - const appId = interaction.options.getString("id"); +async function handleReview(interaction: ChatInputCommandInteraction) { + const appId = interaction.options.getString("id")!; const application = await getApplication( interaction.client, - interaction.guild.id, + interaction.guild!.id, appId, ); if (!application) { @@ -302,7 +305,7 @@ async function handleReview(interaction) { }); } - const buttonRow = new ActionRowBuilder().addComponents( + const buttonRow = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`app_review_approve_${appId}`) .setLabel('Approve') @@ -319,7 +322,7 @@ async function handleReview(interaction) { flags: ["Ephemeral"], }); - const collector = interaction.channel.createMessageComponentCollector({ + const collector = interaction.channel!.createMessageComponentCollector({ componentType: ComponentType.Button, filter: i => i.user.id === interaction.user.id && @@ -337,7 +340,7 @@ async function handleReview(interaction) { .setTitle(`${isApprove ? 'Approve' : 'Deny'} Application - Reason`); reasonModal.addComponents( - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('review_reason') .setLabel('Reason (optional)') @@ -366,7 +369,7 @@ async function handleReview(interaction) { const updatedApplication = await ApplicationService.reviewApplication( reasonSubmit.client, - interaction.guild.id, + interaction.guild!.id, appId, { action, @@ -389,7 +392,7 @@ async function handleReview(interaction) { await user.send({ embeds: [dmEmbed] }); } catch (error) { logger.warn('Failed to send DM to user for application review', { - error: error.message, + error: (error as Error).message, userId: application.userId, applicationId: appId }); @@ -398,12 +401,12 @@ async function handleReview(interaction) { if (application.logMessageId && application.logChannelId) { try { const statusColor = getApplicationStatusColor(status); - const logChannel = interaction.guild.channels.cache.get( - application.logChannelId, - ); + const logChannel = interaction.guild!.channels.cache.get( + application.logChannelId as string, + ) as TextChannel | undefined; if (logChannel) { const logMessage = await logChannel.messages.fetch( - application.logMessageId, + application.logMessageId as string, ); if (logMessage) { const embed = logMessage.embeds[0]; @@ -425,7 +428,7 @@ async function handleReview(interaction) { } } catch (error) { logger.warn('Failed to update log message for application', { - error: error.message, + error: (error as Error).message, applicationId: appId, logMessageId: application.logMessageId }); @@ -434,13 +437,13 @@ async function handleReview(interaction) { if (isApprove) { try { - const member = await interaction.guild.members.fetch( + const member = await interaction.guild!.members.fetch( application.userId, ); - await member.roles.add(application.roleId); + await member.roles.add(application.roleId!); } catch (error) { logger.error('Failed to assign role to approved applicant', { - error: error.message, + error: (error as Error).message, userId: application.userId, roleId: application.roleId, applicationId: appId @@ -480,12 +483,12 @@ async function handleReview(interaction) { }); } -async function handleList(interaction) { +async function handleList(interaction: ChatInputCommandInteraction) { const status = interaction.options.getString("status"); const user = interaction.options.getUser("user"); const limit = interaction.options.getNumber("limit") || 10; - const filters = {}; + const filters: ApplicationFilters = {}; if (status) { filters.status = status; @@ -495,7 +498,7 @@ async function handleList(interaction) { let applications = await getApplications( interaction.client, - interaction.guild.id, + interaction.guild!.id, filters, ); @@ -503,15 +506,15 @@ async function handleList(interaction) { applications = await Promise.all( applications.map(async (app) => { try { - await interaction.guild.members.fetch(app.userId); + await interaction.guild!.members.fetch(app.userId); return app; } catch { - await deleteApplication(interaction.client, interaction.guild.id, app.id, app.userId); + await deleteApplication(interaction.client, interaction.guild!.id, app.id, app.userId); return null; } }) - ).then(results => results.filter(Boolean)); + ).then(results => results.filter((app): app is Application => Boolean(app))); } if (user) { @@ -519,7 +522,7 @@ async function handleList(interaction) { } if (applications.length === 0) { - const applicationRoles = await getApplicationRoles(interaction.client, interaction.guild.id); + const applicationRoles = await getApplicationRoles(interaction.client, interaction.guild!.id); if (applicationRoles.length > 0) { const embed = createEmbed({ @@ -528,7 +531,7 @@ async function handleList(interaction) { }); applicationRoles.forEach((appRole, index) => { - const role = interaction.guild.roles.cache.get(appRole.roleId); + const role = interaction.guild!.roles.cache.get(appRole.roleId); embed.addFields({ name: `${index + 1}. ${appRole.name}`, value: `**Role:** ${role ?`<@&${appRole.roleId}>`: 'Role not found'}\n**Available for applications:** Yes`, @@ -551,7 +554,7 @@ async function handleList(interaction) { } applications = applications - .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .slice(0, limit); const embed = createEmbed({ title: "Submitted Applications", description: `Showing ${applications.length} applications.`, }); diff --git a/src/commands/Community/apply.js b/src/commands/Community/apply.ts similarity index 86% rename from src/commands/Community/apply.js rename to src/commands/Community/apply.ts index 0e2c2c9249..2529c8d286 100644 --- a/src/commands/Community/apply.js +++ b/src/commands/Community/apply.ts @@ -1,5 +1,7 @@ import { getColor, getDefaultApplicationQuestions } from '../../config/bot.js'; import { SlashCommandBuilder, ActionRowBuilder, ModalBuilder, TextInputBuilder, TextInputStyle } from 'discord.js'; +import type { ChatInputCommandInteraction, ModalSubmitInteraction } from 'discord.js'; +import type { Command } from '../../types/index.js'; import { createEmbed, successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError, withErrorHandling, createError, ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; @@ -17,8 +19,9 @@ import { updateApplication, getApplicationRoleSettings } from '../../utils/database.js'; +import type { ApplicationAnswer, ApplicationSettings } from '../../utils/database.js'; -function getApplicationStatusPresentation(statusValue) { +function getApplicationStatusPresentation(statusValue: unknown) { const normalized = typeof statusValue === 'string' ? statusValue.trim().toLowerCase() : 'unknown'; const statusLabel = normalized === 'pending' ? 'In Progress' : @@ -70,7 +73,7 @@ export default { category: "Community", - execute: withErrorHandling(async (interaction) => { + execute: withErrorHandling(async (interaction: ChatInputCommandInteraction) => { if (!interaction.inGuild()) { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This command can only be used in a server.' }); } @@ -85,13 +88,13 @@ export default { logger.info(`Apply command executed: ${subcommand}`, { userId: interaction.user.id, - guildId: guild.id, + guildId: guild!.id, subcommand }); const settings = await getApplicationSettings( interaction.client, - guild.id, + guild!.id, ); if (!settings.enabled) { @@ -99,7 +102,7 @@ export default { 'Applications are disabled', ErrorTypes.CONFIGURATION, 'Applications are currently disabled in this server.', - { guildId: guild.id } + { guildId: guild!.id } ); } @@ -111,9 +114,9 @@ export default { await handleList(interaction); } }, { type: 'command', commandName: 'apply' }) -}; +} satisfies Command; -export async function handleApplicationModal(interaction) { +export async function handleApplicationModal(interaction: ModalSubmitInteraction) { if (!interaction.isModalSubmit()) return; const customId = interaction.customId; @@ -121,24 +124,24 @@ export async function handleApplicationModal(interaction) { const roleId = customId.split('_')[2]; - const applicationRoles = await getApplicationRoles(interaction.client, interaction.guild.id); + const applicationRoles = await getApplicationRoles(interaction.client, interaction.guild!.id); const applicationRole = applicationRoles.find(appRole => appRole.roleId === roleId); if (!applicationRole) { return await replyUserError(interaction, { type: ErrorTypes.CONFIGURATION, message: 'Application configuration not found.' }); } - const role = interaction.guild.roles.cache.get(roleId); + const role = interaction.guild!.roles.cache.get(roleId); if (!role) { return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Role not found.' }); } - const answers = []; - const settings = await getApplicationSettings(interaction.client, interaction.guild.id); + const answers: ApplicationAnswer[] = []; + const settings = await getApplicationSettings(interaction.client, interaction.guild!.id); let questions = settings.questions?.length ? settings.questions : getDefaultApplicationQuestions(); - const roleSettings = await getApplicationRoleSettings(interaction.client, interaction.guild.id, roleId); + const roleSettings = await getApplicationRoleSettings(interaction.client, interaction.guild!.id, roleId); if (roleSettings.questions && roleSettings.questions.length > 0) { questions = roleSettings.questions; } @@ -153,7 +156,7 @@ export async function handleApplicationModal(interaction) { try { const application = await ApplicationService.submitApplication(interaction.client, { - guildId: interaction.guild.id, + guildId: interaction.guild!.id, userId: interaction.user.id, roleId: roleId, roleName: applicationRole.name, @@ -171,16 +174,16 @@ export async function handleApplicationModal(interaction) { await InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"] }); - const settings = await getApplicationSettings(interaction.client, interaction.guild.id); - const roleSettings = await getApplicationRoleSettings(interaction.client, interaction.guild.id, roleId); - const guildConfig = await getGuildConfig(interaction.client, interaction.guild.id); + const settings = await getApplicationSettings(interaction.client, interaction.guild!.id); + const roleSettings = await getApplicationRoleSettings(interaction.client, interaction.guild!.id, roleId); + const guildConfig = await getGuildConfig(interaction.client, interaction.guild!.id); const logChannelId = resolveApplicationLogChannel(guildConfig, roleSettings, settings); if (logChannelId) { const logMessage = await logEvent({ client: interaction.client, - guildId: interaction.guild.id, + guildId: interaction.guild!.id, eventType: EVENT_TYPES.APPLICATION_SUBMIT, channelId: logChannelId, data: { @@ -199,7 +202,7 @@ export async function handleApplicationModal(interaction) { }); if (logMessage) { - await updateApplication(interaction.client, interaction.guild.id, application.id, { + await updateApplication(interaction.client, interaction.guild!.id, application.id, { logMessageId: logMessage.id, logChannelId, }); @@ -208,11 +211,11 @@ export async function handleApplicationModal(interaction) { } catch (error) { logger.error('Error creating application:', { - error: error.message, + error: (error as Error).message, userId: interaction.user.id, - guildId: interaction.guild.id, + guildId: interaction.guild!.id, roleId, - stack: error.stack + stack: (error as Error).stack }); await handleInteractionError(interaction, error, { @@ -222,9 +225,9 @@ export async function handleApplicationModal(interaction) { } } -async function handleList(interaction) { +async function handleList(interaction: ChatInputCommandInteraction) { try { - const applicationRoles = await getApplicationRoles(interaction.client, interaction.guild.id); + const applicationRoles = await getApplicationRoles(interaction.client, interaction.guild!.id); if (applicationRoles.length === 0) { return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'No applications are currently available.' }); @@ -236,7 +239,7 @@ async function handleList(interaction) { }); applicationRoles.forEach((appRole, index) => { - const role = interaction.guild.roles.cache.get(appRole.roleId); + const role = interaction.guild!.roles.cache.get(appRole.roleId); embed.addFields({ name: `${index + 1}. ${appRole.name}`, value: `**Role:** ${role ?`<@&${appRole.roleId}>`: 'Role not found'}\n` + @@ -252,28 +255,28 @@ async function handleList(interaction) { return InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); } catch (error) { logger.error('Error listing applications:', { - error: error.message, - guildId: interaction.guild.id, - stack: error.stack + error: (error as Error).message, + guildId: interaction.guild!.id, + stack: (error as Error).stack }); throw createError( 'Failed to load applications', ErrorTypes.DATABASE, 'Failed to load applications. Please try again later.', - { guildId: interaction.guild.id } + { guildId: interaction.guild!.id } ); } } -async function handleSubmit(interaction, settings) { +async function handleSubmit(interaction: ChatInputCommandInteraction, settings: ApplicationSettings) { const applicationName = interaction.options.getString("application"); const member = interaction.member; - const applicationRoles = await getApplicationRoles(interaction.client, interaction.guild.id); + const applicationRoles = await getApplicationRoles(interaction.client, interaction.guild!.id); const applicationRole = applicationRoles.find(appRole => - appRole.name.toLowerCase() === applicationName.toLowerCase() + appRole.name.toLowerCase() === applicationName!.toLowerCase() ); if (!applicationRole) { @@ -282,7 +285,7 @@ async function handleSubmit(interaction, settings) { const userApps = await getUserApplications( interaction.client, - interaction.guild.id, + interaction.guild!.id, interaction.user.id, ); const pendingApp = userApps.find((app) => app.status === "pending"); @@ -291,7 +294,7 @@ async function handleSubmit(interaction, settings) { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You already have a pending application. Please wait for it to be reviewed.' }); } - const role = interaction.guild.roles.cache.get(applicationRole.roleId); + const role = interaction.guild!.roles.cache.get(applicationRole.roleId); if (!role) { return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'The role for this application no longer exists.' }); } @@ -301,7 +304,7 @@ async function handleSubmit(interaction, settings) { .setTitle(`Application for ${applicationRole.name}`); let questions = settings.questions?.length ? settings.questions : getDefaultApplicationQuestions(); - const roleSettings = await getApplicationRoleSettings(interaction.client, interaction.guild.id, applicationRole.roleId); + const roleSettings = await getApplicationRoleSettings(interaction.client, interaction.guild!.id, applicationRole.roleId); if (roleSettings.questions && roleSettings.questions.length > 0) { questions = roleSettings.questions; } @@ -318,20 +321,20 @@ async function handleSubmit(interaction, settings) { .setRequired(true) .setMaxLength(1000); - const row = new ActionRowBuilder().addComponents(input); + const row = new ActionRowBuilder().addComponents(input); modal.addComponents(row); }); await interaction.showModal(modal); } -async function handleStatus(interaction) { +async function handleStatus(interaction: ChatInputCommandInteraction) { const appId = interaction.options.getString("id"); if (appId) { const application = await getApplication( interaction.client, - interaction.guild.id, + interaction.guild!.id, appId, ); @@ -356,7 +359,7 @@ async function handleStatus(interaction) { } else { const applications = await getUserApplications( interaction.client, - interaction.guild.id, + interaction.guild!.id, interaction.user.id, ); @@ -365,7 +368,7 @@ async function handleStatus(interaction) { } const recentApplications = applications - .sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0)) + .sort((a, b) => new Date(b.createdAt || 0).getTime() - new Date(a.createdAt || 0).getTime()) .slice(0, 10); const embed = createEmbed({ diff --git a/src/commands/Community/modules/app_dashboard.js b/src/commands/Community/modules/app_dashboard.ts similarity index 86% rename from src/commands/Community/modules/app_dashboard.js rename to src/commands/Community/modules/app_dashboard.ts index dbb7e3e2d0..06781733b7 100644 --- a/src/commands/Community/modules/app_dashboard.js +++ b/src/commands/Community/modules/app_dashboard.ts @@ -18,6 +18,16 @@ import { CheckboxBuilder, TextDisplayBuilder, } from 'discord.js'; +import type { + ChatInputCommandInteraction, + Guild, + ModalSubmitInteraction, + SelectMenuModalData, + StringSelectMenuInteraction, +} from 'discord.js'; +import type { GuildConfig, NexoClient } from '../../../types/index.js'; +import type { ApplicationRole, ApplicationSettings } from '../../../utils/database.js'; +import type { ErrorLike } from '../../../utils/errorHandler.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; import { successEmbed } from '../../../utils/embeds.js'; import { logger } from '../../../utils/logger.js'; @@ -37,13 +47,20 @@ import { import { getGuildConfig } from '../../../services/config/guildConfig.js'; import { setLogChannel, resolveApplicationLogChannel, resolveLogChannel } from '../../../services/loggingService.js'; -async function buildDashboardEmbed(settings, roles, guild, client) { +/** The dashboard is opened from the /app-admin slash command or from the setup modal submit. */ +export type AppDashboardInteraction = ChatInputCommandInteraction | ModalSubmitInteraction; + +/** `managerRoles` is stored in the settings record but not declared on ApplicationSettings. */ +type ManagerRoles = string[] | undefined; + +async function buildDashboardEmbed(settings: ApplicationSettings, roles: ApplicationRole[], guild: Guild, client: NexoClient) { const guildConfig = await getGuildConfig(client, guild.id); const applicationsChannel = resolveLogChannel(guildConfig, 'applications') || settings.logChannelId; const logChannel = applicationsChannel ? `<#${applicationsChannel}>` : '`Not set`'; + const managerRoles = settings.managerRoles as ManagerRoles; const managerRoleList = - settings.managerRoles?.length > 0 - ? settings.managerRoles.map(id => `<@&${id}>`).join(',') + (managerRoles?.length ?? 0) > 0 + ? managerRoles!.map(id => `<@&${id}>`).join(',') : '`None configured`'; const roleList = roles.length > 0 @@ -76,7 +93,7 @@ async function buildDashboardEmbed(settings, roles, guild, client) { .setTimestamp(); } -function buildSelectMenu(guildId) { +function buildSelectMenu(guildId: string) { return new StringSelectMenuBuilder() .setCustomId(`app_cfg_${guildId}`) .setPlaceholder('Select a setting to configure...') @@ -114,9 +131,9 @@ function buildSelectMenu(guildId) { ); } -function buildButtonRow(settings, guildId, disabled = false) { +function buildButtonRow(settings: ApplicationSettings, guildId: string, disabled = false) { const systemOn = settings.enabled === true; - return new ActionRowBuilder().addComponents( + return new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`app_cfg_toggle_${guildId}`) .setLabel('Applications') @@ -125,22 +142,22 @@ function buildButtonRow(settings, guildId, disabled = false) { ); } -async function refreshDashboard(rootInteraction, settings, roles, guildId, client) { +async function refreshDashboard(rootInteraction: AppDashboardInteraction, settings: ApplicationSettings, roles: ApplicationRole[], guildId: string, client: NexoClient) { const selectMenu = buildSelectMenu(guildId); await InteractionHelper.safeEditReply(rootInteraction, { - embeds: [await buildDashboardEmbed(settings, roles, rootInteraction.guild, client)], + embeds: [await buildDashboardEmbed(settings, roles, rootInteraction.guild!, client)], components: [ buildButtonRow(settings, guildId), - new ActionRowBuilder().addComponents(selectMenu), + new ActionRowBuilder().addComponents(selectMenu), ], }).catch(() => {}); } export default { prefixOnly: false, - async execute(interaction, config, client, selectedAppName = null) { + async execute(interaction: AppDashboardInteraction, config: GuildConfig | null, client: NexoClient, selectedAppName: string | null = null) { try { - const guildId = interaction.guild.id; + const guildId = interaction.guild!.id; await InteractionHelper.safeDefer(interaction, { flags: ['Ephemeral'] }); @@ -155,7 +172,7 @@ export default { const isCompletelyUnconfigured = !applicationsChannel && !settings.enabled && - (settings.managerRoles?.length ?? 0) === 0 && + ((settings.managerRoles as ManagerRoles)?.length ?? 0) === 0 && roles.length === 0; if (isCompletelyUnconfigured) { @@ -187,7 +204,7 @@ export default { if (error instanceof NexoBotError) throw error; logger.error('Unexpected error in app_dashboard:', error); throw new NexoBotError( - `Applications dashboard failed: ${error.message}`, + `Applications dashboard failed: ${(error as Error).message}`, ErrorTypes.UNKNOWN, 'Failed to open the applications dashboard.', ); @@ -195,7 +212,7 @@ export default { }, }; -async function showApplicationSelector(interaction, roles, settings, guildId, client) { +async function showApplicationSelector(interaction: AppDashboardInteraction, roles: ApplicationRole[], settings: ApplicationSettings, guildId: string, client: NexoClient) { const selectMenu = new StringSelectMenuBuilder() .setCustomId(`app_select_${guildId}`) .setPlaceholder('Select an application to configure...') @@ -216,10 +233,10 @@ async function showApplicationSelector(interaction, roles, settings, guildId, cl await InteractionHelper.safeEditReply(interaction, { embeds: [embed], - components: [new ActionRowBuilder().addComponents(selectMenu)], + components: [new ActionRowBuilder().addComponents(selectMenu)], }); - const collector = interaction.channel.createMessageComponentCollector({ + const collector = interaction.channel!.createMessageComponentCollector({ componentType: ComponentType.StringSelect, filter: i => i.user.id === interaction.user.id && i.customId === `app_select_${guildId}`, @@ -249,22 +266,22 @@ async function showApplicationSelector(interaction, roles, settings, guildId, cl }); } -async function showGlobalDashboard(interaction, settings, roles, guildId, client) { +async function showGlobalDashboard(interaction: AppDashboardInteraction, settings: ApplicationSettings, roles: ApplicationRole[], guildId: string, client: NexoClient) { const selectMenu = buildSelectMenu(guildId); await InteractionHelper.safeEditReply(interaction, { - embeds: [await buildDashboardEmbed(settings, roles, interaction.guild, client)], + embeds: [await buildDashboardEmbed(settings, roles, interaction.guild!, client)], components: [ buildButtonRow(settings, guildId), - new ActionRowBuilder().addComponents(selectMenu), + new ActionRowBuilder().addComponents(selectMenu), ], }); setupCollectors(interaction, settings, roles, guildId, client, null); } -async function showApplicationDashboard(rootInteraction, selectedRole, settings, roles, guildId, client) { - const roleObj = rootInteraction.guild.roles.cache.get(selectedRole.roleId); +async function showApplicationDashboard(rootInteraction: AppDashboardInteraction, selectedRole: ApplicationRole, settings: ApplicationSettings, roles: ApplicationRole[], guildId: string, client: NexoClient) { + const roleObj = rootInteraction.guild!.roles.cache.get(selectedRole.roleId); const guildConfig = await getGuildConfig(client, guildId); const appSettings = await getApplicationRoleSettings(client, guildId, selectedRole.roleId); @@ -280,8 +297,9 @@ async function showApplicationDashboard(rootInteraction, selectedRole, settings, ? questions.map((q, i) => `${i + 1}. \`${q.length > 60 ? q.substring(0, 60) + '…' : q}\``).join('\n') : '`Inherits global questions`'; - const managerRolesDisplay = settings.managerRoles && settings.managerRoles.length > 0 - ? settings.managerRoles.map(id => `<@&${id}>`).join(',') + const managerRoles = settings.managerRoles as ManagerRoles; + const managerRolesDisplay = managerRoles && managerRoles.length > 0 + ? managerRoles.map(id => `<@&${id}>`).join(',') : '`None configured`'; const embed = new EmbedBuilder() @@ -326,7 +344,7 @@ async function showApplicationDashboard(rootInteraction, selectedRole, settings, const configMenu = buildApplicationSelectMenu(guildId, selectedRole.roleId); - const controlButtons = new ActionRowBuilder().addComponents( + const controlButtons = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`app_toggle_${selectedRole.roleId}`) .setLabel(isEnabled ? 'Disable Application' : 'Enable Application') @@ -338,7 +356,7 @@ async function showApplicationDashboard(rootInteraction, selectedRole, settings, .setEmoji('🗑️'), ); - const menuRow = new ActionRowBuilder().addComponents(configMenu); + const menuRow = new ActionRowBuilder().addComponents(configMenu); await InteractionHelper.safeEditReply(rootInteraction, { embeds: [embed], @@ -348,10 +366,10 @@ async function showApplicationDashboard(rootInteraction, selectedRole, settings, setupCollectors(rootInteraction, settings, roles, guildId, client, selectedRole.roleId); } -function setupCollectors(interaction, settings, roles, guildId, client, selectedRoleId) { +function setupCollectors(interaction: AppDashboardInteraction, settings: ApplicationSettings, roles: ApplicationRole[], guildId: string, client: NexoClient, selectedRoleId: string | null) { const customIdPrefix = selectedRoleId ? `app_cfg_${selectedRoleId}` : `app_cfg_${guildId}`; - const collector = interaction.channel.createMessageComponentCollector({ + const collector = interaction.channel!.createMessageComponentCollector({ componentType: ComponentType.StringSelect, filter: i => i.user.id === interaction.user.id && @@ -373,7 +391,7 @@ function setupCollectors(interaction, settings, roles, guildId, client, selected await handleLogChannel(selectInteraction, interaction, settings, roles, guildId, client, selectedRoleId); break; case 'manager_role': - await handleManagerRole(selectInteraction, interaction, settings, roles, guildId, client, selectedRoleId); + await handleManagerRole(selectInteraction, interaction, settings, roles, guildId, client); break; case 'questions': await handleQuestions(selectInteraction, interaction, settings, roles, guildId, client, selectedRoleId); @@ -385,7 +403,7 @@ function setupCollectors(interaction, settings, roles, guildId, client, selected await handleRoleRemove(selectInteraction, interaction, settings, roles, guildId, client); break; case 'retention': - await handleRetention(selectInteraction, interaction, settings, roles, guildId, client, selectedRoleId); + await handleRetention(selectInteraction, interaction, settings, roles, guildId, client); break; } } catch (error) { @@ -426,7 +444,7 @@ function setupCollectors(interaction, settings, roles, guildId, client, selected }); if (!selectedRoleId) { - const globalToggleCollector = interaction.channel.createMessageComponentCollector({ + const globalToggleCollector = interaction.channel!.createMessageComponentCollector({ componentType: ComponentType.Button, filter: i => i.user.id === interaction.user.id && @@ -485,7 +503,7 @@ function setupCollectors(interaction, settings, roles, guildId, client, selected } if (selectedRoleId) { - const btnCollector = interaction.channel.createMessageComponentCollector({ + const btnCollector = interaction.channel!.createMessageComponentCollector({ componentType: ComponentType.Button, filter: i => i.user.id === interaction.user.id && @@ -576,7 +594,7 @@ function setupCollectors(interaction, settings, roles, guildId, client, selected } }); - const toggleCollector = interaction.channel.createMessageComponentCollector({ + const toggleCollector = interaction.channel!.createMessageComponentCollector({ componentType: ComponentType.Button, filter: i => i.user.id === interaction.user.id && @@ -645,7 +663,7 @@ function setupCollectors(interaction, settings, roles, guildId, client, selected } } -function buildApplicationSelectMenu(guildId, roleId) { +function buildApplicationSelectMenu(guildId: string, roleId: string) { return new StringSelectMenuBuilder() .setCustomId(`app_cfg_${roleId}`) .setPlaceholder('Select a setting to configure...') @@ -673,7 +691,7 @@ function buildApplicationSelectMenu(guildId, roleId) { ); } -async function handleLogChannel(selectInteraction, rootInteraction, settings, roles, guildId, client, selectedRoleId) { +async function handleLogChannel(selectInteraction: StringSelectMenuInteraction, rootInteraction: AppDashboardInteraction, settings: ApplicationSettings, roles: ApplicationRole[], guildId: string, client: NexoClient, selectedRoleId: string | null) { let currentChannel = settings.logChannelId; if (selectedRoleId) { const roleSettings = await getApplicationRoleSettings(client, guildId, selectedRoleId); @@ -707,8 +725,8 @@ async function handleLogChannel(selectInteraction, rootInteraction, settings, ro filter: i => i.user.id === selectInteraction.user.id && i.customId === `app_cfg_log_channel_modal_${guildId}_${selectedRoleId || 'global'}`, }); - const channelId = modalSubmission.fields.getField('log_channel').values[0]; - const channel = selectInteraction.guild.channels.cache.get(channelId); + const channelId = (modalSubmission.fields.getField('log_channel') as SelectMenuModalData).values[0]; + const channel = selectInteraction.guild!.channels.cache.get(channelId); if (selectedRoleId) { const roleSettings = await getApplicationRoleSettings(client, guildId, selectedRoleId); @@ -727,7 +745,7 @@ async function handleLogChannel(selectInteraction, rootInteraction, settings, ro await refreshDashboard(rootInteraction, settings, roles, guildId, client); } catch (error) { - if (error.code === 'INTERACTION_TIMEOUT') return; + if ((error as ErrorLike).code === 'INTERACTION_TIMEOUT') return; logger.error('Error in log channel modal:', error); await replyUserError(selectInteraction, { type: ErrorTypes.UNKNOWN, @@ -736,7 +754,7 @@ async function handleLogChannel(selectInteraction, rootInteraction, settings, ro } } -async function handleManagerRole(selectInteraction, rootInteraction, settings, roles, guildId, client) { +async function handleManagerRole(selectInteraction: StringSelectMenuInteraction, rootInteraction: AppDashboardInteraction, settings: ApplicationSettings, roles: ApplicationRole[], guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId(`app_cfg_manager_role_modal_${guildId}`) .setTitle('Configure Manager Roles'); @@ -763,8 +781,8 @@ async function handleManagerRole(selectInteraction, rootInteraction, settings, r filter: i => i.user.id === selectInteraction.user.id && i.customId === `app_cfg_manager_role_modal_${guildId}`, }); - const selectedRoleIds = modalSubmission.fields.getField('manager_roles').values; - const roleSet = new Set(settings.managerRoles ?? []); + const selectedRoleIds = (modalSubmission.fields.getField('manager_roles') as SelectMenuModalData).values; + const roleSet = new Set((settings.managerRoles as ManagerRoles) ?? []); for (const roleId of selectedRoleIds) { if (roleSet.has(roleId)) { @@ -774,11 +792,12 @@ async function handleManagerRole(selectInteraction, rootInteraction, settings, r } } - settings.managerRoles = Array.from(roleSet); + const managerRoles = Array.from(roleSet); + settings.managerRoles = managerRoles; await saveApplicationSettings(client, guildId, settings); - const finalList = settings.managerRoles.length > 0 - ? settings.managerRoles.map(id => `<@&${id}>`).join(',') + const finalList = managerRoles.length > 0 + ? managerRoles.map(id => `<@&${id}>`).join(',') : '`None`'; await modalSubmission.reply({ @@ -788,7 +807,7 @@ async function handleManagerRole(selectInteraction, rootInteraction, settings, r await refreshDashboard(rootInteraction, settings, roles, guildId, client); } catch (error) { - if (error.code === 'INTERACTION_TIMEOUT') return; + if ((error as ErrorLike).code === 'INTERACTION_TIMEOUT') return; logger.error('Error in manager role modal:', error); await replyUserError(selectInteraction, { type: ErrorTypes.UNKNOWN, @@ -797,7 +816,7 @@ async function handleManagerRole(selectInteraction, rootInteraction, settings, r } } -async function handleQuestions(selectInteraction, rootInteraction, settings, roles, guildId, client, selectedRoleId) { +async function handleQuestions(selectInteraction: StringSelectMenuInteraction, rootInteraction: AppDashboardInteraction, settings: ApplicationSettings, roles: ApplicationRole[], guildId: string, client: NexoClient, selectedRoleId: string | null) { let currentQuestions = settings.questions ?? []; if (selectedRoleId) { @@ -809,7 +828,7 @@ async function handleQuestions(selectInteraction, rootInteraction, settings, rol .setCustomId('app_cfg_questions') .setTitle('Edit Application Questions') .addComponents( - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('q1') .setLabel('Question 1 (required)') @@ -819,7 +838,7 @@ async function handleQuestions(selectInteraction, rootInteraction, settings, rol .setMinLength(1) .setRequired(true), ), - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('q2') .setLabel('Question 2 (optional)') @@ -828,7 +847,7 @@ async function handleQuestions(selectInteraction, rootInteraction, settings, rol .setMaxLength(100) .setRequired(false), ), - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('q3') .setLabel('Question 3 (optional)') @@ -837,7 +856,7 @@ async function handleQuestions(selectInteraction, rootInteraction, settings, rol .setMaxLength(100) .setRequired(false), ), - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('q4') .setLabel('Question 4 (optional)') @@ -846,7 +865,7 @@ async function handleQuestions(selectInteraction, rootInteraction, settings, rol .setMaxLength(100) .setRequired(false), ), - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('q5') .setLabel('Question 5 (optional)') @@ -902,7 +921,7 @@ async function handleQuestions(selectInteraction, rootInteraction, settings, rol await refreshDashboard(rootInteraction, settings, roles, guildId, client); } -async function handleRoleAdd(selectInteraction, rootInteraction, settings, roles, guildId, client) { +async function handleRoleAdd(selectInteraction: StringSelectMenuInteraction, rootInteraction: AppDashboardInteraction, settings: ApplicationSettings, roles: ApplicationRole[], guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId(`app_cfg_role_add_modal_${guildId}`) .setTitle('Add Application Role'); @@ -927,7 +946,7 @@ async function handleRoleAdd(selectInteraction, rootInteraction, settings, roles .setRequired(false); modal.addLabelComponents(roleLabel); - modal.addComponents(new ActionRowBuilder().addComponents(nameInput)); + modal.addComponents(new ActionRowBuilder().addComponents(nameInput)); await selectInteraction.showModal(modal); @@ -937,8 +956,8 @@ async function handleRoleAdd(selectInteraction, rootInteraction, settings, roles filter: i => i.user.id === selectInteraction.user.id && i.customId === `app_cfg_role_add_modal_${guildId}`, }); - const roleId = modalSubmission.fields.getField('application_role').values[0]; - const role = selectInteraction.guild.roles.cache.get(roleId); + const roleId = (modalSubmission.fields.getField('application_role') as SelectMenuModalData).values[0]; + const role = selectInteraction.guild!.roles.cache.get(roleId); const customName = modalSubmission.fields.getTextInputValue('role_name').trim() || role?.name || roleId; if (roles.some(r => r.roleId === roleId)) { @@ -959,7 +978,7 @@ async function handleRoleAdd(selectInteraction, rootInteraction, settings, roles await refreshDashboard(rootInteraction, settings, roles, guildId, client); } catch (error) { - if (error.code === 'INTERACTION_TIMEOUT') return; + if ((error as ErrorLike).code === 'INTERACTION_TIMEOUT') return; logger.error('Error in role add modal:', error); await replyUserError(selectInteraction, { type: ErrorTypes.UNKNOWN, @@ -968,7 +987,7 @@ async function handleRoleAdd(selectInteraction, rootInteraction, settings, roles } } -async function handleRoleRemove(selectInteraction, rootInteraction, settings, roles, guildId, client) { +async function handleRoleRemove(selectInteraction: StringSelectMenuInteraction, rootInteraction: AppDashboardInteraction, settings: ApplicationSettings, roles: ApplicationRole[], guildId: string, client: NexoClient) { if (roles.length === 0) { await replyUserError(selectInteraction, { type: ErrorTypes.USER_INPUT, @@ -1003,7 +1022,7 @@ async function handleRoleRemove(selectInteraction, rootInteraction, settings, ro filter: i => i.user.id === selectInteraction.user.id && i.customId === `app_cfg_role_remove_modal_${guildId}`, }); - const roleId = modalSubmission.fields.getField('remove_role').values[0]; + const roleId = (modalSubmission.fields.getField('remove_role') as SelectMenuModalData).values[0]; const index = roles.findIndex(r => r.roleId === roleId); if (index === -1) { @@ -1021,7 +1040,7 @@ async function handleRoleRemove(selectInteraction, rootInteraction, settings, ro await refreshDashboard(rootInteraction, settings, roles, guildId, client); } catch (error) { - if (error.code === 'INTERACTION_TIMEOUT') return; + if ((error as ErrorLike).code === 'INTERACTION_TIMEOUT') return; logger.error('Error in role remove modal:', error); await replyUserError(selectInteraction, { type: ErrorTypes.UNKNOWN, @@ -1030,7 +1049,7 @@ async function handleRoleRemove(selectInteraction, rootInteraction, settings, ro } } -async function handleRetention(selectInteraction, rootInteraction, settings, roles, guildId, client) { +async function handleRetention(selectInteraction: StringSelectMenuInteraction, rootInteraction: AppDashboardInteraction, settings: ApplicationSettings, roles: ApplicationRole[], guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId('app_cfg_retention') .setTitle('Application Retention Periods'); @@ -1112,7 +1131,7 @@ async function handleRetention(selectInteraction, rootInteraction, settings, rol await refreshDashboard(rootInteraction, settings, roles, guildId, client); } -async function handleDeleteApplication(confirmSubmit, selectedRoleId, guildId, roles, client) { +async function handleDeleteApplication(confirmSubmit: ModalSubmitInteraction, selectedRoleId: string, guildId: string, roles: ApplicationRole[], client: NexoClient) { try { const roleIndex = roles.findIndex(r => r.roleId === selectedRoleId); diff --git a/src/commands/Core/commands.js b/src/commands/Core/commands.ts similarity index 89% rename from src/commands/Core/commands.js rename to src/commands/Core/commands.ts index 579cbf2bd3..1646a7e8d4 100644 --- a/src/commands/Core/commands.js +++ b/src/commands/Core/commands.ts @@ -3,6 +3,8 @@ import { PermissionFlagsBits, MessageFlags, } from 'discord.js'; +import type { APIActionRowComponent, APIComponentInMessageActionRow, ChatInputCommandInteraction } from 'discord.js'; +import type { Command, NexoClient } from '../../types/index.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; @@ -25,7 +27,7 @@ import { const DASHBOARD_TIMEOUT_MS = 10 * 60 * 1000; -function buildCategoryChoices(client) { +function buildCategoryChoices(client: NexoClient) { const registry = buildCommandRegistry(client); return [...registry.values()] .sort((a, b) => a.displayName.localeCompare(b.displayName)) @@ -36,7 +38,7 @@ function buildCategoryChoices(client) { })); } -async function ensureManageGuild(interaction) { +async function ensureManageGuild(interaction: ChatInputCommandInteraction) { if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the **Manage Server** permission to manage commands.' }); return false; @@ -121,7 +123,7 @@ export default { // For command scope, get all commands including subcommands const registry = buildCommandRegistry(interaction.client); - const allCommands = []; + const allCommands: string[] = []; // Check if the query matches a category name - if so, show commands from that category const matchedCategory = resolveCategoryChoice(interaction.client, query); @@ -166,7 +168,7 @@ export default { return; } - const view = await buildDashboardView(client, interaction.guildId, interaction.guild, 'overview'); + const view = await buildDashboardView(client, interaction.guildId!, interaction.guild!, 'overview'); await InteractionHelper.safeEditReply(interaction, { embeds: [view.embed], components: view.components, @@ -190,21 +192,21 @@ export default { await handleDashboardComponent(componentInteraction, client); } catch (error) { logger.error('Command access dashboard interaction failed', { - error: error.message, + error: (error as Error).message, customId: componentInteraction.customId, guildId: interaction.guildId, }); await replyUserError(componentInteraction, { type: ErrorTypes.UNKNOWN, - message: error.message || 'Failed to update command access.', + message: (error as Error).message || 'Failed to update command access.', }).catch(() => {}); } }); collector.on('end', async () => { - const finalView = await buildDashboardView(client, interaction.guildId, interaction.guild, 'overview'); + const finalView = await buildDashboardView(client, interaction.guildId!, interaction.guild!, 'overview'); const disabledComponents = finalView.components.map((row) => { - const newRow = row.toJSON(); + const newRow: APIActionRowComponent = row.toJSON(); newRow.components = newRow.components.map((component) => ({ ...component, disabled: true })); return newRow; }); @@ -231,7 +233,7 @@ export default { } if (isDisable) { - await disableCategory(client, interaction.guildId, category.key); + await disableCategory(client, interaction.guildId!, category.key); return InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( @@ -242,23 +244,23 @@ export default { }); } - await enableCategory(client, interaction.guildId, category.key); + 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).`)], }); } - const commandName = target.toLowerCase(); + const commandName = target!.toLowerCase(); if (isDisable) { - await disableCommand(client, interaction.guildId, commandName); + await disableCommand(client, interaction.guildId!, commandName); return InteractionHelper.safeEditReply(interaction, { embeds: [successEmbed('Command Disabled', `\`/${commandName}\` is now disabled in this server.`)], }); } - await enableCommand(client, interaction.guildId, commandName); + await enableCommand(client, interaction.guildId!, commandName); return InteractionHelper.safeEditReply(interaction, { embeds: [successEmbed('Command Enabled', `\`/${commandName}\` is now enabled in this server.`)], }); }, -}; +} satisfies Command; diff --git a/src/commands/Core/configWizard.js b/src/commands/Core/configWizard.ts similarity index 85% rename from src/commands/Core/configWizard.js rename to src/commands/Core/configWizard.ts index 22ac013ec0..95e5e0234b 100644 --- a/src/commands/Core/configWizard.js +++ b/src/commands/Core/configWizard.ts @@ -15,6 +15,17 @@ import { LabelBuilder, ChannelType, } from 'discord.js'; +import type { + ButtonInteraction, + ChatInputCommandInteraction, + DMChannel, + Guild, + MessageComponentInteraction, + ModalSubmitInteraction, + SelectMenuModalData, + StringSelectMenuInteraction, +} from 'discord.js'; +import type { Command, GuildConfig, NexoClient } from '../../types/index.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { createEmbed, successEmbed, infoEmbed, warningEmbed, buildUserErrorEmbed } from '../../utils/embeds.js'; import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; @@ -23,9 +34,19 @@ import ConfigService from '../../services/config/configService.js'; import { logger } from '../../utils/logger.js'; import { botConfig, getCommandPrefix } from '../../config/bot.js'; +type WizardAnswer = { cancelled: true; answer?: undefined } | { answer: string; cancelled?: undefined }; + +interface WizardPrompt { + key: string; + skipMessage: string; + question: string; + /** undefined = skip, null = clear */ + parse(answer: string): Promise; +} + const DASHBOARD_CUSTOM_ID = 'config_select'; const WIZARD_BUTTON_ID = 'config_wizard'; -const activeWizardSessions = new Set(); +const activeWizardSessions = new Set(); const DM_DISABLED_HELP = [ '1. Right-click this server\'s name (mobile: tap the server name at the top).', @@ -34,7 +55,7 @@ const DM_DISABLED_HELP = [ '4. Click **Start Setup Wizard** again.', ].join('\n'); -async function notifyWizardStarted(buttonInteraction) { +async function notifyWizardStarted(buttonInteraction: ButtonInteraction) { await buttonInteraction.followUp({ embeds: [infoEmbed( 'Setup Wizard Started', @@ -44,14 +65,14 @@ async function notifyWizardStarted(buttonInteraction) { }).catch(() => {}); } -async function notifyWizardDmBlocked(buttonInteraction) { +async function notifyWizardDmBlocked(buttonInteraction: ButtonInteraction) { await replyUserError(buttonInteraction, { type: ErrorTypes.USER_INPUT, message: `I couldn't send you a DM. Enable DMs from this server, then try again.\n\n${DM_DISABLED_HELP}`, }).catch(() => {}); } -function formatChannelMention(guild, channelId) { +function formatChannelMention(guild: Guild, channelId: string | null | undefined) { if (!channelId) { return '`Not set`'; } @@ -59,7 +80,7 @@ function formatChannelMention(guild, channelId) { return channel ? `<#${channelId}>` : `#${channelId}`; } -function formatRoleMention(guild, roleId) { +function formatRoleMention(guild: Guild, roleId: string | null | undefined) { if (!roleId) { return '`Not set`'; } @@ -90,7 +111,7 @@ function getThemeColorLines() { ].join('\n'); } -function buildDashboardEmbed(config, guild) { +function buildDashboardEmbed(config: GuildConfig, guild: Guild) { const setupDone = config.setupWizardCompleted; return createEmbed({ @@ -140,8 +161,8 @@ function buildDashboardEmbed(config, guild) { }); } -function buildSettingsSelect(guildId) { - return new ActionRowBuilder().addComponents( +function buildSettingsSelect(guildId: string) { + return new ActionRowBuilder().addComponents( new StringSelectMenuBuilder() .setCustomId(`${DASHBOARD_CUSTOM_ID}:${guildId}`) .setPlaceholder('⚙️ Select a setting to edit...') @@ -165,8 +186,8 @@ function buildSettingsSelect(guildId) { ); } -function buildButtonRow(config, guildId) { - return new ActionRowBuilder().addComponents( +function buildButtonRow(config: GuildConfig, guildId: string) { + return new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`${WIZARD_BUTTON_ID}:${guildId}`) .setLabel(config.setupWizardCompleted ? 'Re-run Setup Wizard' : 'Start Setup Wizard') @@ -175,7 +196,7 @@ function buildButtonRow(config, guildId) { ); } -function extractId(value) { +function extractId(value: unknown) { if (!value || typeof value !== 'string') return null; const channelMention = value.match(/<#!?(\d{17,19})>/); @@ -190,7 +211,7 @@ function extractId(value) { return null; } -async function askQuestion(dmChannel, userId, prompt, stepNumber, totalSteps) { +async function askQuestion(dmChannel: DMChannel, userId: string, prompt: string, stepNumber: number, totalSteps: number): Promise { await dmChannel.send({ embeds: [createEmbed({ title: `Setup Question ${stepNumber}/${totalSteps}`, @@ -212,7 +233,7 @@ async function askQuestion(dmChannel, userId, prompt, stepNumber, totalSteps) { return null; } - const answer = collected.first().content.trim(); + const answer = collected.first()!.content.trim(); if (answer.toLowerCase() === 'cancel') { await dmChannel.send({ embeds: [infoEmbed('Setup Cancelled', 'Setup wizard stopped. Your saved answers are still applied.')], @@ -223,7 +244,7 @@ async function askQuestion(dmChannel, userId, prompt, stepNumber, totalSteps) { return { answer }; } -function formatSavedAck(key, value, guild) { +function formatSavedAck(key: string, value: string | null, guild: Guild) { if (key === 'prefix') { return `Server prefix saved as \`${value}\`.`; } @@ -247,7 +268,7 @@ function formatSavedAck(key, value, guild) { return 'Setting saved.'; } -async function validateGuildChannelId(guild, channelId) { +async function validateGuildChannelId(guild: Guild, channelId: string) { const channel = guild.channels.cache.get(channelId) ?? await guild.channels.fetch(channelId).catch(() => null); if (!channel || !channel.isTextBased()) { throw new Error('That channel was not found in this server or is not a text channel.'); @@ -255,7 +276,7 @@ async function validateGuildChannelId(guild, channelId) { return channel.id; } -async function validateGuildRoleId(guild, roleId) { +async function validateGuildRoleId(guild: Guild, roleId: string) { const role = guild.roles.cache.get(roleId) ?? await guild.roles.fetch(roleId).catch(() => null); if (!role) { throw new Error('That role was not found in this server.'); @@ -263,13 +284,13 @@ async function validateGuildRoleId(guild, roleId) { return role.id; } -async function refreshDashboard(rootInteraction, config, guild) { +async function refreshDashboard(rootInteraction: ChatInputCommandInteraction, config: GuildConfig, guild: Guild) { const embed = buildDashboardEmbed(config, guild); const components = [buildButtonRow(config, guild.id), buildSettingsSelect(guild.id)]; await InteractionHelper.safeEditReply(rootInteraction, { embeds: [embed], components }).catch(() => {}); } -async function runSetupWizard(buttonInteraction, config, guild, client, rootInteraction) { +async function runSetupWizard(buttonInteraction: ButtonInteraction, config: GuildConfig, guild: Guild, client: NexoClient, rootInteraction: ChatInputCommandInteraction) { const user = buttonInteraction.user; if (activeWizardSessions.has(user.id)) { @@ -282,12 +303,12 @@ async function runSetupWizard(buttonInteraction, config, guild, client, rootInte activeWizardSessions.add(user.id); - let dmChannel; + let dmChannel: DMChannel | undefined; try { dmChannel = await user.createDM(); } catch (error) { - logger.warn('Failed to create DM channel for setup wizard', { userId: user.id, error: error.message }); + logger.warn('Failed to create DM channel for setup wizard', { userId: user.id, error: (error as Error).message }); await notifyWizardDmBlocked(buttonInteraction); return; } finally { @@ -296,7 +317,7 @@ async function runSetupWizard(buttonInteraction, config, guild, client, rootInte } } - const prompts = [ + const prompts: WizardPrompt[] = [ { key: 'prefix', skipMessage: 'Keeping the current server prefix.', @@ -338,8 +359,8 @@ async function runSetupWizard(buttonInteraction, config, guild, client, rootInte }, ]; - const changes = {}; - const errors = []; + const changes: Record = {}; + const errors: string[] = []; let wizardCancelled = false; try { @@ -352,7 +373,7 @@ async function runSetupWizard(buttonInteraction, config, guild, client, rootInte })], }); } catch (error) { - logger.warn('Failed to send setup wizard DM', { userId: user.id, error: error.message }); + logger.warn('Failed to send setup wizard DM', { userId: user.id, error: (error as Error).message }); await notifyWizardDmBlocked(buttonInteraction); return; } @@ -402,15 +423,15 @@ async function runSetupWizard(buttonInteraction, config, guild, client, rootInte const updatedConfig = await getGuildConfig(client, guild.id); await refreshDashboard(rootInteraction, updatedConfig, guild); } catch (refreshError) { - logger.debug('Failed to refresh dashboard during setup wizard', { error: refreshError.message }); + logger.debug('Failed to refresh dashboard during setup wizard', { error: (refreshError as Error).message }); } } answered = true; } catch (error) { - errors.push(`• ${prompt.key}: ${error.message}`); + errors.push(`• ${prompt.key}: ${(error as Error).message}`); await dmChannel.send({ - embeds: [buildUserErrorEmbed(ErrorTypes.VALIDATION, `${error.message}\n\nPlease reply again with a valid answer, \`skip\`, or \`cancel\`.`)], + embeds: [buildUserErrorEmbed(ErrorTypes.VALIDATION, `${(error as Error).message}\n\nPlease reply again with a valid answer, \`skip\`, or \`cancel\`.`)], }); } } @@ -424,7 +445,7 @@ async function runSetupWizard(buttonInteraction, config, guild, client, rootInte try { await setConfigValue(client, guild.id, 'setupWizardCompleted', true); } catch (error) { - logger.warn('Failed to persist setupWizardCompleted flag', { guildId: guild.id, error: error.message }); + logger.warn('Failed to persist setupWizardCompleted flag', { guildId: guild.id, error: (error as Error).message }); } } @@ -457,14 +478,14 @@ async function runSetupWizard(buttonInteraction, config, guild, client, rootInte const updatedConfig = await getGuildConfig(client, guild.id); await refreshDashboard(rootInteraction, updatedConfig, guild); } catch (error) { - logger.debug('Failed to refresh dashboard after wizard completion', { error: error.message }); + logger.debug('Failed to refresh dashboard after wizard completion', { error: (error as Error).message }); } } finally { activeWizardSessions.delete(user.id); } } -async function showSettingModal(selectInteraction, guildId, setting) { +async function showSettingModal(selectInteraction: StringSelectMenuInteraction, guildId: string, setting: string) { const modalCustomId = `config_wizard_modal:${setting}:${guildId}`; if (setting === 'logChannelId') { @@ -524,13 +545,13 @@ async function showSettingModal(selectInteraction, guildId, setting) { .setMinLength(1) .setMaxLength(10); - modal.addComponents(new ActionRowBuilder().addComponents(textInput)); + modal.addComponents(new ActionRowBuilder().addComponents(textInput)); await selectInteraction.showModal(modal); } -function resolveSettingModalValue(setting, submitted) { +function resolveSettingModalValue(setting: string, submitted: ModalSubmitInteraction) { if (setting === 'logChannelId') { - const channelId = submitted.fields.getField('log_channel')?.values?.[0]; + const channelId = (submitted.fields.getField('log_channel') as SelectMenuModalData)?.values?.[0]; if (!channelId) { throw new Error('Please select a log channel.'); } @@ -538,7 +559,7 @@ function resolveSettingModalValue(setting, submitted) { } if (setting === 'modRole') { - const roleId = submitted.fields.getField('mod_role')?.values?.[0]; + const roleId = (submitted.fields.getField('mod_role') as SelectMenuModalData)?.values?.[0]; if (!roleId) { throw new Error('Please select a moderator role.'); } @@ -552,7 +573,7 @@ function resolveSettingModalValue(setting, submitted) { return prefix; } -function buildSettingSuccessMessage(setting, value, guild) { +function buildSettingSuccessMessage(setting: string, value: string, guild: Guild) { if (setting === 'logChannelId') { const channel = guild.channels.cache.get(value); return `Log channel set to ${channel ?? `<#${value}>`}.`; @@ -566,7 +587,7 @@ function buildSettingSuccessMessage(setting, value, guild) { return `Server prefix set to \`${value}\`.`; } -async function handleSettingModalSubmit(selectInteraction, rootInteraction, setting, guildId, client) { +async function handleSettingModalSubmit(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, setting: string, guildId: string, client: NexoClient) { const modalCustomId = `config_wizard_modal:${setting}:${guildId}`; const submitted = await selectInteraction @@ -587,17 +608,17 @@ async function handleSettingModalSubmit(selectInteraction, rootInteraction, sett await ConfigService.updateSetting(client, guildId, setting, value, submitted.user.id); await submitted.reply({ - embeds: [successEmbed('Configuration Updated', buildSettingSuccessMessage(setting, value, submitted.guild))], + embeds: [successEmbed('Configuration Updated', buildSettingSuccessMessage(setting, value, submitted.guild!))], flags: MessageFlags.Ephemeral, }); const updatedConfig = await getGuildConfig(client, guildId); - await refreshDashboard(rootInteraction, updatedConfig, submitted.guild); + await refreshDashboard(rootInteraction, updatedConfig, submitted.guild!); } catch (error) { logger.error('Config wizard modal submit error:', error); await replyUserError(submitted, { type: ErrorTypes.CONFIGURATION, - message: error.message || 'Please try again.', + message: (error as Error).message || 'Please try again.', }).catch(() => {}); } } @@ -625,9 +646,9 @@ export default { }); } - const guildConfig = await getGuildConfig(interaction.client, interaction.guildId); - const embed = buildDashboardEmbed(guildConfig, interaction.guild); - const components = [buildButtonRow(guildConfig, interaction.guildId), buildSettingsSelect(interaction.guildId)]; + const guildConfig = await getGuildConfig(interaction.client, interaction.guildId!); + const embed = buildDashboardEmbed(guildConfig, interaction.guild!); + const components = [buildButtonRow(guildConfig, interaction.guildId!), buildSettingsSelect(interaction.guildId!)]; await InteractionHelper.safeEditReply(interaction, { embeds: [embed], components }); @@ -636,7 +657,7 @@ export default { return; } - const collectorFilter = (componentInteraction) => + const collectorFilter = (componentInteraction: MessageComponentInteraction) => componentInteraction.user.id === interaction.user.id && componentInteraction.customId.includes(`:${interaction.guildId}`); @@ -651,20 +672,20 @@ export default { await componentInteraction.deferUpdate(); if (componentInteraction.customId.startsWith(`${WIZARD_BUTTON_ID}:`)) { - const latestConfig = await getGuildConfig(interaction.client, interaction.guildId); - await runSetupWizard(componentInteraction, latestConfig, interaction.guild, interaction.client, interaction); + const latestConfig = await getGuildConfig(interaction.client, interaction.guildId!); + await runSetupWizard(componentInteraction, latestConfig, interaction.guild!, interaction.client, interaction); } return; } if (componentInteraction.isStringSelectMenu()) { const selected = componentInteraction.values[0]; - await showSettingModal(componentInteraction, interaction.guildId, selected); + await showSettingModal(componentInteraction, interaction.guildId!, selected); await handleSettingModalSubmit( componentInteraction, interaction, selected, - interaction.guildId, + interaction.guildId!, interaction.client, ); } @@ -684,4 +705,4 @@ export default { }); } }, -}; +} satisfies Command; diff --git a/src/commands/Core/help.js b/src/commands/Core/help.ts similarity index 92% rename from src/commands/Core/help.js rename to src/commands/Core/help.ts index e489777412..587b7df7ef 100644 --- a/src/commands/Core/help.js +++ b/src/commands/Core/help.ts @@ -4,6 +4,8 @@ import { ButtonBuilder, ButtonStyle, } from "discord.js"; +import type { Command, NexoClient } from '../../types/index.js'; +import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { createEmbed } from "../../utils/embeds.js"; import { @@ -21,7 +23,7 @@ const ALL_COMMANDS_ID = "help-all-commands"; const BUG_REPORT_BUTTON_ID = "help-bug-report"; const HELP_MENU_TIMEOUT_MS = 5 * 60 * 1000; -const CATEGORY_ICONS = { +const CATEGORY_ICONS: Record = { Core: "ℹ️", Moderation: "🛡️", Economy: "💰", @@ -42,14 +44,14 @@ const CATEGORY_ICONS = { Verification: "✅", }; -function formatCategoryName(rawCategory) { +function formatCategoryName(rawCategory: string) { return rawCategory .replace(/_/g, '') .replace(/([a-z])([A-Z])/g, '$1 $2') .replace(/\b\w/g, (char) => char.toUpperCase()); } -export async function createInitialHelpMenu(client) { +export async function createInitialHelpMenu(client: NexoClient) { const commandsPath = path.join(__dirname, "../../commands"); const categoryDirs = ( await fs.readdir(commandsPath, { withFileTypes: true }) @@ -124,7 +126,7 @@ export async function createInitialHelpMenu(client) { options, ); - const buttonRow = new ActionRowBuilder().addComponents([ + const buttonRow = new ActionRowBuilder().addComponents([ bugReportButton, ]); @@ -169,8 +171,8 @@ export default { components: [], }); } catch (error) { - logger.debug('Help menu close edit failed (interaction may have expired):', error?.message); + logger.debug('Help menu close edit failed (interaction may have expired):', (error as Error | undefined)?.message); } }, HELP_MENU_TIMEOUT_MS); }, -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Core/modules/commands_dashboard.js b/src/commands/Core/modules/commands_dashboard.ts similarity index 79% rename from src/commands/Core/modules/commands_dashboard.js rename to src/commands/Core/modules/commands_dashboard.ts index 88a8865a50..05839ab08e 100644 --- a/src/commands/Core/modules/commands_dashboard.js +++ b/src/commands/Core/modules/commands_dashboard.ts @@ -5,6 +5,8 @@ import { StringSelectMenuBuilder, StringSelectMenuOptionBuilder, } from 'discord.js'; +import type { APIEmbedField, Guild, MessageComponentInteraction, StringSelectMenuInteraction } from 'discord.js'; +import type { NexoClient } from '../../../types/index.js'; import { createEmbed } from '../../../utils/embeds.js'; import { getCommandAccessSnapshot, @@ -15,6 +17,17 @@ import { resetCategoryCommands, } from '../../../services/commandAccessService.js'; import { getGuildConfig } from '../../../services/config/guildConfig.js'; +import type { CommandRegistryEntry } from '../../../services/commandAccessService.js'; + +export type CommandAccessSnapshot = ReturnType; +export type CommandAccessCategory = CommandAccessSnapshot['categories'][number]; +export type DashboardRow = ActionRowBuilder | ActionRowBuilder; + +export interface DashboardView { + embed: ReturnType; + components: DashboardRow[]; + categoryKey?: string; +} export const DASHBOARD_CATEGORY_SELECT = 'cmdaccess_category'; export const DASHBOARD_COMMAND_SELECT = 'cmdaccess_command'; @@ -31,11 +44,11 @@ const STATUS = { disabled: '🔴', }; -function customId(base, guildId, suffix = '') { +function customId(base: string, guildId: string, suffix = '') { return suffix ? `${base}:${guildId}:${suffix}` : `${base}:${guildId}`; } -function getCategoryStatus(category) { +function getCategoryStatus(category: CommandAccessCategory) { if (category.categoryDisabled) { return STATUS.disabled; } @@ -45,15 +58,15 @@ function getCategoryStatus(category) { return STATUS.partial; } -function formatCommandLabel(command) { +function formatCommandLabel(command: CommandRegistryEntry) { if (command.isSubcommand) { return `\`${command.name.replace(/ /g, ' ')}\``; } return `\`${command.name}\``; } -function chunkLines(lines, maxLength = 980) { - const chunks = []; +function chunkLines(lines: string[], maxLength = 980) { + const chunks: string[] = []; let current = ''; for (const line of lines) { @@ -73,7 +86,7 @@ function chunkLines(lines, maxLength = 980) { return chunks; } -export function buildOverviewEmbed(snapshot, guild) { +export function buildOverviewEmbed(snapshot: CommandAccessSnapshot, guild: Guild) { const fullyEnabled = snapshot.categories.filter((c) => !c.categoryDisabled && c.disabledCount === 0).length; const partial = snapshot.categories.filter((c) => !c.categoryDisabled && c.disabledCount > 0).length; const disabled = snapshot.categories.filter((c) => c.categoryDisabled).length; @@ -84,7 +97,7 @@ export function buildOverviewEmbed(snapshot, guild) { return `${icon} ${category.icon} **${category.displayName}** — ${category.enabledCount}/${category.totalCount}${subcommandNote}`; }); - const fields = [ + const fields: APIEmbedField[] = [ { name: '📊 Summary', value: [ @@ -127,7 +140,7 @@ export function buildOverviewEmbed(snapshot, guild) { }); } -export function buildCategoryEmbed(category, guild) { +export function buildCategoryEmbed(category: CommandAccessCategory, guild: Guild) { const statusIcon = getCategoryStatus(category); const statusText = category.categoryDisabled ? 'Category disabled' @@ -142,7 +155,7 @@ export function buildCategoryEmbed(category, guild) { return `${icon} ${formatCommandLabel(command)}${lock}`; }); - const fields = [ + const fields: APIEmbedField[] = [ { name: `${statusIcon} Status`, value: statusText, @@ -182,7 +195,7 @@ export function buildCategoryEmbed(category, guild) { }); } -export function buildOverviewComponents(guildId, snapshot) { +export function buildOverviewComponents(guildId: string, snapshot: CommandAccessSnapshot): DashboardRow[] { const categoryOptions = snapshot.categories.slice(0, 25).map((category) => { const status = getCategoryStatus(category); return new StringSelectMenuOptionBuilder() @@ -193,13 +206,13 @@ export function buildOverviewComponents(guildId, snapshot) { }); return [ - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new StringSelectMenuBuilder() .setCustomId(customId(DASHBOARD_CATEGORY_SELECT, guildId)) .setPlaceholder('📁 Select a category...') .addOptions(categoryOptions), ), - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(customId(DASHBOARD_REFRESH, guildId)) .setLabel('Refresh') @@ -209,7 +222,7 @@ export function buildOverviewComponents(guildId, snapshot) { ]; } -export function buildCategoryComponents(guildId, category) { +export function buildCategoryComponents(guildId: string, category: CommandAccessCategory): DashboardRow[] { const toggleableCommands = category.commands.filter((command) => !command.protected); const commandOptions = toggleableCommands.slice(0, 25).map((command) => { const enabled = category.enabledCommands.includes(command.name); @@ -223,8 +236,8 @@ export function buildCategoryComponents(guildId, category) { .setValue(command.name); }); - const rows = [ - new ActionRowBuilder().addComponents( + const rows: DashboardRow[] = [ + new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(customId(DASHBOARD_HOME, guildId)) .setLabel('Back') @@ -255,7 +268,7 @@ export function buildCategoryComponents(guildId, category) { if (commandOptions.length > 0) { rows.unshift( - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new StringSelectMenuBuilder() .setCustomId(customId(DASHBOARD_COMMAND_SELECT, guildId, category.key)) .setPlaceholder('Toggle a command or subcommand...') @@ -267,7 +280,7 @@ export function buildCategoryComponents(guildId, category) { return rows; } -export async function buildDashboardView(client, guildId, guild, view = 'overview', categoryKey = null) { +export async function buildDashboardView(client: NexoClient, guildId: string, guild: Guild, view = 'overview', categoryKey: string | null = null): Promise { const config = await getGuildConfig(client, guildId); const snapshot = getCommandAccessSnapshot(client, config); @@ -293,7 +306,7 @@ export async function buildDashboardView(client, guildId, guild, view = 'overvie }; } -export async function handleDashboardComponent(interaction, client) { +export async function handleDashboardComponent(interaction: MessageComponentInteraction, client: NexoClient) { const parts = interaction.customId.split(':'); const action = parts[0]; const guildId = parts[1]; @@ -308,7 +321,8 @@ export async function handleDashboardComponent(interaction, client) { if (action === DASHBOARD_COMMAND_SELECT) { const categoryKey = suffix; - const commandName = interaction.values[0]; + // Only the command select menu uses this customId. + const commandName = (interaction as StringSelectMenuInteraction).values[0]; const config = await getGuildConfig(client, guildId); const snapshot = getCommandAccessSnapshot(client, config); const category = snapshot.categories.find((entry) => entry.key === categoryKey); @@ -320,20 +334,21 @@ export async function handleDashboardComponent(interaction, client) { await enableCommand(client, guildId, commandName); } - const view = await buildDashboardView(client, guildId, interaction.guild, 'category', categoryKey); + const view = await buildDashboardView(client, guildId, interaction.guild!, 'category', categoryKey); return interaction.update({ embeds: [view.embed], components: view.components }); } if (action === DASHBOARD_CATEGORY_SELECT) { - const categoryKey = interaction.values[0]; - const view = await buildDashboardView(client, guildId, interaction.guild, 'category', categoryKey); + // Only the category select menu uses this customId. + const categoryKey = (interaction as StringSelectMenuInteraction).values[0]; + const view = await buildDashboardView(client, guildId, interaction.guild!, 'category', categoryKey); return interaction.update({ embeds: [view.embed], components: view.components }); } await interaction.deferUpdate(); if (action === DASHBOARD_REFRESH || action === DASHBOARD_HOME) { - const view = await buildDashboardView(client, guildId, interaction.guild, 'overview'); + const view = await buildDashboardView(client, guildId, interaction.guild!, 'overview'); return interaction.editReply({ embeds: [view.embed], components: view.components }); } @@ -344,44 +359,44 @@ export async function handleDashboardComponent(interaction, client) { const category = snapshot.categories.find((entry) => entry.key === categoryKey); if (category?.categoryDisabled) { - await enableCategory(client, guildId, categoryKey); + await enableCategory(client, guildId, categoryKey!); } else { - await disableCategory(client, guildId, categoryKey); + await disableCategory(client, guildId, categoryKey!); } - const view = await buildDashboardView(client, guildId, interaction.guild, 'category', categoryKey); + const view = await buildDashboardView(client, guildId, interaction.guild!, 'category', categoryKey); return interaction.editReply({ embeds: [view.embed], components: view.components }); } if (action === DASHBOARD_ENABLE_ALL) { - await enableCategory(client, guildId, suffix); - await resetCategoryCommands(client, guildId, suffix); - const view = await buildDashboardView(client, guildId, interaction.guild, 'category', suffix); + await enableCategory(client, guildId, suffix!); + await resetCategoryCommands(client, guildId, suffix!); + const view = await buildDashboardView(client, guildId, interaction.guild!, 'category', suffix); return interaction.editReply({ embeds: [view.embed], components: view.components }); } if (action === DASHBOARD_DISABLE_ALL) { - await disableCategory(client, guildId, suffix); - const view = await buildDashboardView(client, guildId, interaction.guild, 'category', suffix); + await disableCategory(client, guildId, suffix!); + const view = await buildDashboardView(client, guildId, interaction.guild!, 'category', suffix); return interaction.editReply({ embeds: [view.embed], components: view.components }); } if (action === DASHBOARD_RESET_COMMANDS) { - await enableCategory(client, guildId, suffix); - await resetCategoryCommands(client, guildId, suffix); - const view = await buildDashboardView(client, guildId, interaction.guild, 'category', suffix); + await enableCategory(client, guildId, suffix!); + await resetCategoryCommands(client, guildId, suffix!); + const view = await buildDashboardView(client, guildId, interaction.guild!, 'category', suffix); return interaction.editReply({ embeds: [view.embed], components: view.components }); } return interaction.editReply({ content: 'Unknown dashboard action.', embeds: [], components: [] }); } -export function isCommandAccessCustomId(customIdValue) { +export function isCommandAccessCustomId(customIdValue: string) { return customIdValue.startsWith('cmdaccess_'); } -export function createDashboardCollectorFilter(userId, guildId) { - return (componentInteraction) => +export function createDashboardCollectorFilter(userId: string, guildId: string | null) { + return (componentInteraction: MessageComponentInteraction) => componentInteraction.user.id === userId && componentInteraction.customId.includes(`:${guildId}`); } diff --git a/src/commands/Core/ping.js b/src/commands/Core/ping.ts similarity index 84% rename from src/commands/Core/ping.js rename to src/commands/Core/ping.ts index 8341f780d0..8852ad3b54 100644 --- a/src/commands/Core/ping.js +++ b/src/commands/Core/ping.ts @@ -1,4 +1,10 @@ import { SlashCommandBuilder, MessageFlags } from 'discord.js'; +import type { ChatInputCommandInteraction, GuildTextBasedChannel } from 'discord.js'; +import type { Command } from '../../types/index.js'; +import type { InteractionExtras } from '../../utils/interactionHelper.js'; + +/** Prefix invocations arrive as a mock interaction carrying the message timestamp (see utils/messageAdapter). */ +type PingInteraction = ChatInputCommandInteraction & InteractionExtras & { _commandStartTime?: number }; import { createEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; @@ -8,7 +14,7 @@ export default { .setName("ping") .setDescription("Checks the bot's latency and API speed"), - async prefixExecute(interaction) { + async prefixExecute(interaction: PingInteraction) { try { const startTime = Date.now(); const pingingMessage = await interaction.reply({ content: 'Pinging...' }); @@ -25,14 +31,14 @@ export default { } catch (error) { logger.error('Ping prefix command error:', error); if (!interaction.replied && !interaction._replyMessage) { - await interaction.channel.send({ + await (interaction.channel as GuildTextBasedChannel).send({ embeds: [createEmbed({ title: 'System Error', description: 'Could not determine latency at this time.', color: 'error' })], }).catch(() => {}); } } }, - async execute(interaction) { + async execute(interaction: PingInteraction) { logger.info('execute called - checking if slash command or prefix command'); logger.info(`execute - has _commandStartTime: ${!!interaction._commandStartTime}, createdTimestamp: ${interaction.createdTimestamp}`); @@ -78,4 +84,4 @@ export default { } } }, -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Core/stats.js b/src/commands/Core/stats.ts similarity index 95% rename from src/commands/Core/stats.js rename to src/commands/Core/stats.ts index fc6dc3d392..90dbf51089 100644 --- a/src/commands/Core/stats.js +++ b/src/commands/Core/stats.ts @@ -1,4 +1,5 @@ import { SlashCommandBuilder, version, MessageFlags } from 'discord.js'; +import type { Command } from '../../types/index.js'; import { createEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; @@ -40,4 +41,4 @@ export default { }); } }, -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Core/support.js b/src/commands/Core/support.ts similarity index 89% rename from src/commands/Core/support.js rename to src/commands/Core/support.ts index 5983883f39..b4cec17c3d 100644 --- a/src/commands/Core/support.js +++ b/src/commands/Core/support.ts @@ -1,4 +1,5 @@ import { SlashCommandBuilder, ButtonBuilder, ButtonStyle, ActionRowBuilder, MessageFlags } from 'discord.js'; +import type { Command } from '../../types/index.js'; import { createEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; @@ -16,7 +17,7 @@ export default { .setStyle(ButtonStyle.Link) .setURL(SUPPORT_URL); - const actionRow = new ActionRowBuilder().addComponents(supportButton); + const actionRow = new ActionRowBuilder().addComponents(supportButton); await InteractionHelper.safeReply(interaction, { embeds: [ @@ -38,4 +39,4 @@ export default { } } }, -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Core/uptime.js b/src/commands/Core/uptime.ts similarity index 95% rename from src/commands/Core/uptime.js rename to src/commands/Core/uptime.ts index a3fb52487c..98e1de6868 100644 --- a/src/commands/Core/uptime.js +++ b/src/commands/Core/uptime.ts @@ -1,4 +1,5 @@ import { SlashCommandBuilder, MessageFlags } from 'discord.js'; +import type { Command } from '../../types/index.js'; import { createEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; @@ -41,4 +42,4 @@ export default { } } }, -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Economy/balance.js b/src/commands/Economy/balance.ts similarity index 93% rename from src/commands/Economy/balance.js rename to src/commands/Economy/balance.ts index 95528ecd9c..58d5adb00b 100644 --- a/src/commands/Economy/balance.js +++ b/src/commands/Economy/balance.ts @@ -1,3 +1,4 @@ +import type { Command } from '../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { getEconomyData, getMaxBankCapacity } from '../../utils/economy.js'; @@ -22,9 +23,9 @@ export default { const userOption = interaction.options.getUser("user"); const targetUser = userOption || interaction.user; - const guildId = interaction.guildId; + const guildId = interaction.guildId!; - logger.info(`[ECONOMY] Balance check - userOption: ${userOption?.id || 'null'}, targetUser: ${targetUser.id}, guildId: ${guildId}, isPrefix: ${!!interaction._commandStartTime}`); + logger.info(`[ECONOMY] Balance check - userOption: ${userOption?.id || 'null'}, targetUser: ${targetUser.id}, guildId: ${guildId}, isPrefix: ${!!(interaction as typeof interaction & { _commandStartTime?: number })._commandStartTime}`); logger.debug(`[ECONOMY] Balance check for ${targetUser.id}`, { userId: targetUser.id, guildId }); @@ -84,4 +85,4 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'balance' }) -}; \ No newline at end of file +} satisfies Command; diff --git a/src/commands/Economy/beg.js b/src/commands/Economy/beg.ts similarity index 92% rename from src/commands/Economy/beg.js rename to src/commands/Economy/beg.ts index 9b2fc717f9..9a032a0219 100644 --- a/src/commands/Economy/beg.js +++ b/src/commands/Economy/beg.ts @@ -1,6 +1,8 @@ +import type { Command } from '../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; import { successEmbed, warningEmbed } from '../../utils/embeds.js'; import { getEconomyData, setEconomyData } from '../../utils/economy.js'; +import type { EconomyData } from '../../utils/economy.js'; import { botConfig } from '../../config/bot.js'; import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; @@ -20,9 +22,9 @@ export default { if (!deferred) return; const userId = interaction.user.id; - const guildId = interaction.guildId; + const guildId = interaction.guildId!; - let userData = await getEconomyData(client, guildId, userId); + let userData: EconomyData & { lastBeg?: number } = await getEconomyData(client, guildId, userId); if (!userData) { throw createError( @@ -96,4 +98,4 @@ userData.lastBeg = Date.now(); await InteractionHelper.safeEditReply(interaction, { embeds: [replyEmbed] }); }, { command: 'beg' }) -}; \ No newline at end of file +} satisfies Command; diff --git a/src/commands/Economy/buy.js b/src/commands/Economy/buy.ts similarity index 91% rename from src/commands/Economy/buy.js rename to src/commands/Economy/buy.ts index 1a90387315..39726f5529 100644 --- a/src/commands/Economy/buy.js +++ b/src/commands/Economy/buy.ts @@ -1,4 +1,5 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; +import type { Command } from '../../types/index.js'; +import { SlashCommandBuilder, MessageFlags, type GuildMember } 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'; @@ -32,8 +33,8 @@ export default { if (!deferred) return; const userId = interaction.user.id; - const guildId = interaction.guildId; - const itemId = interaction.options.getString("item_id").toLowerCase(); + const guildId = interaction.guildId!; + const itemId = interaction.options.getString("item_id")!.toLowerCase(); const quantity = interaction.options.getInteger("quantity") || 1; const item = SHOP_ITEMS.find(i => i.id === itemId); @@ -81,7 +82,7 @@ export default { { itemId } ); } - if (interaction.member.roles.cache.has(PREMIUM_ROLE_ID)) { + if ((interaction.member as GuildMember).roles.cache.has(PREMIUM_ROLE_ID)) { throw createError( "Role already owned", ErrorTypes.VALIDATION, @@ -104,9 +105,9 @@ export default { let successDescription = `You successfully purchased ${quantity}x **${item.name}** for **$${totalCost.toLocaleString()}**!`; if (item.type === "role" && itemId === "premium_role") { - const member = interaction.member; + const member = interaction.member as GuildMember; - const role = interaction.guild.roles.cache.get(PREMIUM_ROLE_ID); + const role = interaction.guild!.roles.cache.get(PREMIUM_ROLE_ID!); if (!role) { throw createError( @@ -130,11 +131,11 @@ export default { "Role assignment failed", ErrorTypes.DISCORD_API, "Successfully deducted money, but failed to grant the role. Your cash has been refunded.", - { roleId: PREMIUM_ROLE_ID, originalError: roleError.message } + { roleId: PREMIUM_ROLE_ID, originalError: roleError instanceof Error ? roleError.message : String(roleError) } ); } } else if (item.type === "upgrade") { - userData.upgrades[itemId] = true; + userData.upgrades![itemId] = true; successDescription += `\n\n**✨ Your upgrade is now active!**`; } else if (item.type === "consumable" || item.type === "tool") { userData.inventory[itemId] = @@ -157,4 +158,4 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: [MessageFlags.Ephemeral] }); }, { command: 'buy' }) -}; \ No newline at end of file +} satisfies Command; diff --git a/src/commands/Economy/crime.js b/src/commands/Economy/crime.ts similarity index 92% rename from src/commands/Economy/crime.js rename to src/commands/Economy/crime.ts index 101dc5a575..1442a923b1 100644 --- a/src/commands/Economy/crime.js +++ b/src/commands/Economy/crime.ts @@ -1,6 +1,8 @@ +import type { Command } from '../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { getEconomyData, setEconomyData } from '../../utils/economy.js'; +import type { EconomyData } from '../../utils/economy.js'; import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; @@ -38,20 +40,20 @@ export default { await InteractionHelper.safeDefer(interaction); const userId = interaction.user.id; - const guildId = interaction.guildId; + const guildId = interaction.guildId!; const now = Date.now(); - const userData = await getEconomyData(client, guildId, userId); + const userData: EconomyData & { jailedUntil?: number } = await getEconomyData(client, guildId, userId); const lastCrime = userData.cooldowns?.crime || 0; const isJailed = userData.jailedUntil && userData.jailedUntil > now; if (isJailed) { - const timeLeft = Math.ceil((userData.jailedUntil - now) / (1000 * 60)); + const timeLeft = Math.ceil((userData.jailedUntil! - now) / (1000 * 60)); throw createError( "User is in jail", ErrorTypes.RATE_LIMIT, `You're in jail for ${timeLeft} more minutes!`, - { jailTimeRemaining: userData.jailedUntil - now } + { jailTimeRemaining: userData.jailedUntil! - now } ); } @@ -65,7 +67,7 @@ export default { ); } - const crimeType = interaction.options.getString("type").toLowerCase(); + const crimeType = interaction.options.getString("type")!.toLowerCase(); const crime = CRIME_TYPES.find( c => c.name.toLowerCase().replace(/\s+/g, '-') === crimeType ); @@ -116,4 +118,4 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); } }, { command: 'crime' }) -}; \ No newline at end of file +} satisfies Command; diff --git a/src/commands/Economy/daily.js b/src/commands/Economy/daily.ts similarity index 93% rename from src/commands/Economy/daily.js rename to src/commands/Economy/daily.ts index 4045d23ed0..d99d30bbb3 100644 --- a/src/commands/Economy/daily.js +++ b/src/commands/Economy/daily.ts @@ -1,4 +1,5 @@ -import { SlashCommandBuilder } from 'discord.js'; +import type { Command } from '../../types/index.js'; +import { SlashCommandBuilder, type GuildMember } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { getEconomyData, setEconomyData } from '../../utils/economy.js'; import { getGuildConfig } from '../../services/config/guildConfig.js'; @@ -22,7 +23,7 @@ export default { if (!deferred) return; const userId = interaction.user.id; - const guildId = interaction.guildId; + const guildId = interaction.guildId!; const now = Date.now(); logger.debug(`[ECONOMY] Daily claimed started for ${userId}`, { userId, guildId }); @@ -60,7 +61,7 @@ export default { if ( PREMIUM_ROLE_ID && interaction.member && - interaction.member.roles.cache.has(PREMIUM_ROLE_ID) + (interaction.member as GuildMember).roles.cache.has(PREMIUM_ROLE_ID) ) { const bonusAmount = Math.floor( DAILY_AMOUNT * PREMIUM_BONUS_PERCENTAGE, @@ -101,4 +102,4 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'daily' }) -}; \ No newline at end of file +} satisfies Command; diff --git a/src/commands/Economy/deposit.js b/src/commands/Economy/deposit.ts similarity index 97% rename from src/commands/Economy/deposit.js rename to src/commands/Economy/deposit.ts index 56015b5fb4..9b1f1574bf 100644 --- a/src/commands/Economy/deposit.js +++ b/src/commands/Economy/deposit.ts @@ -1,3 +1,4 @@ +import type { Command } from '../../types/index.js'; import { SlashCommandBuilder, MessageFlags } from 'discord.js'; import { successEmbed, buildUserErrorEmbed } from '../../utils/embeds.js'; import { getEconomyData, setEconomyData, getMaxBankCapacity } from '../../utils/economy.js'; @@ -20,8 +21,8 @@ export default { if (!deferred) return; const userId = interaction.user.id; - const guildId = interaction.guildId; - const amountInput = interaction.options.getString("amount"); + const guildId = interaction.guildId!; + const amountInput = interaction.options.getString("amount")!; const userData = await getEconomyData(client, guildId, userId); @@ -135,4 +136,4 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'deposit' }) -}; \ No newline at end of file +} satisfies Command; diff --git a/src/commands/Economy/economy.js b/src/commands/Economy/economy.ts similarity index 93% rename from src/commands/Economy/economy.js rename to src/commands/Economy/economy.ts index d702eb6157..63c32d30fa 100644 --- a/src/commands/Economy/economy.js +++ b/src/commands/Economy/economy.ts @@ -1,3 +1,4 @@ +import type { Command } from '../../types/index.js'; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; @@ -29,4 +30,4 @@ export default { await economyDashboard.execute(interaction, config, client); } } -}; \ No newline at end of file +} satisfies Command; diff --git a/src/commands/Economy/eleaderboard.js b/src/commands/Economy/eleaderboard.ts similarity index 88% rename from src/commands/Economy/eleaderboard.js rename to src/commands/Economy/eleaderboard.ts index e4603797a7..a6ab3238ed 100644 --- a/src/commands/Economy/eleaderboard.js +++ b/src/commands/Economy/eleaderboard.ts @@ -1,9 +1,11 @@ +import type { Command } from '../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; import { createEmbed } from '../../utils/embeds.js'; import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { getEconomyPrefix } from '../../utils/database.js'; +import type { EconomyData } from '../../utils/economy.js'; export default { data: new SlashCommandBuilder() @@ -15,7 +17,7 @@ export default { const deferred = await InteractionHelper.safeDefer(interaction); if (!deferred) return; - const guildId = interaction.guildId; + const guildId = interaction.guildId!; logger.debug(`[ECONOMY] Leaderboard requested`, { guildId }); @@ -35,11 +37,11 @@ export default { ); } - let allUserData = []; + let allUserData: { userId: string; net_worth: number }[] = []; for (const key of allKeys) { const userId = key.replace(prefix, ""); - const userData = await client.db.get(key); + const userData = await client.db.get(key); if (userData) { allUserData.push({ @@ -56,7 +58,7 @@ export default { allUserData.findIndex((u) => u.userId === interaction.user.id) + 1; const rankEmoji = ["🥇", "🥈", "🥉"]; - const leaderboardEntries = []; + const leaderboardEntries: string[] = []; for (let i = 0; i < topUsers.length; i++) { const user = topUsers[i]; @@ -86,4 +88,4 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'eleaderboard' }) -}; \ No newline at end of file +} satisfies Command; diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.ts similarity index 93% rename from src/commands/Economy/fish.js rename to src/commands/Economy/fish.ts index 5288f048bb..2ef3bd930e 100644 --- a/src/commands/Economy/fish.js +++ b/src/commands/Economy/fish.ts @@ -1,6 +1,8 @@ +import type { Command } from '../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { getEconomyData, setEconomyData } from '../../utils/economy.js'; +import type { EconomyData } from '../../utils/economy.js'; import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; @@ -39,10 +41,10 @@ export default { if (!deferred) return; const userId = interaction.user.id; - const guildId = interaction.guildId; + const guildId = interaction.guildId!; const now = Date.now(); - const userData = await getEconomyData(client, guildId, userId); + const userData: EconomyData & { lastFish?: number } = await getEconomyData(client, guildId, userId); const lastFish = userData.lastFish || 0; const hasFishingRod = userData.inventory["fishing_rod"] || 0; @@ -75,10 +77,10 @@ export default { fishCaught = FISH_TYPES.filter(f => f.rarity === 'rare')[Math.floor(Math.random() * 2)]; } else if (rand < 0.98) { - fishCaught = FISH_TYPES.find(f => f.rarity === 'epic'); + fishCaught = FISH_TYPES.find(f => f.rarity === 'epic')!; } else { - fishCaught = FISH_TYPES.find(f => f.rarity === 'legendary'); + fishCaught = FISH_TYPES.find(f => f.rarity === 'legendary')!; } const baseEarned = Math.floor( @@ -100,7 +102,7 @@ export default { await setEconomyData(client, guildId, userId, userData); - const rarityColors = { + const rarityColors: Record = { common: '#95A5A6', uncommon: '#2ECC71', rare: '#3498DB', @@ -129,4 +131,4 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'fish' }) -}; \ No newline at end of file +} satisfies Command; diff --git a/src/commands/Economy/gamble.js b/src/commands/Economy/gamble.ts similarity index 94% rename from src/commands/Economy/gamble.js rename to src/commands/Economy/gamble.ts index b4acf1745d..fcd48e29a7 100644 --- a/src/commands/Economy/gamble.js +++ b/src/commands/Economy/gamble.ts @@ -1,6 +1,8 @@ +import type { Command } from '../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { getEconomyData, setEconomyData } from '../../utils/economy.js'; +import type { EconomyData } from '../../utils/economy.js'; import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; @@ -27,11 +29,11 @@ export default { if (!deferred) return; const userId = interaction.user.id; - const guildId = interaction.guildId; - const betAmount = interaction.options.getInteger("amount"); + const guildId = interaction.guildId!; + const betAmount = interaction.options.getInteger("amount")!; const now = Date.now(); - const userData = await getEconomyData(client, guildId, userId); + const userData: EconomyData & { lastGamble?: number } = await getEconomyData(client, guildId, userId); const lastGamble = userData.lastGamble || 0; let cloverCount = userData.inventory["lucky_clover"] || 0; let charmCount = userData.inventory["lucky_charm"] || 0; @@ -128,4 +130,4 @@ userData.lastGamble = now; await InteractionHelper.safeEditReply(interaction, { embeds: [resultEmbed] }); }, { command: 'gamble' }) -}; \ No newline at end of file +} satisfies Command; diff --git a/src/commands/Economy/inventory.js b/src/commands/Economy/inventory.ts similarity index 93% rename from src/commands/Economy/inventory.js rename to src/commands/Economy/inventory.ts index b7efcfec80..5dc2e50bf5 100644 --- a/src/commands/Economy/inventory.js +++ b/src/commands/Economy/inventory.ts @@ -1,3 +1,4 @@ +import type { Command } from '../../types/index.js'; import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { shopItems } from '../../config/shop/items.js'; @@ -18,7 +19,7 @@ export default { if (!deferred) return; const userId = interaction.user.id; - const guildId = interaction.guildId; + const guildId = interaction.guildId!; logger.debug(`[ECONOMY] Inventory requested for ${userId}`, { userId, guildId }); @@ -48,7 +49,7 @@ export default { .map( ([itemId, quantity]) => { const item = SHOP_ITEMS.find(i => i.id === itemId); - return `**${item.name}:** ${quantity}x`; + return `**${item!.name}:** ${quantity}x`; } ) .join("\n"); @@ -67,4 +68,4 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'inventory' }) -}; \ No newline at end of file +} satisfies Command; diff --git a/src/commands/Economy/mine.js b/src/commands/Economy/mine.ts similarity index 92% rename from src/commands/Economy/mine.js rename to src/commands/Economy/mine.ts index fda8ba1dda..415e4a9ce4 100644 --- a/src/commands/Economy/mine.js +++ b/src/commands/Economy/mine.ts @@ -1,6 +1,8 @@ +import type { Command } from '../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { getEconomyData, setEconomyData } from '../../utils/economy.js'; +import type { EconomyData } from '../../utils/economy.js'; import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; @@ -28,10 +30,10 @@ export default { if (!deferred) return; const userId = interaction.user.id; - const guildId = interaction.guildId; + const guildId = interaction.guildId!; const now = Date.now(); - const userData = await getEconomyData(client, guildId, userId); + const userData: EconomyData & { lastMine?: number } = await getEconomyData(client, guildId, userId); const lastMine = userData.lastMine || 0; const hasDiamondPickaxe = userData.inventory["diamond_pickaxe"] || 0; const hasPickaxe = userData.inventory["pickaxe"] || 0; @@ -90,4 +92,4 @@ userData.lastMine = now; await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'mine' }) -}; \ No newline at end of file +} satisfies Command; diff --git a/src/commands/Economy/modules/economy_dashboard.js b/src/commands/Economy/modules/economy_dashboard.ts similarity index 85% rename from src/commands/Economy/modules/economy_dashboard.js rename to src/commands/Economy/modules/economy_dashboard.ts index db2514b827..a5cc96d92d 100644 --- a/src/commands/Economy/modules/economy_dashboard.js +++ b/src/commands/Economy/modules/economy_dashboard.ts @@ -12,6 +12,10 @@ import { MessageFlags, ComponentType, EmbedBuilder, + type ChatInputCommandInteraction, + type Guild, + type StringSelectMenuInteraction, + type SelectMenuModalData, } from 'discord.js'; import { getColor, BotConfig } from '../../../config/bot.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; @@ -20,6 +24,8 @@ import { logger } from '../../../utils/logger.js'; import { NexoBotError, ErrorTypes, replyUserError } from '../../../utils/errorHandler.js'; import { getEconomyPrefix } from '../../../utils/database.js'; import { getEconomyData, addMoney, removeMoney, getMaxBankCapacity } from '../../../utils/economy.js'; +import type { EconomyData } from '../../../utils/economy.js'; +import type { Command, NexoClient, SubcommandModule } from '../../../types/index.js'; import fs from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; @@ -27,7 +33,7 @@ import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -async function buildDashboardEmbed(guild, client) { +async function buildDashboardEmbed(guild: Guild, client: NexoClient) { const currencySymbol = BotConfig.economy.currency.symbol; const currencyName = BotConfig.economy.currency.name; @@ -41,10 +47,10 @@ async function buildDashboardEmbed(guild, client) { for (const key of economyKeys) { const userId = key.split(':').pop(); - const member = await guild.members.fetch(userId).catch(() => null); + const member = await guild.members.fetch(userId!).catch(() => null); if (member?.user?.bot) continue; - const userData = await client.db.get(key, {}); + const userData = await client.db.get>(key, {}); if (userData) { totalInCirculation += (userData.wallet || 0) + (userData.bank || 0); userCount++; @@ -72,7 +78,7 @@ async function buildDashboardEmbed(guild, client) { .setTimestamp(); } -function buildSelectMenu(guildId) { +function buildSelectMenu(guildId: string) { return new StringSelectMenuBuilder() .setCustomId(`economy_dashboard_${guildId}`) .setPlaceholder('Select an action...') @@ -100,19 +106,21 @@ function buildSelectMenu(guildId) { ); } -async function refreshDashboard(rootInteraction, guild, client) { +async function refreshDashboard(rootInteraction: ChatInputCommandInteraction, guild: Guild, client: NexoClient) { const selectMenu = buildSelectMenu(guild.id); await InteractionHelper.safeEditReply(rootInteraction, { embeds: [await buildDashboardEmbed(guild, client)], components: [ - new ActionRowBuilder().addComponents(selectMenu), + new ActionRowBuilder().addComponents(selectMenu), ], }).catch(() => {}); } -async function updateConfigFile(currencySymbol, currencyName) { +async function updateConfigFile(currencySymbol: string, currencyName: string) { try { - const configPath = path.join(__dirname, '../../../config/bot.js'); + // tsx dev runs from src/ (config/bot.ts); the compiled build runs from dist/ (config/bot.js). + const tsConfigPath = path.join(__dirname, '../../../config/bot.ts'); + const configPath = await fs.access(tsConfigPath).then(() => tsConfigPath, () => path.join(__dirname, '../../../config/bot.js')); let configContent = await fs.readFile(configPath, 'utf-8'); configContent = configContent.replace( @@ -121,13 +129,13 @@ async function updateConfigFile(currencySymbol, currencyName) { ); configContent = configContent.replace( - /name:\s*"[^"]*",\s*\/\/\s*Currency display name/, - `name: "${currencyName}", // Currency display name` + /(\/\/\s*Currency display name\.?\s*\n\s*)name:\s*"[^"]*"/, + (_match: string, prefix: string) => `${prefix}name: "${currencyName}"` ); configContent = configContent.replace( - /namePlural:\s*"[^"]*",\s*\/\/\s*Plural display name/, - `namePlural: "${currencyName}s", // Plural display name` + /(\/\/\s*Plural display name\.?\s*\n\s*)namePlural:\s*"[^"]*"/, + (_match: string, prefix: string) => `${prefix}namePlural: "${currencyName}s"` ); await fs.writeFile(configPath, configContent, 'utf-8'); @@ -143,16 +151,16 @@ export default { prefixOnly: false, async execute(interaction, config, client) { try { - const guild = interaction.guild; + const guild = interaction.guild!; const selectMenu = buildSelectMenu(guild.id); - const selectRow = new ActionRowBuilder().addComponents(selectMenu); + const selectRow = new ActionRowBuilder().addComponents(selectMenu); await InteractionHelper.safeEditReply(interaction, { embeds: [await buildDashboardEmbed(guild, client)], components: [selectRow], }); - const collector = interaction.channel.createMessageComponentCollector({ + const collector = interaction.channel!.createMessageComponentCollector({ componentType: ComponentType.StringSelect, filter: i => i.user.id === interaction.user.id && i.customId === `economy_dashboard_${guild.id}`, @@ -216,15 +224,15 @@ export default { if (error instanceof NexoBotError) throw error; logger.error('Unexpected error in economy_dashboard:', error); throw new NexoBotError( - `Economy dashboard failed: ${error.message}`, + `Economy dashboard failed: ${error instanceof Error ? error.message : String(error)}`, ErrorTypes.UNKNOWN, 'Failed to open the economy dashboard.', ); } }, -}; +} satisfies SubcommandModule & Pick; -async function handleAddCurrency(selectInteraction, rootInteraction, guild, client) { +async function handleAddCurrency(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, guild: Guild, client: NexoClient) { const modal = new ModalBuilder() .setCustomId(`economy_add_currency_${guild.id}`) .setTitle('Add Currency'); @@ -261,8 +269,8 @@ async function handleAddCurrency(selectInteraction, rootInteraction, guild, clie modal.addLabelComponents(userLabel); modal.addComponents( - new ActionRowBuilder().addComponents(amountInput), - new ActionRowBuilder().addComponents(typeInput), + new ActionRowBuilder().addComponents(amountInput), + new ActionRowBuilder().addComponents(typeInput), ); await selectInteraction.showModal(modal); @@ -276,7 +284,7 @@ async function handleAddCurrency(selectInteraction, rootInteraction, guild, clie if (!submitted) return; - const userId = submitted.fields.getField('target_user').values[0]; + const userId = (submitted.fields.getField('target_user') as SelectMenuModalData).values[0]; const amount = parseInt(submitted.fields.getTextInputValue('amount').trim(), 10); const type = submitted.fields.getTextInputValue('type').trim().toLowerCase(); @@ -321,7 +329,7 @@ async function handleAddCurrency(selectInteraction, rootInteraction, guild, clie await refreshDashboard(rootInteraction, guild, client); } -async function handleRemoveCurrency(selectInteraction, rootInteraction, guild, client) { +async function handleRemoveCurrency(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, guild: Guild, client: NexoClient) { const modal = new ModalBuilder() .setCustomId(`economy_remove_currency_${guild.id}`) .setTitle('Remove Currency'); @@ -358,8 +366,8 @@ async function handleRemoveCurrency(selectInteraction, rootInteraction, guild, c modal.addLabelComponents(userLabel); modal.addComponents( - new ActionRowBuilder().addComponents(amountInput), - new ActionRowBuilder().addComponents(typeInput), + new ActionRowBuilder().addComponents(amountInput), + new ActionRowBuilder().addComponents(typeInput), ); await selectInteraction.showModal(modal); @@ -373,7 +381,7 @@ async function handleRemoveCurrency(selectInteraction, rootInteraction, guild, c if (!submitted) return; - const userId = submitted.fields.getField('target_user').values[0]; + const userId = (submitted.fields.getField('target_user') as SelectMenuModalData).values[0]; const amount = parseInt(submitted.fields.getTextInputValue('amount').trim(), 10); const type = submitted.fields.getTextInputValue('type').trim().toLowerCase(); @@ -418,7 +426,7 @@ async function handleRemoveCurrency(selectInteraction, rootInteraction, guild, c await refreshDashboard(rootInteraction, guild, client); } -async function handleChangeCurrency(selectInteraction, rootInteraction, guild) { +async function handleChangeCurrency(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, guild: Guild) { const modal = new ModalBuilder() .setCustomId(`economy_change_currency_${guild.id}`) .setTitle('Change Currency Symbol'); @@ -433,7 +441,7 @@ async function handleChangeCurrency(selectInteraction, rootInteraction, guild) { .setMaxLength(3) .setRequired(true); - modal.addComponents(new ActionRowBuilder().addComponents(symbolInput)); + modal.addComponents(new ActionRowBuilder().addComponents(symbolInput)); await selectInteraction.showModal(modal); @@ -472,7 +480,7 @@ async function handleChangeCurrency(selectInteraction, rootInteraction, guild) { }); } -async function handleChangeName(selectInteraction, rootInteraction, guild) { +async function handleChangeName(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, guild: Guild) { const modal = new ModalBuilder() .setCustomId(`economy_change_name_${guild.id}`) .setTitle('Change Currency Name'); @@ -487,7 +495,7 @@ async function handleChangeName(selectInteraction, rootInteraction, guild) { .setMaxLength(20) .setRequired(true); - modal.addComponents(new ActionRowBuilder().addComponents(nameInput)); + modal.addComponents(new ActionRowBuilder().addComponents(nameInput)); await selectInteraction.showModal(modal); diff --git a/src/commands/Economy/modules/shop_browse.js b/src/commands/Economy/modules/shop_browse.ts similarity index 88% rename from src/commands/Economy/modules/shop_browse.js rename to src/commands/Economy/modules/shop_browse.ts index b7adcf40dd..7108ba7c31 100644 --- a/src/commands/Economy/modules/shop_browse.js +++ b/src/commands/Economy/modules/shop_browse.ts @@ -1,4 +1,5 @@ -import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, EmbedBuilder, MessageFlags } from 'discord.js'; +import type { SubcommandModule } from '../../../types/index.js'; +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, EmbedBuilder, MessageFlags, type InteractionReplyOptions } from 'discord.js'; import { shopItems } from '../../../config/shop/items.js'; import { getColor } from '../../../config/bot.js'; import { logger } from '../../../utils/logger.js'; @@ -12,7 +13,7 @@ export default { const totalPages = Math.ceil(shopItems.length / ITEMS_PER_PAGE); let currentPage = 1; - const createShopEmbed = (page) => { + const createShopEmbed = (page: number) => { const startIndex = (page - 1) * ITEMS_PER_PAGE; const pageItems = shopItems.slice(startIndex, startIndex + ITEMS_PER_PAGE); const embed = new EmbedBuilder() @@ -30,10 +31,10 @@ export default { return embed; }; - const createShopComponents = (page) => { + const createShopComponents = (page: number) => { if (totalPages <= 1) return []; return [ - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('shop_prev') .setLabel('⬅️ Previous') @@ -51,7 +52,7 @@ export default { const message = await interaction.reply({ embeds: [createShopEmbed(currentPage)], components: createShopComponents(currentPage), - flags: 0, + flags: 0 as InteractionReplyOptions['flags'], }); const collector = message.createMessageComponentCollector({ @@ -83,7 +84,7 @@ export default { await message.edit({ components: disabledComponents }); } catch (error) { logger.debug('shop_browse: could not disable components on collector end', { - error: error.message, + error: error instanceof Error ? error.message : String(error), }); } }); @@ -91,4 +92,4 @@ export default { await handleInteractionError(interaction, error, { command: 'shop_browse' }); } }, -}; \ No newline at end of file +} satisfies SubcommandModule; diff --git a/src/commands/Economy/modules/shop_config_setrole.js b/src/commands/Economy/modules/shop_config_setrole.ts similarity index 78% rename from src/commands/Economy/modules/shop_config_setrole.js rename to src/commands/Economy/modules/shop_config_setrole.ts index e917e9e69a..65ba854c37 100644 --- a/src/commands/Economy/modules/shop_config_setrole.js +++ b/src/commands/Economy/modules/shop_config_setrole.ts @@ -1,4 +1,5 @@ -import { PermissionsBitField } from 'discord.js'; +import type { SubcommandModule } from '../../../types/index.js'; +import { PermissionsBitField, type GuildMember } from 'discord.js'; import { successEmbed } from '../../../utils/embeds.js'; import { getGuildConfig, setGuildConfig } from '../../../services/config/guildConfig.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; @@ -7,12 +8,12 @@ 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)) { + if (!(interaction.member as GuildMember).permissions.has(PermissionsBitField.Flags.ManageGuild)) { return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need **Manage Server** permissions to set the premium role.' }); } - const role = interaction.options.getRole('role'); - const guildId = interaction.guildId; + const role = interaction.options.getRole('role')!; + const guildId = interaction.guildId!; try { const currentConfig = await getGuildConfig(client, guildId); @@ -28,4 +29,4 @@ export default { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Could not save the guild configuration.' }); } }, -}; \ No newline at end of file +} satisfies SubcommandModule; diff --git a/src/commands/Economy/pay.js b/src/commands/Economy/pay.ts similarity index 96% rename from src/commands/Economy/pay.js rename to src/commands/Economy/pay.ts index 5e8e904862..97053e94f6 100644 --- a/src/commands/Economy/pay.js +++ b/src/commands/Economy/pay.ts @@ -1,3 +1,4 @@ +import type { Command } from '../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { getEconomyData, addMoney, removeMoney, setEconomyData } from '../../utils/economy.js'; @@ -29,9 +30,9 @@ export default { if (!deferred) return; const senderId = interaction.user.id; - const receiver = interaction.options.getUser("user"); - const amount = interaction.options.getInteger("amount"); - const guildId = interaction.guildId; + const receiver = interaction.options.getUser("user")!; + const amount = interaction.options.getInteger("amount")!; + const guildId = interaction.guildId!; logger.debug(`[ECONOMY] Pay command initiated`, { senderId, @@ -143,7 +144,7 @@ export default { }); await receiver.send({ embeds: [receiverEmbed] }); } catch (e) { - logger.warn(`Could not DM user ${receiver.id}: ${e.message}`); + logger.warn(`Could not DM user ${receiver.id}: ${e instanceof Error ? e.message : String(e)}`); } }, { command: 'pay' }) -}; \ No newline at end of file +} satisfies Command; diff --git a/src/commands/Economy/rob.js b/src/commands/Economy/rob.ts similarity index 98% rename from src/commands/Economy/rob.js rename to src/commands/Economy/rob.ts index 68f74fbfee..d959452586 100644 --- a/src/commands/Economy/rob.js +++ b/src/commands/Economy/rob.ts @@ -1,3 +1,4 @@ +import type { Command } from '../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; import { successEmbed, warningEmbed, buildUserErrorEmbed } from '../../utils/embeds.js'; import { getEconomyData, setEconomyData } from '../../utils/economy.js'; @@ -26,8 +27,8 @@ export default { if (!deferred) return; const robberId = interaction.user.id; - const victimUser = interaction.options.getUser("user"); - const guildId = interaction.guildId; + const victimUser = interaction.options.getUser("user")!; + const guildId = interaction.guildId!; const now = Date.now(); if (robberId === victimUser.id) { @@ -151,4 +152,4 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [resultEmbed] }); }, { command: 'rob' }) -}; \ No newline at end of file +} satisfies Command; diff --git a/src/commands/Economy/shop-config.js b/src/commands/Economy/shop-config.ts similarity index 93% rename from src/commands/Economy/shop-config.js rename to src/commands/Economy/shop-config.ts index fb3be41dc3..a72dcb8666 100644 --- a/src/commands/Economy/shop-config.js +++ b/src/commands/Economy/shop-config.ts @@ -1,3 +1,4 @@ +import type { Command } from '../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; import shopConfigSetrole from './modules/shop_config_setrole.js'; @@ -25,4 +26,4 @@ export default { return shopConfigSetrole.execute(interaction, config, client); } }, -}; +} satisfies Command; diff --git a/src/commands/Economy/shop.js b/src/commands/Economy/shop.ts similarity index 83% rename from src/commands/Economy/shop.js rename to src/commands/Economy/shop.ts index 16b2b62d96..7abaec3124 100644 --- a/src/commands/Economy/shop.js +++ b/src/commands/Economy/shop.ts @@ -1,3 +1,4 @@ +import type { Command } from '../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; import shopBrowse from './modules/shop_browse.js'; @@ -10,4 +11,4 @@ export default { async execute(interaction, config, client) { return shopBrowse.execute(interaction, config, client); }, -}; +} satisfies Command; diff --git a/src/commands/Economy/slut.js b/src/commands/Economy/slut.ts similarity index 92% rename from src/commands/Economy/slut.js rename to src/commands/Economy/slut.ts index 98b83e369e..cd9664a624 100644 --- a/src/commands/Economy/slut.js +++ b/src/commands/Economy/slut.ts @@ -1,6 +1,8 @@ +import type { Command } from '../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; import { createEmbed } from '../../utils/embeds.js'; import { getEconomyData, setEconomyData } from '../../utils/economy.js'; +import type { EconomyData } from '../../utils/economy.js'; import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; @@ -40,15 +42,15 @@ const LOSS_OUTCOMES = [ "The shift went sideways and left you in the red.", ]; -function randomInt(min, max) { +function randomInt(min: number, max: number): number { return Math.floor(Math.random() * (max - min + 1)) + min; } -function randomChoice(items) { +function randomChoice(items: T[]): T { return items[Math.floor(Math.random() * items.length)]; } -function resolveOutcome(activity, wallet) { +function resolveOutcome(activity: typeof SLUT_ACTIVITIES[number], wallet: number) { const successChance = Math.max(0.35, 0.55 - activity.risk * 0.2); const fineChance = 0.22; const robbedChance = 0.2; @@ -111,12 +113,12 @@ export default { if (!deferred) return; const userId = interaction.user.id; - const guildId = interaction.guildId; + const guildId = interaction.guildId!; const now = Date.now(); logger.debug(`[ECONOMY] Slut command started for ${userId}`, { userId, guildId }); - const userData = await getEconomyData(client, guildId, userId); + const userData: EconomyData & { lastSlut?: number; totalSluts?: number; totalSlutEarnings?: number; totalSlutLosses?: number; failedSluts?: number } = await getEconomyData(client, guildId, userId); if (!userData) { throw createError( @@ -185,4 +187,4 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'slut' }) -}; \ No newline at end of file +} satisfies Command; diff --git a/src/commands/Economy/withdraw.js b/src/commands/Economy/withdraw.ts similarity index 95% rename from src/commands/Economy/withdraw.js rename to src/commands/Economy/withdraw.ts index bf45af02ad..1ddac96e3b 100644 --- a/src/commands/Economy/withdraw.js +++ b/src/commands/Economy/withdraw.ts @@ -1,3 +1,4 @@ +import type { Command } from '../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { getEconomyData, setEconomyData, getMaxBankCapacity } from '../../utils/economy.js'; @@ -20,8 +21,8 @@ export default { await InteractionHelper.safeDefer(interaction); const userId = interaction.user.id; - const guildId = interaction.guildId; - const amountInput = interaction.options.getInteger("amount"); + const guildId = interaction.guildId!; + const amountInput = interaction.options.getInteger("amount")!; const userData = await getEconomyData(client, guildId, userId); @@ -82,4 +83,4 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'withdraw' }) -}; \ No newline at end of file +} satisfies Command; diff --git a/src/commands/Economy/work.js b/src/commands/Economy/work.ts similarity index 97% rename from src/commands/Economy/work.js rename to src/commands/Economy/work.ts index 775be897ad..844e38072b 100644 --- a/src/commands/Economy/work.js +++ b/src/commands/Economy/work.ts @@ -1,3 +1,4 @@ +import type { Command } from '../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { getEconomyData, setEconomyData } from '../../utils/economy.js'; @@ -33,7 +34,7 @@ export default { if (!deferred) return; const userId = interaction.user.id; - const guildId = interaction.guildId; + const guildId = interaction.guildId!; const now = Date.now(); const userData = await getEconomyData(client, guildId, userId); @@ -120,4 +121,4 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'work' }) -}; \ No newline at end of file +} satisfies Command; diff --git a/src/commands/Fun/count.js b/src/commands/Fun/count.ts similarity index 97% rename from src/commands/Fun/count.js rename to src/commands/Fun/count.ts index 1dbd6014b1..9094567595 100644 --- a/src/commands/Fun/count.js +++ b/src/commands/Fun/count.ts @@ -1,3 +1,4 @@ +import type { Command } from '../../types/index.js'; import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, MessageFlags } from 'discord.js'; import { createEmbed, successEmbed, infoEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; @@ -73,13 +74,13 @@ export default { return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the **Manage Server** permission to use this command.' }); } - const guildId = interaction.guildId; + const guildId = interaction.guildId!; const subcommand = interaction.options.getSubcommand(); const config = await getCountingGameConfig(interaction.client, guildId); if (subcommand === 'setup') { const channel = interaction.options.getChannel('channel'); - const system = interaction.options.getString('system'); + const system = interaction.options.getString('system')!; if (!channel || channel.type !== ChannelType.GuildText) { return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please choose a text channel for the counting game.' }); } @@ -173,4 +174,4 @@ export default { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Something went wrong while managing the counting game.' }); } }, -}; +} satisfies Command; diff --git a/src/commands/Fun/fight.js b/src/commands/Fun/fight.ts similarity index 91% rename from src/commands/Fun/fight.js rename to src/commands/Fun/fight.ts index 3093af2419..eae6690033 100644 --- a/src/commands/Fun/fight.js +++ b/src/commands/Fun/fight.ts @@ -1,9 +1,10 @@ +import type { Command } from '../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; import { successEmbed, warningEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; -const rand = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; +const rand = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min; const EMBED_DESCRIPTION_LIMIT = 4096; export default { @@ -22,7 +23,7 @@ export default { await InteractionHelper.safeDefer(interaction); const challenger = interaction.user; - const opponent = interaction.options.getUser("opponent"); + const opponent = interaction.options.getUser("opponent")!; if (challenger.id === opponent.id) { const embed = warningEmbed( @@ -45,7 +46,7 @@ export default { const rounds = rand(3, 7); const damage = rand(10, 50); - const log = []; + const log: string[] = []; log.push( `💥 **${challenger.username}** challenges **${opponent.username}** to a duel! (Best of ${rounds} rounds)`, ); @@ -80,4 +81,4 @@ export default { 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 +} satisfies Command; diff --git a/src/commands/Fun/flip.js b/src/commands/Fun/flip.ts similarity index 92% rename from src/commands/Fun/flip.js rename to src/commands/Fun/flip.ts index 8fedb9090d..6fc5530c84 100644 --- a/src/commands/Fun/flip.js +++ b/src/commands/Fun/flip.ts @@ -1,3 +1,4 @@ +import type { Command } from '../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; @@ -22,4 +23,4 @@ export default { 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 +} satisfies Command; diff --git a/src/commands/Fun/roll.js b/src/commands/Fun/roll.ts similarity index 96% rename from src/commands/Fun/roll.js rename to src/commands/Fun/roll.ts index e9eb0d31b5..f755709a08 100644 --- a/src/commands/Fun/roll.js +++ b/src/commands/Fun/roll.ts @@ -1,3 +1,4 @@ +import type { Command } from '../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; import { successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; @@ -21,7 +22,7 @@ export default { await InteractionHelper.safeDefer(interaction); const notation = interaction.options - .getString("notation") + .getString("notation")! .toLowerCase() .replace(/\s/g, ""); @@ -78,4 +79,4 @@ export default { 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 +} satisfies Command; diff --git a/src/commands/Giveaway/gcreate.js b/src/commands/Giveaway/gcreate.ts similarity index 92% rename from src/commands/Giveaway/gcreate.js rename to src/commands/Giveaway/gcreate.ts index 0a199ef5b0..09a3cc3e10 100644 --- a/src/commands/Giveaway/gcreate.js +++ b/src/commands/Giveaway/gcreate.ts @@ -1,8 +1,10 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, MessageFlags } from 'discord.js'; +import type { Command } from '../../types/index.js'; +import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, MessageFlags, type GuildMember, type GuildBasedChannel } from 'discord.js'; import { errorEmbed, successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { saveGiveaway } from '../../utils/giveaways.js'; +import type { Giveaway } from '../../utils/giveaways.js'; import { parseDuration, validatePrize, @@ -66,7 +68,7 @@ export default { ); } - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { + if (!(interaction.member as GuildMember).permissions.has(PermissionFlagsBits.ManageGuild)) { throw new NexoBotError( 'User lacks ManageGuild permission', ErrorTypes.PERMISSION, @@ -78,9 +80,9 @@ export default { 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 winnerCount = interaction.options.getInteger("winners")!; const prize = interaction.options.getString("prize"); - const targetChannel = interaction.options.getChannel("channel") || interaction.channel; + const targetChannel = (interaction.options.getChannel("channel") || interaction.channel) as GuildBasedChannel; const durationMs = parseDuration(durationString); validateWinnerCount(winnerCount); @@ -97,7 +99,7 @@ export default { const endTime = Date.now() + durationMs; - const initialGiveawayData = { + const initialGiveawayData: Giveaway = { messageId: "placeholder", channelId: targetChannel.id, guildId: interaction.guildId, @@ -181,4 +183,4 @@ export default { flags: MessageFlags.Ephemeral, }); }, -}; \ No newline at end of file +} satisfies Command; diff --git a/src/commands/Giveaway/gdelete.js b/src/commands/Giveaway/gdelete.ts similarity index 92% rename from src/commands/Giveaway/gdelete.js rename to src/commands/Giveaway/gdelete.ts index 264e6c69da..cb6503f710 100644 --- a/src/commands/Giveaway/gdelete.js +++ b/src/commands/Giveaway/gdelete.ts @@ -1,4 +1,5 @@ -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; +import type { Command } from '../../types/index.js'; +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, type GuildMember, type Channel } from 'discord.js'; import { errorEmbed, successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; @@ -30,7 +31,7 @@ export default { ); } - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { + if (!(interaction.member as GuildMember).permissions.has(PermissionFlagsBits.ManageGuild)) { throw new NexoBotError( 'User lacks ManageGuild permission', ErrorTypes.PERMISSION, @@ -67,7 +68,7 @@ export default { let deletedMessage = false; let channelName = "Unknown Channel"; - const tryDeleteFromChannel = async (channel) => { + const tryDeleteFromChannel = async (channel: Channel | null) => { if (!channel || !channel.isTextBased() || !channel.messages?.fetch) { return false; } @@ -78,13 +79,13 @@ export default { } await message.delete(); - channelName = channel.name || 'unknown-channel'; + channelName = (channel as { name?: string | null }).name || 'unknown-channel'; deletedMessage = true; return true; }; try { - const channel = await interaction.client.channels.fetch(giveaway.channelId).catch(() => null); + const channel = await interaction.client.channels.fetch(giveaway.channelId!).catch(() => null); if (await tryDeleteFromChannel(channel)) { logger.debug(`Deleted giveaway message ${messageId} from channel ${channelName}`); } @@ -103,7 +104,7 @@ export default { } } } catch (error) { - logger.warn(`Could not delete giveaway message: ${error.message}`); + logger.warn(`Could not delete giveaway message: ${error instanceof Error ? error.message : String(error)}`); } const removedFromDatabase = await deleteGiveaway( @@ -186,4 +187,4 @@ export default { flags: MessageFlags.Ephemeral, }); }, -}; \ No newline at end of file +} satisfies Command; diff --git a/src/commands/Giveaway/gend.js b/src/commands/Giveaway/gend.ts similarity index 93% rename from src/commands/Giveaway/gend.js rename to src/commands/Giveaway/gend.ts index 53f42a3f39..79c56fdb48 100644 --- a/src/commands/Giveaway/gend.js +++ b/src/commands/Giveaway/gend.ts @@ -1,4 +1,5 @@ -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; +import type { Command } from '../../types/index.js'; +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, type GuildMember, type SendableChannels } from 'discord.js'; import { errorEmbed, successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; @@ -35,7 +36,7 @@ export default { ); } - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { + if (!(interaction.member as GuildMember).permissions.has(PermissionFlagsBits.ManageGuild)) { throw new NexoBotError( 'User lacks ManageGuild permission', ErrorTypes.PERMISSION, @@ -80,7 +81,7 @@ export default { const winners = endResult.winners; const channel = await interaction.client.channels.fetch( - updatedGiveaway.channelId, + updatedGiveaway.channelId!, ).catch(err => { logger.warn(`Could not fetch channel ${updatedGiveaway.channelId}:`, err.message); return null; @@ -130,7 +131,7 @@ export default { const winnerMentions = winners .map((id) => `<@${id}>`) .join(","); - const winnerPingMsg = await channel.send({ + const winnerPingMsg = await (channel as SendableChannels).send({ content: `🎉 CONGRATULATIONS ${winnerMentions}! You won the **${updatedGiveaway.prize}** giveaway! Please contact the host <@${updatedGiveaway.hostId}> to claim your prize.`, }); updatedGiveaway.winnerPingMessageId = winnerPingMsg.id; @@ -170,7 +171,7 @@ export default { logger.debug('Error logging giveaway winner event:', logError); } } else { - await channel.send({ + await (channel as SendableChannels).send({ content: `The giveaway for **${updatedGiveaway.prize}** has ended with no valid entries.`, }); logger.info(`Giveaway ended with no winners: ${messageId}`); @@ -188,4 +189,4 @@ export default { flags: MessageFlags.Ephemeral, }); }, -}; \ No newline at end of file +} satisfies Command; diff --git a/src/commands/Giveaway/greroll.js b/src/commands/Giveaway/greroll.ts similarity index 93% rename from src/commands/Giveaway/greroll.js rename to src/commands/Giveaway/greroll.ts index 0f97dccddc..ad332c9900 100644 --- a/src/commands/Giveaway/greroll.js +++ b/src/commands/Giveaway/greroll.ts @@ -1,8 +1,10 @@ -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; +import type { Command } from '../../types/index.js'; +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, type GuildMember, type SendableChannels } from 'discord.js'; import { errorEmbed, successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { getGuildGiveaways, saveGiveaway } from '../../utils/giveaways.js'; +import type { Giveaway } from '../../utils/giveaways.js'; import { selectWinners, createGiveawayEmbed, @@ -33,7 +35,7 @@ export default { ); } - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { + if (!(interaction.member as GuildMember).permissions.has(PermissionFlagsBits.ManageGuild)) { throw new NexoBotError( 'User lacks ManageGuild permission', ErrorTypes.PERMISSION, @@ -82,7 +84,7 @@ export default { const participants = giveaway.participants || []; - if (participants.length < giveaway.winnerCount) { + if (participants.length < giveaway.winnerCount!) { throw new NexoBotError( `Insufficient participants for reroll: ${participants.length} < ${giveaway.winnerCount}`, ErrorTypes.VALIDATION, @@ -93,10 +95,10 @@ export default { const newWinners = selectWinners( participants, - giveaway.winnerCount, + giveaway.winnerCount!, ); - const updatedGiveaway = { + const updatedGiveaway: Giveaway = { ...giveaway, winnerIds: newWinners, rerolledAt: new Date().toISOString(), @@ -104,7 +106,7 @@ export default { }; const channel = await interaction.client.channels.fetch( - giveaway.channelId, + giveaway.channelId!, ).catch(err => { logger.warn(`Could not fetch channel ${giveaway.channelId}:`, err.message); return null; @@ -151,14 +153,14 @@ export default { .join(","); const existingPingMsg = giveaway.winnerPingMessageId - ? await channel.messages.fetch(giveaway.winnerPingMessageId).catch(() => null) + ? await channel.messages.fetch(giveaway.winnerPingMessageId as string).catch(() => null) : null; if (existingPingMsg) { await existingPingMsg.edit({ content: `🔄 **GIVEAWAY REROLL** 🔄 New winners for **${giveaway.prize}**: ${winnerMentions}!`, }); } else { - const newPingMsg = await channel.send({ + const newPingMsg = await (channel as SendableChannels).send({ content: `🔄 **GIVEAWAY REROLL** 🔄 New winners for **${giveaway.prize}**: ${winnerMentions}!`, }); updatedGiveaway.winnerPingMessageId = newPingMsg.id; @@ -229,14 +231,14 @@ export default { .join(","); const existingPingMsg = giveaway.winnerPingMessageId - ? await channel.messages.fetch(giveaway.winnerPingMessageId).catch(() => null) + ? await channel.messages.fetch(giveaway.winnerPingMessageId as string).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({ + const newPingMsg = await (channel as SendableChannels).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; @@ -286,4 +288,4 @@ export default { flags: MessageFlags.Ephemeral, }); }, -}; \ No newline at end of file +} satisfies Command; diff --git a/src/commands/JoinToCreate/jointocreate.js b/src/commands/JoinToCreate/jointocreate.ts similarity index 85% rename from src/commands/JoinToCreate/jointocreate.js rename to src/commands/JoinToCreate/jointocreate.ts index 0eae039ff6..62dd193065 100644 --- a/src/commands/JoinToCreate/jointocreate.js +++ b/src/commands/JoinToCreate/jointocreate.ts @@ -13,6 +13,14 @@ import { logConfigurationChange, getConfiguration } from '../../services/joinToCreateService.js'; +import type { ButtonInteraction, CategoryChannel, GuildBasedChannel, ChatInputCommandInteraction, GuildMember, VoiceChannel } from 'discord.js'; +import type { Command, NexoClient } from '../../types/index.js'; +import type { JoinToCreateChannelOptions } from '../../utils/database.js'; + +// getChannelConfiguration infers `channelConfig` as `StoredChannelOptions | {}`; widen it to the stored option shape. +type ChannelConfiguration = Omit>, 'channelConfig'> & { + channelConfig: Partial; +}; export default { data: new SlashCommandBuilder() @@ -75,7 +83,7 @@ export default { async execute(interaction, config, client) { try { - if (!hasManageGuildPermission(interaction.member)) { + if (!hasManageGuildPermission(interaction.member as GuildMember)) { throw new NexoBotError( 'User lacks ManageGuild permission', ErrorTypes.PERMISSION, @@ -86,7 +94,7 @@ export default { const subcommand = interaction.options.getSubcommand(); await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - let responseEmbed; + let responseEmbed: unknown; if (subcommand === "setup") { await handleSetupSubcommand(interaction, client); @@ -102,7 +110,7 @@ export default { if (error instanceof NexoBotError) { errorMessage = error.userMessage || 'An error occurred. Please try again.'; - logger.debug(`NexoBotError [${error.type}]: ${error.message}`, error.context || {}); + logger.debug(`NexoBotError [${error.type}]: ${(error as Error).message}`, error.context || {}); } else { logger.error('Unexpected error in jointocreate command:', error); errorMessage = 'An unexpected error occurred. Please try again or contact support.'; @@ -114,26 +122,26 @@ export default { } } } -}; +} satisfies Command; -async function handleSetupSubcommand(interaction, client) { +async function handleSetupSubcommand(interaction: ChatInputCommandInteraction, client: NexoClient) { try { - const category = interaction.options.getChannel('category'); + const category = interaction.options.getChannel('category') as CategoryChannel | null; const nameTemplate = interaction.options.getString('channel_name') || "{username}'s Room"; const userLimit = interaction.options.getInteger('user_limit') || 0; const bitrate = interaction.options.getInteger('bitrate') || 64; - const guildId = interaction.guild.id; + const guildId = interaction.guild!.id; logger.debug(`Setting up Join to Create in guild ${guildId} with template: ${nameTemplate}`); const existingConfig = await getConfiguration(client, guildId); if (Array.isArray(existingConfig.triggerChannels) && existingConfig.triggerChannels.length > 0) { - const activeTriggerChannels = []; - const staleTriggerChannelIds = []; + const activeTriggerChannels: GuildBasedChannel[] = []; + const staleTriggerChannelIds: string[] = []; for (const existingChannelId of existingConfig.triggerChannels) { - const existingChannel = await interaction.guild.channels.fetch(existingChannelId).catch(() => null); + const existingChannel = await interaction.guild!.channels.fetch(existingChannelId).catch(() => null); if (existingChannel) { activeTriggerChannels.push(existingChannel); } else { @@ -167,7 +175,7 @@ async function handleSetupSubcommand(interaction, client) { } logger.debug('Creating Join to Create trigger channel...'); - let triggerChannel = await interaction.guild.channels.create({ + let triggerChannel = await interaction.guild!.channels.create({ name: 'Join to Create', type: ChannelType.GuildVoice, parent: category?.id, @@ -175,7 +183,7 @@ async function handleSetupSubcommand(interaction, client) { bitrate: 64000, permissionOverwrites: [ { - id: interaction.guild.id, + id: interaction.guild!.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect], }, ], @@ -217,19 +225,19 @@ async function handleSetupSubcommand(interaction, client) { throw error; } throw new NexoBotError( - `Setup failed: ${error.message}`, + `Setup failed: ${(error as Error).message}`, ErrorTypes.DISCORD_API, 'Failed to set up Join to Create system. Please check bot permissions.' ); } } -async function handleConfigSubcommand(interaction, client) { +async function handleConfigSubcommand(interaction: ChatInputCommandInteraction, client: NexoClient) { try { - const triggerChannel = interaction.options.getChannel('trigger_channel'); - const guildId = interaction.guild.id; + const triggerChannel = interaction.options.getChannel('trigger_channel') as VoiceChannel; + const guildId = interaction.guild!.id; - const currentConfig = await getChannelConfiguration(client, guildId, triggerChannel.id); + const currentConfig: ChannelConfiguration = await getChannelConfiguration(client, guildId, triggerChannel.id); const channelConfig = currentConfig.channelConfig || {}; const configEmbed = new EmbedBuilder() @@ -276,7 +284,7 @@ async function handleConfigSubcommand(interaction, client) { .setLabel('🗑️ Remove Channel') .setStyle(ButtonStyle.Danger); - const row = new ActionRowBuilder().addComponents(nameButton, limitButton, bitrateButton, deleteButton); + const row = new ActionRowBuilder().addComponents(nameButton, limitButton, bitrateButton, deleteButton); await InteractionHelper.safeEditReply(interaction, { embeds: [configEmbed], @@ -301,7 +309,7 @@ async function handleConfigSubcommand(interaction, client) { collector.on('collect', async (buttonInteraction) => { try { - if (!hasManageGuildPermission(buttonInteraction.member)) { + if (!hasManageGuildPermission(buttonInteraction.member as GuildMember)) { await buttonInteraction.reply({ content: '❌ You need **Manage Server** permission to use these controls.', flags: MessageFlags.Ephemeral @@ -326,7 +334,7 @@ async function handleConfigSubcommand(interaction, client) { : 'An error occurred while processing your request.'; if (error instanceof NexoBotError) { - logger.debug(`Button interaction validation error: ${error.message}`, error.context || {}); + logger.debug(`Button interaction validation error: ${(error as Error).message}`, error.context || {}); } else { logger.error('Unexpected error in config button interaction:', error); } @@ -339,7 +347,7 @@ async function handleConfigSubcommand(interaction, client) { }); collector.on('end', () => { - const disabledRow = new ActionRowBuilder().addComponents( + const disabledRow = new ActionRowBuilder().addComponents( nameButton.setDisabled(true), limitButton.setDisabled(true), bitrateButton.setDisabled(true), @@ -357,14 +365,14 @@ async function handleConfigSubcommand(interaction, client) { throw error; } throw new NexoBotError( - `Config failed: ${error.message}`, + `Config failed: ${(error as Error).message}`, ErrorTypes.DATABASE, 'Failed to load configuration.' ); } } -async function handleNameTemplateModal(interaction, triggerChannel, currentConfig, client) { +async function handleNameTemplateModal(interaction: ButtonInteraction, triggerChannel: VoiceChannel, currentConfig: ChannelConfiguration, client: NexoClient) { try { const TEMPLATE_OPTIONS = [ { label: "{username}'s Room (Default)", value: "{username}'s Room" }, @@ -410,7 +418,7 @@ async function handleNameTemplateModal(interaction, triggerChannel, currentConfi time: 60000 }); - if (!hasManageGuildPermission(modalSubmission.member)) { + if (!hasManageGuildPermission(modalSubmission.member as GuildMember)) { await modalSubmission.reply({ content: '❌ You need **Manage Server** permission to modify these settings.', flags: MessageFlags.Ephemeral @@ -420,11 +428,11 @@ async function handleNameTemplateModal(interaction, triggerChannel, currentConfi const [newTemplate] = modalSubmission.fields.getStringSelectValues('template'); - await updateChannelConfig(client, interaction.guild.id, triggerChannel.id, { + await updateChannelConfig(client, interaction.guild!.id, triggerChannel.id, { nameTemplate: newTemplate }); - await logConfigurationChange(client, interaction.guild.id, interaction.user.id, 'Updated channel name template', { + await logConfigurationChange(client, interaction.guild!.id, interaction.user.id, 'Updated channel name template', { channelId: triggerChannel.id, newTemplate }); @@ -435,7 +443,7 @@ async function handleNameTemplateModal(interaction, triggerChannel, currentConfi }); } catch (error) { - if (error.code === 'INTERACTION_COLLECTOR_ERROR') { + if ((error as { code?: unknown }).code === 'INTERACTION_COLLECTOR_ERROR') { return; } if (error instanceof NexoBotError) { @@ -443,14 +451,14 @@ async function handleNameTemplateModal(interaction, triggerChannel, currentConfi } logger.error('Unexpected error in name template modal:', error); throw new NexoBotError( - `Modal error: ${error.message}`, + `Modal error: ${(error as Error).message}`, ErrorTypes.UNKNOWN, 'An error occurred while updating the template.' ); } } -async function handleUserLimitModal(interaction, triggerChannel, currentConfig, client) { +async function handleUserLimitModal(interaction: ButtonInteraction, triggerChannel: VoiceChannel, currentConfig: ChannelConfiguration, client: NexoClient) { try { const currentLimit = currentConfig.channelConfig?.userLimit ?? currentConfig.userLimit ?? 0; @@ -458,7 +466,7 @@ async function handleUserLimitModal(interaction, triggerChannel, currentConfig, .setCustomId(`jtc_limit_modal_${triggerChannel.id}`) .setTitle('Configure User Limit') .addComponents( - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('user_limit') .setLabel('Enter user limit (0-99, 0 = unlimited)') @@ -478,7 +486,7 @@ async function handleUserLimitModal(interaction, triggerChannel, currentConfig, time: 60000 }); - if (!hasManageGuildPermission(modalSubmission.member)) { + if (!hasManageGuildPermission(modalSubmission.member as GuildMember)) { await modalSubmission.reply({ content: '❌ You need **Manage Server** permission to modify these settings.', flags: MessageFlags.Ephemeral @@ -488,11 +496,11 @@ async function handleUserLimitModal(interaction, triggerChannel, currentConfig, const userInput = modalSubmission.fields.getTextInputValue('user_limit').trim(); - await updateChannelConfig(client, interaction.guild.id, triggerChannel.id, { + await updateChannelConfig(client, interaction.guild!.id, triggerChannel.id, { userLimit: parseInt(userInput) }); - await logConfigurationChange(client, interaction.guild.id, interaction.user.id, 'Updated user limit', { + await logConfigurationChange(client, interaction.guild!.id, interaction.user.id, 'Updated user limit', { channelId: triggerChannel.id, userLimit: parseInt(userInput) }); @@ -503,7 +511,7 @@ async function handleUserLimitModal(interaction, triggerChannel, currentConfig, }); } catch (error) { - if (error.code === 'INTERACTION_COLLECTOR_ERROR') { + if ((error as { code?: unknown }).code === 'INTERACTION_COLLECTOR_ERROR') { return; } if (error instanceof NexoBotError) { @@ -511,14 +519,14 @@ async function handleUserLimitModal(interaction, triggerChannel, currentConfig, } logger.error('Unexpected error in user limit modal:', error); throw new NexoBotError( - `Modal error: ${error.message}`, + `Modal error: ${(error as Error).message}`, ErrorTypes.UNKNOWN, 'An error occurred while updating the user limit.' ); } } -async function handleBitrateModal(interaction, triggerChannel, currentConfig, client) { +async function handleBitrateModal(interaction: ButtonInteraction, triggerChannel: VoiceChannel, currentConfig: ChannelConfiguration, client: NexoClient) { try { const currentBitrate = ((currentConfig.channelConfig.bitrate ?? currentConfig.bitrate ?? 64000) / 1000); @@ -526,7 +534,7 @@ async function handleBitrateModal(interaction, triggerChannel, currentConfig, cl .setCustomId(`jtc_bitrate_modal_${triggerChannel.id}`) .setTitle('Configure Bitrate') .addComponents( - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('bitrate') .setLabel('Enter bitrate in kbps (8-384)') @@ -546,7 +554,7 @@ async function handleBitrateModal(interaction, triggerChannel, currentConfig, cl time: 60000 }); - if (!hasManageGuildPermission(modalSubmission.member)) { + if (!hasManageGuildPermission(modalSubmission.member as GuildMember)) { await modalSubmission.reply({ content: '❌ You need **Manage Server** permission to modify these settings.', flags: MessageFlags.Ephemeral @@ -556,11 +564,11 @@ async function handleBitrateModal(interaction, triggerChannel, currentConfig, cl const userInput = modalSubmission.fields.getTextInputValue('bitrate').trim(); - await updateChannelConfig(client, interaction.guild.id, triggerChannel.id, { + await updateChannelConfig(client, interaction.guild!.id, triggerChannel.id, { bitrate: parseInt(userInput) * 1000 }); - await logConfigurationChange(client, interaction.guild.id, interaction.user.id, 'Updated bitrate', { + await logConfigurationChange(client, interaction.guild!.id, interaction.user.id, 'Updated bitrate', { channelId: triggerChannel.id, bitrate: parseInt(userInput) }); @@ -571,7 +579,7 @@ async function handleBitrateModal(interaction, triggerChannel, currentConfig, cl }); } catch (error) { - if (error.code === 'INTERACTION_COLLECTOR_ERROR') { + if ((error as { code?: unknown }).code === 'INTERACTION_COLLECTOR_ERROR') { return; } if (error instanceof NexoBotError) { @@ -579,16 +587,16 @@ async function handleBitrateModal(interaction, triggerChannel, currentConfig, cl } logger.error('Unexpected error in bitrate modal:', error); throw new NexoBotError( - `Modal error: ${error.message}`, + `Modal error: ${(error as Error).message}`, ErrorTypes.UNKNOWN, 'An error occurred while updating the bitrate.' ); } } -async function handleChannelDeletion(interaction, triggerChannel, currentConfig, client) { +async function handleChannelDeletion(interaction: ButtonInteraction, triggerChannel: VoiceChannel, currentConfig: ChannelConfiguration, client: NexoClient) { try { - const confirmRow = new ActionRowBuilder().addComponents( + const confirmRow = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`jtc_delete_confirm_${triggerChannel.id}`) .setLabel('🗑️ Yes, Delete') @@ -618,7 +626,7 @@ async function handleChannelDeletion(interaction, triggerChannel, currentConfig, deleteCollector.on('collect', async (buttonInteraction) => { try { - if (!hasManageGuildPermission(buttonInteraction.member)) { + if (!hasManageGuildPermission(buttonInteraction.member as GuildMember)) { await buttonInteraction.reply({ content: '❌ You need **Manage Server** permission to remove channels.', flags: MessageFlags.Ephemeral @@ -628,9 +636,9 @@ async function handleChannelDeletion(interaction, triggerChannel, currentConfig, if (buttonInteraction.customId === `jtc_delete_confirm_${triggerChannel.id}`) { - await removeTriggerChannel(client, interaction.guild.id, triggerChannel.id); + await removeTriggerChannel(client, interaction.guild!.id, triggerChannel.id); - await logConfigurationChange(client, interaction.guild.id, interaction.user.id, 'Removed Join to Create trigger', { + await logConfigurationChange(client, interaction.guild!.id, interaction.user.id, 'Removed Join to Create trigger', { channelId: triggerChannel.id, channelName: triggerChannel.name }); @@ -640,7 +648,7 @@ async function handleChannelDeletion(interaction, triggerChannel, currentConfig, await triggerChannel.delete('Join to Create trigger removed by administrator'); } } catch (deleteError) { - logger.warn(`Could not delete channel ${triggerChannel.id}: ${deleteError.message}`); + logger.warn(`Could not delete channel ${triggerChannel.id}: ${(deleteError as Error).message}`); } @@ -676,7 +684,7 @@ async function handleChannelDeletion(interaction, triggerChannel, currentConfig, } logger.error('Unexpected error in handleChannelDeletion:', error); throw new NexoBotError( - `Deletion error: ${error.message}`, + `Deletion error: ${(error as Error).message}`, ErrorTypes.UNKNOWN, 'An error occurred while removing the channel.' ); diff --git a/src/commands/JoinToCreate/modules/config_setup.js b/src/commands/JoinToCreate/modules/config_setup.ts similarity index 90% rename from src/commands/JoinToCreate/modules/config_setup.js rename to src/commands/JoinToCreate/modules/config_setup.ts index 2e18c9caed..2355c6a88f 100644 --- a/src/commands/JoinToCreate/modules/config_setup.js +++ b/src/commands/JoinToCreate/modules/config_setup.ts @@ -20,12 +20,15 @@ import { removeJoinToCreateTrigger, addJoinToCreateTrigger } from '../../../utils/database.js'; +import type { JoinToCreateChannelOptions, JoinToCreateConfig } from '../../../utils/database.js'; +import type { GuildTextBasedChannel, StringSelectMenuInteraction, VoiceChannel } from 'discord.js'; +import type { NexoClient, SubcommandModule } from '../../../types/index.js'; export default { async execute(interaction, config, client) { try { - const triggerChannel = interaction.options.getChannel('trigger_channel'); - const guildId = interaction.guild.id; + const triggerChannel = interaction.options.getChannel('trigger_channel') as VoiceChannel; + const guildId = interaction.guild!.id; const currentConfig = await getJoinToCreateConfig(client, guildId); @@ -87,7 +90,7 @@ export default { .setValue('view_settings') ); - const row = new ActionRowBuilder().addComponents(selectMenu); + const row = new ActionRowBuilder().addComponents(selectMenu); await InteractionHelper.safeEditReply(interaction, { embeds: [embed], @@ -96,7 +99,7 @@ export default { logger.error('Failed to edit reply in config_setup:', error); }); - const collector = interaction.channel.createMessageComponentCollector({ + const collector = (interaction.channel as GuildTextBasedChannel).createMessageComponentCollector({ componentType: ComponentType.StringSelect, filter: (i) => i.user.id === interaction.user.id && i.customId === `jointocreate_config_${triggerChannel.id}`, time: 60000 @@ -145,7 +148,7 @@ time: 60000 collector.on('end', async (collected, reason) => { if (reason === 'time') { - const disabledRow = new ActionRowBuilder().addComponents( + const disabledRow = new ActionRowBuilder().addComponents( selectMenu.setDisabled(true) ); @@ -160,15 +163,15 @@ time: 60000 } logger.error('Unexpected error in config_setup:', error); throw new NexoBotError( - `Config setup failed: ${error.message}`, + `Config setup failed: ${(error as Error).message}`, ErrorTypes.UNKNOWN, 'Failed to configure Join to Create system.' ); } } -}; +} satisfies SubcommandModule; -async function handleNameTemplateChange(interaction, triggerChannel, currentConfig, client) { +async function handleNameTemplateChange(interaction: StringSelectMenuInteraction, triggerChannel: VoiceChannel, currentConfig: JoinToCreateConfig, client: NexoClient) { const embed = new EmbedBuilder() .setTitle('Channel Name Template Configuration') .setDescription('Please enter the new channel name template.') @@ -189,7 +192,7 @@ async function handleNameTemplateChange(interaction, triggerChannel, currentConf await interaction.followUp({ embeds: [embed], flags: MessageFlags.Ephemeral }); - const collector = interaction.channel.createMessageCollector({ + const collector = (interaction.channel as GuildTextBasedChannel).createMessageCollector({ filter: (m) => m.author.id === interaction.user.id, time: 600_000, max: 1 @@ -213,7 +216,7 @@ time: 600_000, nameTemplate: newTemplate }; - await updateJoinToCreateConfig(client, interaction.guild.id, { + await updateJoinToCreateConfig(client, interaction.guild!.id, { channelOptions: channelOptions }); @@ -251,7 +254,7 @@ time: 600_000, }); } -async function handleUserLimitChange(interaction, triggerChannel, currentConfig, client) { +async function handleUserLimitChange(interaction: StringSelectMenuInteraction, triggerChannel: VoiceChannel, currentConfig: JoinToCreateConfig, client: NexoClient) { const embed = new EmbedBuilder() .setTitle('User Limit Configuration') .setDescription('Please enter the new user limit (0-99, where 0 = no limit).') @@ -267,7 +270,7 @@ async function handleUserLimitChange(interaction, triggerChannel, currentConfig, await interaction.followUp({ embeds: [embed], flags: MessageFlags.Ephemeral }); - const collector = interaction.channel.createMessageCollector({ + const collector = (interaction.channel as GuildTextBasedChannel).createMessageCollector({ filter: (m) => m.author.id === interaction.user.id && /^\d+$/.test(m.content.trim()), time: 600_000, max: 1 @@ -291,7 +294,7 @@ async function handleUserLimitChange(interaction, triggerChannel, currentConfig, userLimit: newLimit }; - await updateJoinToCreateConfig(client, interaction.guild.id, { + await updateJoinToCreateConfig(client, interaction.guild!.id, { channelOptions: channelOptions }); @@ -329,7 +332,7 @@ async function handleUserLimitChange(interaction, triggerChannel, currentConfig, }); } -async function handleBitrateChange(interaction, triggerChannel, currentConfig, client) { +async function handleBitrateChange(interaction: StringSelectMenuInteraction, triggerChannel: VoiceChannel, currentConfig: JoinToCreateConfig, client: NexoClient) { const embed = new EmbedBuilder() .setTitle('Bitrate Configuration') .setDescription('Please enter the new bitrate in kbps (8-384).') @@ -350,7 +353,7 @@ async function handleBitrateChange(interaction, triggerChannel, currentConfig, c await interaction.followUp({ embeds: [embed], flags: MessageFlags.Ephemeral }); - const collector = interaction.channel.createMessageCollector({ + const collector = (interaction.channel as GuildTextBasedChannel).createMessageCollector({ filter: (m) => m.author.id === interaction.user.id && /^\d+$/.test(m.content.trim()), time: 600_000, max: 1 @@ -374,7 +377,7 @@ async function handleBitrateChange(interaction, triggerChannel, currentConfig, c bitrate: newBitrate * 1000 }; - await updateJoinToCreateConfig(client, interaction.guild.id, { + await updateJoinToCreateConfig(client, interaction.guild!.id, { channelOptions: channelOptions }); @@ -412,14 +415,14 @@ async function handleBitrateChange(interaction, triggerChannel, currentConfig, c }); } -async function handleRemoveTrigger(interaction, triggerChannel, currentConfig, client) { +async function handleRemoveTrigger(interaction: StringSelectMenuInteraction, triggerChannel: VoiceChannel, currentConfig: JoinToCreateConfig, client: NexoClient) { const embed = new EmbedBuilder() .setTitle('Remove Trigger Channel') .setDescription(`Are you sure you want to remove ${triggerChannel} from the Join to Create system?`) .setColor('#ff6600') .setFooter({ text: 'This action cannot be undone' }); - const row = new ActionRowBuilder().addComponents( + const row = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`confirm_remove_${triggerChannel.id}`) .setLabel('Remove Channel') @@ -436,7 +439,7 @@ async function handleRemoveTrigger(interaction, triggerChannel, currentConfig, c flags: MessageFlags.Ephemeral }); - const collector = interaction.channel.createMessageComponentCollector({ + const collector = (interaction.channel as GuildTextBasedChannel).createMessageComponentCollector({ componentType: ComponentType.Button, filter: (i) => i.user.id === interaction.user.id && (i.customId === `confirm_remove_${triggerChannel.id}` || i.customId === `cancel_remove_${triggerChannel.id}`), @@ -449,7 +452,7 @@ async function handleRemoveTrigger(interaction, triggerChannel, currentConfig, c if (buttonInteraction.customId === `confirm_remove_${triggerChannel.id}`) { try { - const success = await removeJoinToCreateTrigger(client, interaction.guild.id, triggerChannel.id); + const success = await removeJoinToCreateTrigger(client, interaction.guild!.id, triggerChannel.id); if (success) { await buttonInteraction.followUp({ @@ -496,8 +499,8 @@ async function handleRemoveTrigger(interaction, triggerChannel, currentConfig, c }); } -async function handleViewSettings(interaction, triggerChannel, currentConfig, client) { - const channelConfig = currentConfig.channelOptions?.[triggerChannel.id] || {}; +async function handleViewSettings(interaction: StringSelectMenuInteraction, triggerChannel: VoiceChannel, currentConfig: JoinToCreateConfig, client: NexoClient) { + const channelConfig: Partial = currentConfig.channelOptions?.[triggerChannel.id] || {}; const embed = new EmbedBuilder() .setTitle('Current Settings') diff --git a/src/commands/JoinToCreate/modules/setup.js b/src/commands/JoinToCreate/modules/setup.ts similarity index 84% rename from src/commands/JoinToCreate/modules/setup.js rename to src/commands/JoinToCreate/modules/setup.ts index e3158b1b6b..db866645ea 100644 --- a/src/commands/JoinToCreate/modules/setup.js +++ b/src/commands/JoinToCreate/modules/setup.ts @@ -5,16 +5,18 @@ import { NexoBotError, ErrorTypes } from '../../../utils/errorHandler.js'; import { addJoinToCreateTrigger, getJoinToCreateConfig } from '../../../utils/database.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import type { CategoryChannel } from 'discord.js'; +import type { SubcommandModule } from '../../../types/index.js'; export default { async execute(interaction, config, client) { - const category = interaction.options.getChannel('category'); + const category = interaction.options.getChannel('category') as CategoryChannel | null; const nameTemplate = interaction.options.getString('channel_name') || "{username}'s Room"; const userLimit = interaction.options.getInteger('user_limit') || 0; const bitrate = interaction.options.getInteger('bitrate') || 64; - const guildId = interaction.guild.id; + const guildId = interaction.guild!.id; try { - const triggerChannel = await interaction.guild.channels.create({ + const triggerChannel = await interaction.guild!.channels.create({ name: 'Join to Create', type: ChannelType.GuildVoice, parent: category?.id, @@ -22,18 +24,20 @@ export default { bitrate: bitrate * 1000, permissionOverwrites: [ { - id: interaction.guild.id, + id: interaction.guild!.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect], }, ], }); - await addJoinToCreateTrigger(client, guildId, triggerChannel.id, { + // categoryId is not part of JoinToCreateChannelOptions (ignored by addJoinToCreateTrigger) but kept as in the JS. + const triggerOptions = { nameTemplate: nameTemplate, userLimit: userLimit, bitrate: bitrate * 1000, categoryId: category?.id - }); + }; + await addJoinToCreateTrigger(client, guildId, triggerChannel.id, triggerOptions); const embed = successEmbed( '✅ Join to Create Setup Complete', @@ -69,10 +73,10 @@ export default { } logger.error('Error in JoinToCreate setup:', error); throw new NexoBotError( - `Setup failed: ${error.message}`, + `Setup failed: ${(error as Error).message}`, ErrorTypes.DISCORD_API, 'Failed to set up Join to Create system.' ); } } -}; \ No newline at end of file +} satisfies SubcommandModule; \ No newline at end of file diff --git a/src/commands/Leveling/leaderboard.js b/src/commands/Leveling/leaderboard.ts similarity index 92% rename from src/commands/Leveling/leaderboard.js rename to src/commands/Leveling/leaderboard.ts index edea5cc4e4..e20308b10d 100644 --- a/src/commands/Leveling/leaderboard.js +++ b/src/commands/Leveling/leaderboard.ts @@ -4,6 +4,7 @@ import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { getLeaderboard, getLevelingConfig, getXpForLevel } from '../../services/leveling/leveling.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import type { Command } from '../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('leaderboard') @@ -14,7 +15,7 @@ export default { async execute(interaction, config, client) { 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, { @@ -28,7 +29,7 @@ export default { return; } - const leaderboard = await getLeaderboard(client, interaction.guildId, 10); + const leaderboard = await getLeaderboard(client, interaction.guildId!, 10); if (leaderboard.length === 0) { throw new NexoBotError( @@ -47,7 +48,7 @@ export default { const leaderboardText = await Promise.all( leaderboard.map(async (user, index) => { try { - const member = await interaction.guild.members.fetch(user.userId).catch(() => null); + const member = await interaction.guild!.members.fetch(user.userId).catch(() => null); const userMention = member?.user.toString() || `<@${user.userId}>`; const xpForNextLevel = getXpForLevel(user.level + 1); @@ -72,4 +73,4 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); logger.debug(`Leaderboard displayed for guild ${interaction.guildId}`); } -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Leveling/level.js b/src/commands/Leveling/level.ts similarity index 97% rename from src/commands/Leveling/level.js rename to src/commands/Leveling/level.ts index 2e24a55902..48c73603fb 100644 --- a/src/commands/Leveling/level.js +++ b/src/commands/Leveling/level.ts @@ -1,5 +1,6 @@ import { getColor } from '../../config/bot.js'; import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, MessageFlags } from 'discord.js'; +import type { TextChannel } from 'discord.js'; import { createEmbed } from '../../utils/embeds.js'; import { getLevelingConfig, saveLevelingConfig } from '../../services/leveling/leveling.js'; import { botHasPermission } from '../../utils/permissionGuard.js'; @@ -7,6 +8,7 @@ import { NexoBotError, ErrorTypes, replyUserError } from '../../utils/errorHandl import { InteractionHelper } from '../../utils/interactionHelper.js'; import { logger } from '../../utils/logger.js'; import levelDashboard from './modules/level_dashboard.js'; +import type { Command } from '../../types/index.js'; export default { data: new SlashCommandBuilder() @@ -83,7 +85,7 @@ export default { } if (subcommand === 'setup') { - const channel = interaction.options.getChannel('channel'); + const channel = interaction.options.getChannel('channel') as TextChannel; const xpMin = interaction.options.getInteger('xp_min') ?? 15; const xpMax = interaction.options.getInteger('xp_max') ?? 25; const message = @@ -103,7 +105,7 @@ export default { ); } - 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.` }); @@ -120,7 +122,7 @@ export default { 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, @@ -147,4 +149,4 @@ export default { }); } }, -}; \ No newline at end of file +} satisfies Command; diff --git a/src/commands/Leveling/leveladd.js b/src/commands/Leveling/leveladd.ts similarity index 90% rename from src/commands/Leveling/leveladd.js rename to src/commands/Leveling/leveladd.ts index c181bee195..372262dd17 100644 --- a/src/commands/Leveling/leveladd.js +++ b/src/commands/Leveling/leveladd.ts @@ -6,6 +6,7 @@ import { addLevels, getLevelingConfig } from '../../services/leveling/leveling.j import { createEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import type { Command } from '../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('leveladd') @@ -37,7 +38,7 @@ export default { ); if (!hasPermission) return; - const levelingConfig = await getLevelingConfig(client, interaction.guildId); + const levelingConfig = await getLevelingConfig(client, interaction.guildId!); if (!levelingConfig?.enabled) { await InteractionHelper.safeEditReply(interaction, { embeds: [ @@ -50,10 +51,10 @@ export default { return; } - const targetUser = interaction.options.getUser('user'); - const levelsToAdd = interaction.options.getInteger('levels'); + const targetUser = interaction.options.getUser('user')!; + const levelsToAdd = interaction.options.getInteger('levels')!; - const member = await interaction.guild.members.fetch(targetUser.id).catch(() => null); + const member = await interaction.guild!.members.fetch(targetUser.id).catch(() => null); if (!member) { throw new NexoBotError( `User ${targetUser.id} not found in this guild`, @@ -62,7 +63,7 @@ export default { ); } - const userData = await addLevels(client, interaction.guildId, targetUser.id, levelsToAdd); + const userData = await addLevels(client, interaction.guildId!, targetUser.id, levelsToAdd); await InteractionHelper.safeEditReply(interaction, { embeds: [ @@ -78,4 +79,4 @@ export default { `[ADMIN] User ${interaction.user.tag} added ${levelsToAdd} levels to ${targetUser.tag} in guild ${interaction.guildId}` ); } -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Leveling/levelremove.js b/src/commands/Leveling/levelremove.ts similarity index 90% rename from src/commands/Leveling/levelremove.js rename to src/commands/Leveling/levelremove.ts index 28ee38f43f..aff384e4df 100644 --- a/src/commands/Leveling/levelremove.js +++ b/src/commands/Leveling/levelremove.ts @@ -6,6 +6,7 @@ import { removeLevels, getUserLevelData, getLevelingConfig } from '../../service import { createEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import type { Command } from '../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('levelremove') @@ -37,7 +38,7 @@ export default { ); if (!hasPermission) return; - const levelingConfig = await getLevelingConfig(client, interaction.guildId); + const levelingConfig = await getLevelingConfig(client, interaction.guildId!); if (!levelingConfig?.enabled) { await InteractionHelper.safeEditReply(interaction, { embeds: [ @@ -50,10 +51,10 @@ export default { return; } - const targetUser = interaction.options.getUser('user'); - const levelsToRemove = interaction.options.getInteger('levels'); + const targetUser = interaction.options.getUser('user')!; + const levelsToRemove = interaction.options.getInteger('levels')!; - const member = await interaction.guild.members.fetch(targetUser.id).catch(() => null); + const member = await interaction.guild!.members.fetch(targetUser.id).catch(() => null); if (!member) { throw new NexoBotError( `User ${targetUser.id} not found in this guild`, @@ -62,7 +63,7 @@ export default { ); } - const userData = await getUserLevelData(client, interaction.guildId, targetUser.id); + const userData = await getUserLevelData(client, interaction.guildId!, targetUser.id); if (userData.level === 0) { throw new NexoBotError( `User ${targetUser.id} is already at minimum level`, @@ -71,7 +72,7 @@ export default { ); } - const updatedData = await removeLevels(client, interaction.guildId, targetUser.id, levelsToRemove); + const updatedData = await removeLevels(client, interaction.guildId!, targetUser.id, levelsToRemove); await InteractionHelper.safeEditReply(interaction, { embeds: [ @@ -87,4 +88,4 @@ export default { `[ADMIN] User ${interaction.user.tag} removed ${levelsToRemove} levels from ${targetUser.tag} in guild ${interaction.guildId}` ); } -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Leveling/levelset.js b/src/commands/Leveling/levelset.ts similarity index 88% rename from src/commands/Leveling/levelset.js rename to src/commands/Leveling/levelset.ts index 9c5fa9c760..f1bde1af5a 100644 --- a/src/commands/Leveling/levelset.js +++ b/src/commands/Leveling/levelset.ts @@ -6,6 +6,7 @@ import { setUserLevel, getLevelingConfig } from '../../services/leveling/levelin import { createEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import type { Command } from '../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('levelset') @@ -37,7 +38,7 @@ export default { ); if (!hasPermission) return; - const levelingConfig = await getLevelingConfig(client, interaction.guildId); + const levelingConfig = await getLevelingConfig(client, interaction.guildId!); if (!levelingConfig?.enabled) { await InteractionHelper.safeEditReply(interaction, { embeds: [ @@ -50,10 +51,10 @@ export default { return; } - const targetUser = interaction.options.getUser('user'); - const newLevel = interaction.options.getInteger('level'); + const targetUser = interaction.options.getUser('user')!; + const newLevel = interaction.options.getInteger('level')!; - const member = await interaction.guild.members.fetch(targetUser.id).catch(() => null); + const member = await interaction.guild!.members.fetch(targetUser.id).catch(() => null); if (!member) { throw new NexoBotError( `User ${targetUser.id} not found in this guild`, @@ -62,7 +63,7 @@ export default { ); } - const userData = await setUserLevel(client, interaction.guildId, targetUser.id, newLevel); + const userData = await setUserLevel(client, interaction.guildId!, targetUser.id, newLevel); await InteractionHelper.safeEditReply(interaction, { embeds: [ @@ -78,4 +79,4 @@ export default { `[ADMIN] User ${interaction.user.tag} set ${targetUser.tag}'s level to ${newLevel} in guild ${interaction.guildId}` ); } -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Leveling/modules/level_dashboard.js b/src/commands/Leveling/modules/level_dashboard.ts similarity index 87% rename from src/commands/Leveling/modules/level_dashboard.js rename to src/commands/Leveling/modules/level_dashboard.ts index ac8b89c478..99cea0fd6b 100644 --- a/src/commands/Leveling/modules/level_dashboard.js +++ b/src/commands/Leveling/modules/level_dashboard.ts @@ -16,6 +16,7 @@ import { ComponentType, EmbedBuilder, } from 'discord.js'; +import type { ChatInputCommandInteraction, Guild, SelectMenuModalData, StringSelectMenuInteraction } from 'discord.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; import { successEmbed } from '../../../utils/embeds.js'; import { logger } from '../../../utils/logger.js'; @@ -23,8 +24,10 @@ import { NexoBotError, ErrorTypes, replyUserError } from '../../../utils/errorHa import { getLevelingConfig, saveLevelingConfig } from '../../../services/leveling/leveling.js'; import { botHasPermission } from '../../../utils/permissionGuard.js'; import { startDashboardSession } from '../../../utils/dashboardSession.js'; +import type { GuildLevelingConfig } from '../../../services/leveling/leveling.js'; +import type { NexoClient, SubcommandModule } from '../../../types/index.js'; -function buildDashboardEmbed(cfg, guild) { +function buildDashboardEmbed(cfg: GuildLevelingConfig, guild: Guild) { const channel = cfg.levelUpChannel ? `<#${cfg.levelUpChannel}>` : '`Not set`'; const xpMin = cfg.xpRange?.min ?? cfg.xpPerMessage?.min ?? 15; const xpMax = cfg.xpRange?.max ?? cfg.xpPerMessage?.max ?? 25; @@ -63,7 +66,7 @@ function buildDashboardEmbed(cfg, guild) { .setTimestamp(); } -function buildSelectMenu(guildId) { +function buildSelectMenu(guildId: string) { return new StringSelectMenuBuilder() .setCustomId(`level_cfg_${guildId}`) .setPlaceholder('Select a setting to configure...') @@ -111,10 +114,10 @@ function buildSelectMenu(guildId) { ); } -function buildButtonRow(cfg, guildId, disabled = false) { +function buildButtonRow(cfg: GuildLevelingConfig, guildId: string, disabled = false) { const announceOn = cfg.announceLevelUp !== false; const systemOn = cfg.enabled !== false; - return new ActionRowBuilder().addComponents( + return new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`level_cfg_toggle_announce_${guildId}`) .setLabel('Announcements') @@ -130,13 +133,13 @@ function buildButtonRow(cfg, guildId, disabled = false) { ); } -async function refreshDashboard(rootInteraction, cfg, guildId) { +async function refreshDashboard(rootInteraction: ChatInputCommandInteraction, cfg: GuildLevelingConfig, guildId: string) { const selectMenu = buildSelectMenu(guildId); await InteractionHelper.safeEditReply(rootInteraction, { - embeds: [buildDashboardEmbed(cfg, rootInteraction.guild)], + embeds: [buildDashboardEmbed(cfg, rootInteraction.guild!)], components: [ buildButtonRow(cfg, guildId), - new ActionRowBuilder().addComponents(selectMenu), + new ActionRowBuilder().addComponents(selectMenu), ], }).catch(() => {}); } @@ -145,7 +148,7 @@ export default { prefixOnly: false, async execute(interaction, config, client) { try { - const guildId = interaction.guild.id; + const guildId = interaction.guild!.id; const cfg = await getLevelingConfig(client, guildId); if (!cfg.configured) { @@ -158,10 +161,10 @@ export default { await startDashboardSession({ interaction, - embeds: [buildDashboardEmbed(cfg, interaction.guild)], + embeds: [buildDashboardEmbed(cfg, interaction.guild!)], components: [ buildButtonRow(cfg, guildId), - new ActionRowBuilder().addComponents(buildSelectMenu(guildId)), + new ActionRowBuilder().addComponents(buildSelectMenu(guildId)), ], selectMenuId: `level_cfg_${guildId}`, buttonMatcher: (customId) => @@ -234,15 +237,15 @@ export default { if (error instanceof NexoBotError) throw error; logger.error('Unexpected error in level_dashboard:', error); throw new NexoBotError( - `Level dashboard failed: ${error.message}`, + `Level dashboard failed: ${error instanceof Error ? error.message : String(error)}`, ErrorTypes.UNKNOWN, 'Failed to open the leveling dashboard.', ); } }, -}; +} satisfies SubcommandModule & { prefixOnly: boolean }; -async function handleRoleRewardAdd(selectInteraction, rootInteraction, cfg, guildId, client) { +async function handleRoleRewardAdd(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, cfg: GuildLevelingConfig, guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId(`level_cfg_role_reward_add_${guildId}`) .setTitle('🏆 Add Role Reward'); @@ -269,7 +272,7 @@ async function handleRoleRewardAdd(selectInteraction, rootInteraction, cfg, guil .setRequired(true); modal.addLabelComponents(roleLabel); - modal.addComponents(new ActionRowBuilder().addComponents(levelInput)); + modal.addComponents(new ActionRowBuilder().addComponents(levelInput)); await selectInteraction.showModal(modal); @@ -290,7 +293,7 @@ async function handleRoleRewardAdd(selectInteraction, rootInteraction, cfg, guil return; } - const roleId = submitted.fields.getField('reward_role').values[0]; + const roleId = (submitted.fields.getField('reward_role') as SelectMenuModalData).values[0]; cfg.roleRewards = cfg.roleRewards ?? {}; cfg.roleRewards[level] = roleId; @@ -304,7 +307,7 @@ async function handleRoleRewardAdd(selectInteraction, rootInteraction, cfg, guil await refreshDashboard(rootInteraction, cfg, guildId); } -async function handleRoleRewardRemove(selectInteraction, rootInteraction, cfg, guildId, client) { +async function handleRoleRewardRemove(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, cfg: GuildLevelingConfig, guildId: string, client: NexoClient) { const rewards = cfg.roleRewards ?? {}; const entries = Object.entries(rewards).sort(([a], [b]) => Number(a) - Number(b)); @@ -338,8 +341,8 @@ async function handleRoleRewardRemove(selectInteraction, rootInteraction, cfg, g .setRequired(true); modal.addComponents( - new ActionRowBuilder().addComponents(infoInput), - new ActionRowBuilder().addComponents(levelInput), + new ActionRowBuilder().addComponents(infoInput), + new ActionRowBuilder().addComponents(levelInput), ); await selectInteraction.showModal(modal); @@ -372,7 +375,7 @@ async function handleRoleRewardRemove(selectInteraction, rootInteraction, cfg, g await refreshDashboard(rootInteraction, cfg, guildId); } -async function handleChannel(selectInteraction, rootInteraction, cfg, guildId, client) { +async function handleChannel(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, cfg: GuildLevelingConfig, guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId(`level_cfg_channel_modal_${guildId}`) .setTitle('\ud83d\udce2 Change Level-up Channel'); @@ -403,8 +406,8 @@ async function handleChannel(selectInteraction, rootInteraction, cfg, guildId, c if (!submitted) return; - const channelId = submitted.fields.getField('levelup_channel').values[0]; - const channel = selectInteraction.guild.channels.cache.get(channelId); + const channelId = (submitted.fields.getField('levelup_channel') as SelectMenuModalData).values[0]; + 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.` }); @@ -422,7 +425,7 @@ async function handleChannel(selectInteraction, rootInteraction, cfg, guildId, c await refreshDashboard(rootInteraction, cfg, guildId); } -async function handleIgnoreChannels(selectInteraction, rootInteraction, cfg, guildId, client) { +async function handleIgnoreChannels(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, cfg: GuildLevelingConfig, guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId(`level_cfg_ignore_channels_${guildId}`) .setTitle('\ud83d\udeab Ignored Channels'); @@ -453,7 +456,7 @@ async function handleIgnoreChannels(selectInteraction, rootInteraction, cfg, gui if (!submitted) return; - const selectedIds = submitted.fields.getField('ignore_channel').values; + const selectedIds = (submitted.fields.getField('ignore_channel') as SelectMenuModalData).values; const ignoreSet = new Set(cfg.ignoredChannels ?? []); for (const id of selectedIds) { @@ -479,7 +482,7 @@ async function handleIgnoreChannels(selectInteraction, rootInteraction, cfg, gui await refreshDashboard(rootInteraction, cfg, guildId); } -async function handleIgnoreRoles(selectInteraction, rootInteraction, cfg, guildId, client) { +async function handleIgnoreRoles(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, cfg: GuildLevelingConfig, guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId(`level_cfg_ignore_roles_${guildId}`) .setTitle('\ud83d\udeab Ignored Roles'); @@ -509,7 +512,7 @@ async function handleIgnoreRoles(selectInteraction, rootInteraction, cfg, guildI if (!submitted) return; - const selectedIds = submitted.fields.getField('ignore_role').values; + const selectedIds = (submitted.fields.getField('ignore_role') as SelectMenuModalData).values; const ignoreSet = new Set(cfg.ignoredRoles ?? []); for (const id of selectedIds) { @@ -535,12 +538,12 @@ async function handleIgnoreRoles(selectInteraction, rootInteraction, cfg, guildI await refreshDashboard(rootInteraction, cfg, guildId); } -async function handleMessage(selectInteraction, rootInteraction, cfg, guildId, client) { +async function handleMessage(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, cfg: GuildLevelingConfig, guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId('level_cfg_message') .setTitle('💬 Edit Level-up Message') .addComponents( - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('message_input') .setLabel('Message ({user} and {level} are available)') @@ -591,7 +594,7 @@ async function handleMessage(selectInteraction, rootInteraction, cfg, guildId, c await refreshDashboard(rootInteraction, cfg, guildId); } -async function handleXpRange(selectInteraction, rootInteraction, cfg, guildId, client) { +async function handleXpRange(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, cfg: GuildLevelingConfig, guildId: string, client: NexoClient) { const currentMin = cfg.xpRange?.min ?? cfg.xpPerMessage?.min ?? 15; const currentMax = cfg.xpRange?.max ?? cfg.xpPerMessage?.max ?? 25; @@ -599,7 +602,7 @@ async function handleXpRange(selectInteraction, rootInteraction, cfg, guildId, c .setCustomId('level_cfg_xp_range') .setTitle('Set XP Range per Message') .addComponents( - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('xp_min_input') .setLabel('Minimum XP (1–500)') @@ -610,7 +613,7 @@ async function handleXpRange(selectInteraction, rootInteraction, cfg, guildId, c .setRequired(true) .setPlaceholder('15'), ), - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('xp_max_input') .setLabel('Maximum XP (1–500)') @@ -666,12 +669,12 @@ async function handleXpRange(selectInteraction, rootInteraction, cfg, guildId, c await refreshDashboard(rootInteraction, cfg, guildId); } -async function handleXpCooldown(selectInteraction, rootInteraction, cfg, guildId, client) { +async function handleXpCooldown(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, cfg: GuildLevelingConfig, guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId('level_cfg_cooldown') .setTitle('⏱️ Set XP Cooldown') .addComponents( - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('cooldown_input') .setLabel('Cooldown in seconds (0–3600)') diff --git a/src/commands/Leveling/rank.js b/src/commands/Leveling/rank.ts similarity index 91% rename from src/commands/Leveling/rank.js rename to src/commands/Leveling/rank.ts index ea468d066b..67e997462e 100644 --- a/src/commands/Leveling/rank.js +++ b/src/commands/Leveling/rank.ts @@ -4,6 +4,7 @@ import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { getUserLevelData, getLevelingConfig, getXpForLevel } from '../../services/leveling/leveling.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import type { Command } from '../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('rank') @@ -20,7 +21,7 @@ export default { async execute(interaction, config, client) { 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: [ @@ -34,7 +35,7 @@ export default { } const targetUser = interaction.options.getUser('user') || interaction.user; - const member = await interaction.guild.members + const member = await interaction.guild!.members .fetch(targetUser.id) .catch(() => null); @@ -46,7 +47,7 @@ export default { ); } - const userData = await getUserLevelData(client, interaction.guildId, targetUser.id); + const userData = await getUserLevelData(client, interaction.guildId!, targetUser.id); const safeUserData = { level: userData?.level ?? 0, @@ -60,7 +61,7 @@ export default { const embed = new EmbedBuilder() .setTitle(`${member.displayName}'s Rank`) - .setThumbnail(member.displayAvatarURL({ dynamic: true })) + .setThumbnail(member.displayAvatarURL()) .addFields( { name: 'Level', @@ -88,9 +89,9 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); logger.debug(`Rank checked for user ${targetUser.id} in guild ${interaction.guildId}`); } -}; +} satisfies Command; -function createProgressBar(percentage, length = 10) { +function createProgressBar(percentage: number, length = 10): string { if (percentage < 0 || percentage > 100) { percentage = Math.max(0, Math.min(100, percentage)); } diff --git a/src/commands/Logging/logging.js b/src/commands/Logging/logging.ts similarity index 97% rename from src/commands/Logging/logging.js rename to src/commands/Logging/logging.ts index 0d94386314..a7ed74e171 100644 --- a/src/commands/Logging/logging.js +++ b/src/commands/Logging/logging.ts @@ -1,3 +1,4 @@ +import type { Command } from '../../types/index.js'; import { SlashCommandBuilder, PermissionFlagsBits, ChannelType } from 'discord.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; @@ -65,4 +66,4 @@ export default { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An unexpected error occurred.' }).catch(() => {}); } }, -}; +} satisfies Command; diff --git a/src/commands/Logging/modules/logging_channel.js b/src/commands/Logging/modules/logging_channel.ts similarity index 76% rename from src/commands/Logging/modules/logging_channel.js rename to src/commands/Logging/modules/logging_channel.ts index f92e6a6d3b..3d1bf93812 100644 --- a/src/commands/Logging/modules/logging_channel.js +++ b/src/commands/Logging/modules/logging_channel.ts @@ -1,11 +1,12 @@ -import { PermissionsBitField, ChannelType } from 'discord.js'; +import type { Command, SubcommandModule } from '../../../types/index.js'; +import { PermissionsBitField, ChannelType, type GuildMember, type TextChannel } from 'discord.js'; import { setLogChannel } from '../../../services/loggingService.js'; 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 = { +const DESTINATION_LABELS: Record = { audit: 'Audit Log', applications: 'Applications', reports: 'Reports', @@ -15,18 +16,18 @@ export default { prefixOnly: false, async execute(interaction, config, client) { try { - if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { + if (!(interaction.member as GuildMember).permissions.has(PermissionsBitField.Flags.ManageGuild)) { return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need **Manage Server** permissions to configure logging channels.' }); } await InteractionHelper.safeDefer(interaction, { ephemeral: true }); - const destination = interaction.options.getString('destination'); + const destination = interaction.options.getString('destination')!; const channel = interaction.options.getChannel('channel'); const disable = interaction.options.getBoolean('disable') ?? false; if (disable) { - await setLogChannel(client, interaction.guildId, destination, null); + await setLogChannel(client, interaction.guildId!, destination, null); return InteractionHelper.safeEditReply(interaction, { embeds: [successEmbed( 'Channel Cleared', @@ -39,12 +40,12 @@ export default { return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please provide a valid text channel.' }); } - const botPerms = channel.permissionsFor(interaction.guild.members.me); + const botPerms = (channel as TextChannel).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}.` }); } - await setLogChannel(client, interaction.guildId, destination, channel.id); + await setLogChannel(client, interaction.guildId!, destination, channel.id); return InteractionHelper.safeEditReply(interaction, { embeds: [successEmbed( @@ -57,4 +58,4 @@ export default { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Failed to update the log channel.' }); } }, -}; +} satisfies SubcommandModule & Pick; diff --git a/src/commands/Logging/modules/logging_dashboard.js b/src/commands/Logging/modules/logging_dashboard.ts similarity index 77% rename from src/commands/Logging/modules/logging_dashboard.js rename to src/commands/Logging/modules/logging_dashboard.ts index f317698ccf..037de4dafc 100644 --- a/src/commands/Logging/modules/logging_dashboard.js +++ b/src/commands/Logging/modules/logging_dashboard.ts @@ -1,4 +1,5 @@ -import { EmbedBuilder, MessageFlags, PermissionsBitField } from 'discord.js'; +import type { Command, NexoClient, SubcommandModule } from '../../../types/index.js'; +import { EmbedBuilder, MessageFlags, PermissionsBitField, type BaseInteraction, type Guild, type GuildMember, type MessageComponentInteraction, type ModalSubmitInteraction } from 'discord.js'; import { getColor } from '../../../config/bot.js'; import { getGuildConfig } from '../../../services/config/guildConfig.js'; import { getLoggingStatus } from '../../../services/loggingService.js'; @@ -9,12 +10,17 @@ import { DASHBOARD_CATEGORIES, DASHBOARD_CATEGORY_LABELS, EVENT_TYPES_BY_CATEGORY, + type EnabledEventsMap, } 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) { + +/** Interactions whose source message is the dashboard (buttons, selects, modals opened from it). */ +type DashboardMessageInteraction = MessageComponentInteraction | ModalSubmitInteraction; + +export function getCategoryStatus(enabledEvents: EnabledEventsMap | null | undefined, category: string, auditEnabled: boolean): boolean { if (!auditEnabled) return false; const events = enabledEvents || {}; if (events[`${category}.*`] === false) return false; @@ -23,38 +29,38 @@ export function getCategoryStatus(enabledEvents, category, auditEnabled) { return categoryEvents.every((eventType) => events[eventType] !== false); } -async function formatChannelMention(guild, id) { +async function formatChannelMention(guild: Guild, id: string | null | undefined): Promise { if (!id) return '`Not configured`'; const channel = guild.channels.cache.get(id) ?? await guild.channels.fetch(id).catch(() => null); return channel ? channel.toString() : `⚠️ Missing (${id})`; } -function countEnabledCategories(enabledEvents, auditEnabled) { +function countEnabledCategories(enabledEvents: EnabledEventsMap | null | undefined, auditEnabled: boolean) { const enabled = DASHBOARD_CATEGORIES.filter((key) => getCategoryStatus(enabledEvents, key, auditEnabled), ).length; return { enabled, total: DASHBOARD_CATEGORIES.length }; } -export async function buildLoggingDashboardView(interaction, client) { - const guildConfig = await getGuildConfig(client, interaction.guildId); - const loggingStatus = await getLoggingStatus(client, interaction.guildId); +export async function buildLoggingDashboardView(interaction: BaseInteraction, client: NexoClient) { + const guildConfig = await getGuildConfig(client, interaction.guildId!); + const loggingStatus = await getLoggingStatus(client, interaction.guildId!); const auditEnabled = Boolean(loggingStatus.enabled); const channels = loggingStatus.channels || {}; - const auditChannel = await formatChannelMention(interaction.guild, channels.audit); - const applicationsChannel = await formatChannelMention(interaction.guild, channels.applications); - const reportsChannel = await formatChannelMention(interaction.guild, channels.reports); - const lifecycleChannel = await formatChannelMention(interaction.guild, guildConfig.ticketLogsChannelId); - const transcriptChannel = await formatChannelMention(interaction.guild, guildConfig.ticketTranscriptChannelId); + const auditChannel = await formatChannelMention(interaction.guild!, channels.audit); + const applicationsChannel = await formatChannelMention(interaction.guild!, channels.applications); + const reportsChannel = await formatChannelMention(interaction.guild!, channels.reports); + const lifecycleChannel = await formatChannelMention(interaction.guild!, guildConfig.ticketLogsChannelId); + const transcriptChannel = await formatChannelMention(interaction.guild!, guildConfig.ticketTranscriptChannelId); const ignore = loggingStatus.ignore || { users: [], channels: [] }; const { enabled: enabledCount, total } = countEnabledCategories(loggingStatus.enabledEvents, auditEnabled); const embed = new EmbedBuilder() .setTitle('📝 Logging Dashboard') - .setDescription(`Manage server logging for **${interaction.guild.name}**. Use the menu below to configure channels, categories, and filters.`) + .setDescription(`Manage server logging for **${interaction.guild!.name}**. Use the menu below to configure channels, categories, and filters.`) .setColor(auditEnabled ? getColor('success') : getColor('warning')) .addFields( { @@ -97,8 +103,8 @@ export async function buildLoggingDashboardView(interaction, client) { return { embed, components }; } -export async function buildLoggingCategoriesView(interaction, client) { - const loggingStatus = await getLoggingStatus(client, interaction.guildId); +export async function buildLoggingCategoriesView(interaction: BaseInteraction, client: NexoClient) { + const loggingStatus = await getLoggingStatus(client, interaction.guildId!); const auditEnabled = Boolean(loggingStatus.enabled); const categoryLines = DASHBOARD_CATEGORIES.map((key) => { @@ -123,8 +129,8 @@ export async function buildLoggingCategoriesView(interaction, client) { return { embed, components }; } -export async function buildLoggingFilterView(interaction, client) { - const loggingStatus = await getLoggingStatus(client, interaction.guildId); +export async function buildLoggingFilterView(interaction: BaseInteraction, client: NexoClient) { + const loggingStatus = await getLoggingStatus(client, interaction.guildId!); const ignore = loggingStatus.ignore || { users: [], channels: [] }; const userLines = (ignore.users || []).length @@ -150,15 +156,15 @@ export async function buildLoggingFilterView(interaction, client) { return { embed, components }; } -export function isCategoriesView(interaction) { +export function isCategoriesView(interaction: DashboardMessageInteraction): boolean { return interaction.message?.embeds?.[0]?.title === '📋 Event Categories'; } -export function isFilterView(interaction) { +export function isFilterView(interaction: DashboardMessageInteraction): boolean { return interaction.message?.embeds?.[0]?.title === '🔇 Log Ignore Filters'; } -export async function refreshDashboardMessage(interaction, client) { +export async function refreshDashboardMessage(interaction: DashboardMessageInteraction, client: NexoClient): Promise { let view; if (isCategoriesView(interaction)) { view = await buildLoggingCategoriesView(interaction, client); @@ -168,7 +174,7 @@ export async function refreshDashboardMessage(interaction, client) { view = await buildLoggingDashboardView(interaction, client); } - await interaction.message.edit({ + await interaction.message!.edit({ embeds: [view.embed], components: view.components, content: null, @@ -179,7 +185,7 @@ export default { prefixOnly: false, async execute(interaction, config, client) { try { - if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { + if (!(interaction.member as GuildMember).permissions.has(PermissionsBitField.Flags.ManageGuild)) { return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need **Manage Server** permissions to view the logging dashboard.' }); } @@ -191,4 +197,4 @@ export default { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Failed to load the logging dashboard.' }); } }, -}; +} satisfies SubcommandModule & Pick; diff --git a/src/commands/Moderation/ban.js b/src/commands/Moderation/ban.ts similarity index 89% rename from src/commands/Moderation/ban.js rename to src/commands/Moderation/ban.ts index ea802720f2..a099ec399d 100644 --- a/src/commands/Moderation/ban.js +++ b/src/commands/Moderation/ban.ts @@ -1,4 +1,5 @@ -import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import type { Command } from '../../types/index.js'; +import { SlashCommandBuilder, PermissionFlagsBits, type GuildMember } from 'discord.js'; import { successEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { ModerationService } from '../../services/moderation/moderationService.js'; @@ -49,9 +50,9 @@ export default { } const result = await ModerationService.banUser({ - guild: interaction.guild, + guild: interaction.guild!, user, - moderator: interaction.member, + moderator: interaction.member as GuildMember, reason, }); @@ -64,4 +65,4 @@ export default { ], }); }, -}; +} satisfies Command; diff --git a/src/commands/Moderation/cases.js b/src/commands/Moderation/cases.ts similarity index 95% rename from src/commands/Moderation/cases.js rename to src/commands/Moderation/cases.ts index 5999cd834d..08e0c6f560 100644 --- a/src/commands/Moderation/cases.js +++ b/src/commands/Moderation/cases.ts @@ -1,3 +1,4 @@ +import type { Command } from '../../types/index.js'; import { SlashCommandBuilder, PermissionFlagsBits, ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, MessageFlags } from 'discord.js'; import { createEmbed, successEmbed } from '../../utils/embeds.js'; import { getModerationCases } from '../../utils/moderation.js'; @@ -56,7 +57,7 @@ export default { userId: targetUser?.id }; - const cases = await getModerationCases(interaction.guild.id, filters); + const cases = await getModerationCases(interaction.guild!.id, filters); if (cases.length === 0) { throw new Error(targetUser @@ -69,14 +70,14 @@ export default { const totalPages = Math.ceil(cases.length / CASES_PER_PAGE); let currentPage = 1; - const createCasesEmbed = (page) => { + const createCasesEmbed = (page: number) => { const startIndex = (page - 1) * CASES_PER_PAGE; const endIndex = startIndex + CASES_PER_PAGE; const pageCases = cases.slice(startIndex, endIndex); const embed = createEmbed({ title: 'Moderation Cases', - description: `Showing moderation cases for **${interaction.guild.name}**\n\n**Page ${page} of ${totalPages}**` + description: `Showing moderation cases for **${interaction.guild!.name}**\n\n**Page ${page} of ${totalPages}**` }); pageCases.forEach(case_ => { @@ -97,8 +98,8 @@ export default { return embed; }; - const createNavigationRow = (page) => { - const row = new ActionRowBuilder(); + const createNavigationRow = (page: number) => { + const row = new ActionRowBuilder(); const prevButton = new ButtonBuilder() .setCustomId('prev_page') @@ -174,4 +175,4 @@ time: 120000 return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while retrieving moderation cases. Please try again later.' }); } } -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Moderation/dm.js b/src/commands/Moderation/dm.ts similarity index 92% rename from src/commands/Moderation/dm.js rename to src/commands/Moderation/dm.ts index da9714f18a..ab68716512 100644 --- a/src/commands/Moderation/dm.js +++ b/src/commands/Moderation/dm.ts @@ -1,3 +1,4 @@ +import type { Command } from '../../types/index.js'; import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, MessageFlags } from 'discord.js'; import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { logEvent } from '../../utils/moderation.js'; @@ -5,7 +6,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'; +import { replyUserError, ErrorTypes, type ErrorLike } from '../../utils/errorHandler.js'; export default { data: new SlashCommandBuilder() .setName("dm") @@ -43,8 +44,8 @@ export default { return; } - const targetUser = interaction.options.getUser("user"); - const message = interaction.options.getString("message"); + const targetUser = interaction.options.getUser("user")!; + const message = interaction.options.getString("message")!; const anonymous = interaction.options.getBoolean("anonymous") || false; try { @@ -100,11 +101,11 @@ export default { } catch (error) { logger.error('DM command error:', error); -if (error.code === 50007) { +if ((error as ErrorLike).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: `Failed to send DM: ${error.message}` }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `Failed to send DM: ${(error as ErrorLike).message}` }); } } -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Moderation/kick.js b/src/commands/Moderation/kick.ts similarity index 89% rename from src/commands/Moderation/kick.js rename to src/commands/Moderation/kick.ts index 40bf825395..c9fb2faf53 100644 --- a/src/commands/Moderation/kick.js +++ b/src/commands/Moderation/kick.ts @@ -1,4 +1,5 @@ -import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import type { Command } from '../../types/index.js'; +import { SlashCommandBuilder, PermissionFlagsBits, type GuildMember } from 'discord.js'; import { successEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { ModerationService } from '../../services/moderation/moderationService.js'; @@ -60,9 +61,9 @@ export default { } const result = await ModerationService.kickUser({ - guild: interaction.guild, - member, - moderator: interaction.member, + guild: interaction.guild!, + member: member as GuildMember, + moderator: interaction.member as GuildMember, reason, }); @@ -75,4 +76,4 @@ export default { ], }); }, -}; +} satisfies Command; diff --git a/src/commands/Moderation/lock.js b/src/commands/Moderation/lock.ts similarity index 89% rename from src/commands/Moderation/lock.js rename to src/commands/Moderation/lock.ts index 75b251973f..2a67bc418e 100644 --- a/src/commands/Moderation/lock.js +++ b/src/commands/Moderation/lock.ts @@ -1,4 +1,5 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; +import type { Command } from '../../types/index.js'; +import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, type TextChannel } from 'discord.js'; import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { logEvent } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; @@ -26,8 +27,8 @@ export default { return; } - const channel = interaction.channel; - const everyoneRole = interaction.guild.roles.everyone; + const channel = interaction.channel as TextChannel; + const everyoneRole = interaction.guild!.roles.everyone; try { const currentPermissions = channel.permissionsFor(everyoneRole); @@ -43,7 +44,7 @@ export default { await logEvent({ client, - guild: interaction.guild, + guild: interaction.guild!, event: { action: "Channel Locked", target: channel.toString(), @@ -69,4 +70,4 @@ export default { await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'An unexpected error occurred while trying to lock the channel. Check my permissions (I need \'Manage Channels\').' }); } } -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Moderation/massban.js b/src/commands/Moderation/massban.ts similarity index 87% rename from src/commands/Moderation/massban.js rename to src/commands/Moderation/massban.ts index b599e4c0c2..5e35f07506 100644 --- a/src/commands/Moderation/massban.js +++ b/src/commands/Moderation/massban.ts @@ -1,11 +1,18 @@ -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; +import type { Command } from '../../types/index.js'; +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, type GuildMember } from 'discord.js'; import { createEmbed, successEmbed, warningEmbed } from '../../utils/embeds.js'; import { logModerationAction } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; import { ModerationService } from '../../services/moderation/moderationService.js'; -import { NexoBotError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; +import { NexoBotError, replyUserError, ErrorTypes, type ErrorLike } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +interface MassActionResults { + successful: { user: string; userId: string }[]; + failed: { userId: string; reason: string }[]; + skipped: { user: string; userId: string; reason: string }[]; +} + export default { data: new SlashCommandBuilder() .setName("massban") @@ -44,7 +51,7 @@ export default { return; } - const usersInput = interaction.options.getString("users"); + const usersInput = interaction.options.getString("users")!; const reason = interaction.options.getString("reason") || "Mass ban - No reason provided"; const deleteDays = interaction.options.getInteger("delete_days") || 0; @@ -67,7 +74,7 @@ export default { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You cannot include the bot in a mass ban.' }); } - const results = { + const results: MassActionResults = { successful: [], failed: [], skipped: [] @@ -82,15 +89,15 @@ export default { continue; } - const member = await interaction.guild.members.fetch(userId).catch(() => null); + const member = await interaction.guild!.members.fetch(userId).catch(() => null); if (member) { - const modCheck = ModerationService.validateHierarchy(interaction.member, member, 'ban'); + const modCheck = ModerationService.validateHierarchy(interaction.member as GuildMember, member, 'ban'); if (!modCheck.valid) { results.skipped.push({ user: user.tag, userId, - reason: ModerationService.buildHierarchySkipReason(interaction.member, member, 'ban'), + reason: ModerationService.buildHierarchySkipReason(interaction.member as GuildMember, member, 'ban'), }); continue; } @@ -100,13 +107,13 @@ export default { results.skipped.push({ user: user.tag, userId, - reason: ModerationService.buildHierarchySkipReason(interaction.member, member, 'ban', 'bot'), + reason: ModerationService.buildHierarchySkipReason(interaction.member as GuildMember, member, 'ban', 'bot'), }); continue; } } - await interaction.guild.members.ban(userId, { + await interaction.guild!.members.ban(userId, { reason: reason, deleteMessageSeconds: deleteDays * 24 * 60 * 60 }); @@ -118,7 +125,7 @@ export default { await logModerationAction({ client, - guild: interaction.guild, + guild: interaction.guild!, event: { action: "Member Banned", target: `${user.tag} (${user.id})`, @@ -137,7 +144,7 @@ export default { logger.error(`Failed to ban user ${userId}:`, error); const reason = error instanceof NexoBotError ? (error.userMessage || error.message) - : (error.message || "Unknown error"); + : ((error as ErrorLike).message || "Unknown error"); results.failed.push({ userId, reason, @@ -186,4 +193,4 @@ export default { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while processing the mass ban. Please try again later.' }); } } -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Moderation/masskick.js b/src/commands/Moderation/masskick.ts similarity index 87% rename from src/commands/Moderation/masskick.js rename to src/commands/Moderation/masskick.ts index f6de30579d..d650715473 100644 --- a/src/commands/Moderation/masskick.js +++ b/src/commands/Moderation/masskick.ts @@ -1,11 +1,18 @@ -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; +import type { Command } from '../../types/index.js'; +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, type GuildMember } from 'discord.js'; import { createEmbed, successEmbed, warningEmbed } from '../../utils/embeds.js'; import { logModerationAction } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; import { ModerationService } from '../../services/moderation/moderationService.js'; -import { NexoBotError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; +import { NexoBotError, replyUserError, ErrorTypes, type ErrorLike } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +interface MassActionResults { + successful: { user: string; userId: string }[]; + failed: { userId: string; reason: string }[]; + skipped: { user: string; userId: string; reason: string }[]; +} + export default { data: new SlashCommandBuilder() .setName("masskick") @@ -36,7 +43,7 @@ export default { return; } - const usersInput = interaction.options.getString("users"); + const usersInput = interaction.options.getString("users")!; const reason = interaction.options.getString("reason") || "Mass kick - No reason provided"; try { @@ -58,7 +65,7 @@ export default { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You cannot include the bot in a mass kick.' }); } - const results = { + const results: MassActionResults = { successful: [], failed: [], skipped: [] @@ -66,19 +73,19 @@ export default { for (const userId of userIds) { try { - const member = await interaction.guild.members.fetch(userId).catch(() => null); + const member = await interaction.guild!.members.fetch(userId).catch(() => null); if (!member) { results.failed.push({ userId, reason: "User not in server" }); continue; } - const modCheck = ModerationService.validateHierarchy(interaction.member, member, 'kick'); + const modCheck = ModerationService.validateHierarchy(interaction.member as GuildMember, member, 'kick'); if (!modCheck.valid) { results.skipped.push({ user: member.user.tag, userId, - reason: ModerationService.buildHierarchySkipReason(interaction.member, member, 'kick'), + reason: ModerationService.buildHierarchySkipReason(interaction.member as GuildMember, member, 'kick'), }); continue; } @@ -88,7 +95,7 @@ export default { results.skipped.push({ user: member.user.tag, userId, - reason: ModerationService.buildHierarchySkipReason(interaction.member, member, 'kick', 'bot'), + reason: ModerationService.buildHierarchySkipReason(interaction.member as GuildMember, member, 'kick', 'bot'), }); continue; } @@ -111,7 +118,7 @@ export default { await logModerationAction({ client, - guild: interaction.guild, + guild: interaction.guild!, event: { action: "Member Kicked", target: `${member.user.tag} (${member.user.id})`, @@ -129,7 +136,7 @@ export default { logger.error(`Failed to kick user ${userId}:`, error); const reason = error instanceof NexoBotError ? (error.userMessage || error.message) - : (error.message || "Unknown error"); + : ((error as ErrorLike).message || "Unknown error"); results.failed.push({ userId, reason, @@ -178,4 +185,4 @@ export default { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while processing the mass kick. Please try again later.' }); } } -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Moderation/purge.js b/src/commands/Moderation/purge.ts similarity index 90% rename from src/commands/Moderation/purge.js rename to src/commands/Moderation/purge.ts index 7b279f2d4f..6085a21d68 100644 --- a/src/commands/Moderation/purge.js +++ b/src/commands/Moderation/purge.ts @@ -1,4 +1,5 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, MessageFlags } from 'discord.js'; +import type { Command } from '../../types/index.js'; +import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, MessageFlags, type GuildTextBasedChannel } from 'discord.js'; import { createEmbed, successEmbed } from '../../utils/embeds.js'; import { logEvent } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; @@ -33,8 +34,8 @@ export default { return; } - const amount = interaction.options.getInteger("amount"); - const channel = interaction.channel; + const amount = interaction.options.getInteger("amount")!; + const channel = interaction.channel as GuildTextBasedChannel; if (amount < 1 || amount > 100) return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please specify a number between 1 and 100.' }); @@ -81,4 +82,4 @@ export default { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An unexpected error occurred during message deletion. Note: Messages older than 14 days cannot be bulk deleted.' }); } } -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Moderation/say.js b/src/commands/Moderation/say.ts similarity index 86% rename from src/commands/Moderation/say.js rename to src/commands/Moderation/say.ts index 51d8dffbfa..61dae9ed62 100644 --- a/src/commands/Moderation/say.js +++ b/src/commands/Moderation/say.ts @@ -1,8 +1,13 @@ +import type { Command } from '../../types/index.js'; import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, MessageFlags, + type ChatInputCommandInteraction, + type GuildMember, + type NewsChannel, + type TextChannel, } from 'discord.js'; import { successEmbed } from '../../utils/embeds.js'; import { logEvent } from '../../utils/moderation.js'; @@ -11,22 +16,22 @@ import { InteractionHelper } from '../../utils/interactionHelper.js'; import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { sanitizeInput } from '../../utils/validation.js'; -const TEXT_CHANNEL_TYPES = [ +const TEXT_CHANNEL_TYPES: (ChannelType.GuildText | ChannelType.GuildAnnouncement)[] = [ ChannelType.GuildText, ChannelType.GuildAnnouncement, ]; -function resolveTargetChannel(interaction) { +function resolveTargetChannel(interaction: ChatInputCommandInteraction): TextChannel | NewsChannel | null { const selected = interaction.options.getChannel('channel'); if (selected) { - return selected; + return selected as TextChannel | NewsChannel; } - if (!interaction.channel || !TEXT_CHANNEL_TYPES.includes(interaction.channel.type)) { + if (!interaction.channel || !(TEXT_CHANNEL_TYPES as ChannelType[]).includes(interaction.channel.type)) { return null; } - return interaction.channel; + return interaction.channel as TextChannel | NewsChannel; } export default { @@ -83,8 +88,8 @@ export default { }); } - const memberPermissions = channel.permissionsFor(interaction.member); - const botPermissions = channel.permissionsFor(interaction.guild.members.me); + const memberPermissions = channel.permissionsFor(interaction.member as GuildMember); + const botPermissions = channel.permissionsFor(interaction.guild!.members.me!); if (!memberPermissions?.has(PermissionFlagsBits.SendMessages)) { return replyUserError(interaction, { @@ -131,4 +136,4 @@ export default { flags: MessageFlags.Ephemeral, }); }, -}; +} satisfies Command; diff --git a/src/commands/Moderation/timeout.js b/src/commands/Moderation/timeout.ts similarity index 91% rename from src/commands/Moderation/timeout.js rename to src/commands/Moderation/timeout.ts index cc974d39ee..7acca9d7d2 100644 --- a/src/commands/Moderation/timeout.js +++ b/src/commands/Moderation/timeout.ts @@ -1,4 +1,5 @@ -import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import type { Command } from '../../types/index.js'; +import { SlashCommandBuilder, PermissionFlagsBits, type GuildMember } from 'discord.js'; import { successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; @@ -86,11 +87,11 @@ export default { ); } - const durationMs = durationMinutes * 60 * 1000; + const durationMs = durationMinutes! * 60 * 1000; const result = await ModerationService.timeoutUser({ - guild: interaction.guild, - member, - moderator: interaction.member, + guild: interaction.guild!, + member: member as GuildMember, + moderator: interaction.member as GuildMember, durationMs, reason, }); @@ -108,4 +109,4 @@ export default { ], }); }, -}; +} satisfies Command; diff --git a/src/commands/Moderation/unban.js b/src/commands/Moderation/unban.ts similarity index 88% rename from src/commands/Moderation/unban.js rename to src/commands/Moderation/unban.ts index 484cdec02a..cc5d71602a 100644 --- a/src/commands/Moderation/unban.js +++ b/src/commands/Moderation/unban.ts @@ -1,4 +1,5 @@ -import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import type { Command } from '../../types/index.js'; +import { SlashCommandBuilder, PermissionFlagsBits, type GuildMember } from 'discord.js'; import { successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { ModerationService } from '../../services/moderation/moderationService.js'; @@ -35,7 +36,7 @@ export default { } const rawTarget = interaction.options.getString("target"); - const targetId = rawTarget.replace(/[<@!>]/g, '').trim(); + const targetId = rawTarget!.replace(/[<@!>]/g, '').trim(); if (!/^\d{17,20}$/.test(targetId)) { return replyUserError(interaction, { @@ -55,9 +56,9 @@ export default { const reason = interaction.options.getString("reason") || "No reason provided"; const result = await ModerationService.unbanUser({ - guild: interaction.guild, + guild: interaction.guild!, user: targetUser, - moderator: interaction.member, + moderator: interaction.member as GuildMember, reason, }); @@ -70,4 +71,4 @@ export default { ], }); }, -}; +} satisfies Command; diff --git a/src/commands/Moderation/unlock.js b/src/commands/Moderation/unlock.ts similarity index 90% rename from src/commands/Moderation/unlock.js rename to src/commands/Moderation/unlock.ts index 408c2b3b07..9c571b8a10 100644 --- a/src/commands/Moderation/unlock.js +++ b/src/commands/Moderation/unlock.ts @@ -1,4 +1,5 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; +import type { Command } from '../../types/index.js'; +import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, type TextChannel } from 'discord.js'; import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { logEvent } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; @@ -26,8 +27,8 @@ export default { return; } - const channel = interaction.channel; - const everyoneRole = interaction.guild.roles.everyone; + const channel = interaction.channel as TextChannel; + const everyoneRole = interaction.guild!.roles.everyone; try { const currentPermissions = channel.permissionsFor(everyoneRole); @@ -51,7 +52,7 @@ export default { await logEvent({ client, - guild: interaction.guild, + guild: interaction.guild!, event: { action: "Channel Unlocked", target: channel.toString(), @@ -76,4 +77,4 @@ export default { await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'An unexpected error occurred while trying to unlock the channel. Check my permissions (I need \'Manage Channels\').' }); } } -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Moderation/untimeout.js b/src/commands/Moderation/untimeout.ts similarity index 87% rename from src/commands/Moderation/untimeout.js rename to src/commands/Moderation/untimeout.ts index 744597c555..6cbd8ca8e9 100644 --- a/src/commands/Moderation/untimeout.js +++ b/src/commands/Moderation/untimeout.ts @@ -1,4 +1,5 @@ -import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import type { Command } from '../../types/index.js'; +import { SlashCommandBuilder, PermissionFlagsBits, type GuildMember } from 'discord.js'; import { successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { ModerationService } from '../../services/moderation/moderationService.js'; @@ -50,9 +51,9 @@ export default { } await ModerationService.removeTimeoutUser({ - guild: interaction.guild, - member, - moderator: interaction.member, + guild: interaction.guild!, + member: member as GuildMember, + moderator: interaction.member as GuildMember, }); await InteractionHelper.safeEditReply(interaction, { @@ -63,4 +64,4 @@ export default { ], }); }, -}; +} satisfies Command; diff --git a/src/commands/Moderation/usernotes.js b/src/commands/Moderation/usernotes.ts similarity index 86% rename from src/commands/Moderation/usernotes.js rename to src/commands/Moderation/usernotes.ts index 4fc581fec7..60f2a035a4 100644 --- a/src/commands/Moderation/usernotes.js +++ b/src/commands/Moderation/usernotes.ts @@ -1,4 +1,5 @@ -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; +import type { Command } from '../../types/index.js'; +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, type ChatInputCommandInteraction, type User } from 'discord.js'; import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { getFromDb, setInDb, deleteFromDb, getUserNotesKey, getUserNotesListKey } from '../../utils/database.js'; @@ -7,6 +8,15 @@ import { sanitizeInput } from '../../utils/validation.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; +export interface UserNote { + id: number; + content: string; + type: string; + author: string; + authorId: string; + timestamp: string; +} + export default { data: new SlashCommandBuilder() .setName("usernotes") @@ -86,28 +96,28 @@ export default { async execute(interaction, config, client) { const subcommand = interaction.options.getSubcommand(); const targetUser = interaction.options.getUser("target"); - const guildId = interaction.guild.id; + const guildId = interaction.guild!.id; if (subcommand !== "view" && subcommand !== "remove" && subcommand !== "clear" && subcommand !== "add") { return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please select a valid subcommand.' }); } - let notes = []; + let notes: UserNote[] = []; if (targetUser) { const notesKey = getUserNotesKey(guildId, targetUser.id); - notes = await getFromDb(notesKey, []); + notes = await getFromDb(notesKey, []); } try { switch (subcommand) { case "add": - return await handleAddNote(interaction, targetUser, notes, guildId); + return await handleAddNote(interaction, targetUser!, notes, guildId); case "view": - return await handleViewNotes(interaction, targetUser, notes); + return await handleViewNotes(interaction, targetUser!, notes); case "remove": - return await handleRemoveNote(interaction, targetUser, notes, guildId); + return await handleRemoveNote(interaction, targetUser!, notes, guildId); case "clear": - return await handleClearNotes(interaction, targetUser, notes, guildId); + return await handleClearNotes(interaction, targetUser!, notes, guildId); default: return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please select a valid subcommand.' }); } @@ -116,10 +126,10 @@ export default { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while processing your request. Please try again later.' }); } } -}; +} satisfies Command; -async function handleAddNote(interaction, targetUser, notes, guildId) { - let note = interaction.options.getString("note").trim(); +async function handleAddNote(interaction: ChatInputCommandInteraction, targetUser: User, notes: UserNote[], guildId: string) { + let note = interaction.options.getString("note")!.trim(); const type = interaction.options.getString("type") || "neutral"; if (note.length > 1000) { @@ -132,7 +142,7 @@ async function handleAddNote(interaction, targetUser, notes, guildId) { note = sanitizeInput(note); - const noteData = { + const noteData: UserNote = { id: Date.now(), content: note, type: type, @@ -161,7 +171,7 @@ async function handleAddNote(interaction, targetUser, notes, guildId) { }); } -async function handleViewNotes(interaction, targetUser, notes) { +async function handleViewNotes(interaction: ChatInputCommandInteraction, targetUser: User, notes: UserNote[]) { if (notes.length === 0) { return InteractionHelper.safeReply(interaction, { embeds: [ @@ -173,7 +183,7 @@ async function handleViewNotes(interaction, targetUser, notes) { }); } - const sortedNotes = [...notes].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); + const sortedNotes = [...notes].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); let description = `**Notes for ${targetUser.tag} (${targetUser.id}):**\n\n`; @@ -199,8 +209,8 @@ async function handleViewNotes(interaction, targetUser, notes) { }); } -async function handleRemoveNote(interaction, targetUser, notes, guildId) { - const index = interaction.options.getInteger("index") - 1; +async function handleRemoveNote(interaction: ChatInputCommandInteraction, targetUser: User, notes: UserNote[], guildId: string) { + 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}).` }); @@ -208,7 +218,7 @@ async function handleRemoveNote(interaction, targetUser, notes, guildId) { // 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 sortedNotes = [...notes].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); const removedNote = sortedNotes[index]; const originalIndex = notes.indexOf(removedNote); notes.splice(originalIndex, 1); @@ -230,7 +240,7 @@ async function handleRemoveNote(interaction, targetUser, notes, guildId) { }); } -async function handleClearNotes(interaction, targetUser, notes, guildId) { +async function handleClearNotes(interaction: ChatInputCommandInteraction, targetUser: User, notes: UserNote[], guildId: string) { const noteCount = notes.length; if (noteCount === 0) { @@ -259,8 +269,8 @@ async function handleClearNotes(interaction, targetUser, notes, guildId) { }); } -function getNoteTypeInfo(type) { - const types = { +function getNoteTypeInfo(type: string) { + const types: Record = { warning: { emoji: "⚠️", color: "#FF6B6B" }, positive: { emoji: "✅", color: "#51CF66" }, neutral: { emoji: "📝", color: "#74C0FC" }, diff --git a/src/commands/Moderation/warn.js b/src/commands/Moderation/warn.ts similarity index 92% rename from src/commands/Moderation/warn.js rename to src/commands/Moderation/warn.ts index 349b10bae4..17ed8a1eab 100644 --- a/src/commands/Moderation/warn.js +++ b/src/commands/Moderation/warn.ts @@ -1,4 +1,5 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, MessageFlags } from 'discord.js'; +import type { Command } from '../../types/index.js'; +import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, MessageFlags, type GuildMember } 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'; @@ -40,7 +41,7 @@ export default { const member = interaction.options.getMember("target"); const reason = interaction.options.getString("reason"); const moderator = interaction.user; - const guildId = interaction.guildId; + const guildId = interaction.guildId!; if (!target) { throw new NexoBotError( @@ -68,7 +69,7 @@ export default { ); } - ModerationService.assertModerationHierarchy(interaction.member, member, 'warn'); + ModerationService.assertModerationHierarchy(interaction.member as GuildMember, member as GuildMember, 'warn'); const { id, totalCount } = await WarningService.addWarning({ guildId, @@ -80,7 +81,7 @@ export default { await logModerationAction({ client, - guild: interaction.guild, + guild: interaction.guild!, event: { action: "User Warned", target: `${target.tag} (${target.id})`, @@ -105,4 +106,4 @@ export default { ], }); } -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Moderation/warnings.js b/src/commands/Moderation/warnings.ts similarity index 93% rename from src/commands/Moderation/warnings.js rename to src/commands/Moderation/warnings.ts index 59fbce081c..c752e86e8e 100644 --- a/src/commands/Moderation/warnings.js +++ b/src/commands/Moderation/warnings.ts @@ -1,3 +1,4 @@ +import type { Command } from '../../types/index.js'; import { getColor } from '../../config/bot.js'; import { SlashCommandBuilder, PermissionFlagsBits, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; import { createEmbed } from '../../utils/embeds.js'; @@ -30,8 +31,8 @@ export default { return; } - 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; @@ -66,7 +67,7 @@ export default { embed.addFields(warningFields); - const actionRow = new ActionRowBuilder().addComponents( + const actionRow = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`warning_delete_specific:${target.id}:${interaction.user.id}`) .setLabel('Delete Specific Warning') @@ -95,4 +96,4 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [embed], components: [actionRow] }); }, -}; +} satisfies Command; diff --git a/src/commands/Music/join.js b/src/commands/Music/join.ts similarity index 91% rename from src/commands/Music/join.js rename to src/commands/Music/join.ts index 10e6174103..b6d7b9625c 100644 --- a/src/commands/Music/join.js +++ b/src/commands/Music/join.ts @@ -2,6 +2,7 @@ import { SlashCommandBuilder } from 'discord.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { joinVoiceChannel, replyMusicSuccess } from '../../services/music/musicActions.js'; import { deferMusicCommand } from '../../services/music/prefixSupport.js'; +import type { Command } from '../../types/index.js'; export default { category: 'Music', @@ -18,4 +19,4 @@ export default { const embed = await joinVoiceChannel(client, interaction); await replyMusicSuccess(interaction, embed); }, -}; +} satisfies Command; diff --git a/src/commands/Music/music.js b/src/commands/Music/music.ts similarity index 93% rename from src/commands/Music/music.js rename to src/commands/Music/music.ts index fa8d7cfef6..e8bb2937e0 100644 --- a/src/commands/Music/music.js +++ b/src/commands/Music/music.ts @@ -1,4 +1,5 @@ import { SlashCommandBuilder } from 'discord.js'; +import type { LoopOption } from 'riffy'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { skipTrack, @@ -17,6 +18,7 @@ import { replyMusicSuccess, } from '../../services/music/musicActions.js'; import { deferMusicCommand } from '../../services/music/prefixSupport.js'; +import type { Command } from '../../types/index.js'; export default { category: 'Music', @@ -135,22 +137,22 @@ export default { break; } case 'loop': { - const embed = await setLoopMode(client, interaction, interaction.options.getString('mode')); + const embed = await setLoopMode(client, interaction, interaction.options.getString('mode')! as LoopOption); await replyMusicSuccess(interaction, embed); break; } case 'volume': { - const embed = await setVolume(client, interaction, interaction.options.getInteger('level')); + 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')); + 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')); + const embed = await removeFromQueue(client, interaction, interaction.options.getInteger('position')!); await replyMusicSuccess(interaction, embed); break; } @@ -158,8 +160,8 @@ export default { const embed = await moveInQueue( client, interaction, - interaction.options.getInteger('from'), - interaction.options.getInteger('to'), + interaction.options.getInteger('from')!, + interaction.options.getInteger('to')!, ); await replyMusicSuccess(interaction, embed); break; @@ -175,7 +177,7 @@ export default { break; } case '247': { - const embed = await setTwentyFourSeven(client, interaction, interaction.options.getBoolean('enabled')); + const embed = await setTwentyFourSeven(client, interaction, interaction.options.getBoolean('enabled')!); await replyMusicSuccess(interaction, embed); break; } @@ -185,4 +187,4 @@ export default { }); } }, -}; +} satisfies Command; diff --git a/src/commands/Music/nowplaying.js b/src/commands/Music/nowplaying.ts similarity index 88% rename from src/commands/Music/nowplaying.js rename to src/commands/Music/nowplaying.ts index e50c685122..455651ab8f 100644 --- a/src/commands/Music/nowplaying.js +++ b/src/commands/Music/nowplaying.ts @@ -2,6 +2,7 @@ import { SlashCommandBuilder } from 'discord.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { buildNowPlayingReply } from '../../services/music/musicActions.js'; import { deferMusicCommand } from '../../services/music/prefixSupport.js'; +import type { Command } from '../../types/index.js'; export default { category: 'Music', @@ -11,7 +12,7 @@ export default { async execute(interaction, config, client) { await deferMusicCommand(interaction); - const payload = buildNowPlayingReply(client, interaction.guild.id); + const payload = buildNowPlayingReply(client, interaction.guild!.id); await InteractionHelper.safeEditReply(interaction, payload); }, -}; +} satisfies Command; diff --git a/src/commands/Music/play.js b/src/commands/Music/play.ts similarity index 88% rename from src/commands/Music/play.js rename to src/commands/Music/play.ts index 1d07665382..297684feaa 100644 --- a/src/commands/Music/play.js +++ b/src/commands/Music/play.ts @@ -1,6 +1,7 @@ import { SlashCommandBuilder, MessageFlags } from 'discord.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { playQuery, replyMusicSuccess } from '../../services/music/musicActions.js'; +import type { Command } from '../../types/index.js'; export default { slashOnly: true, @@ -18,7 +19,7 @@ export default { return; } - const result = await playQuery(client, interaction, interaction.options.getString('query')); + const result = await playQuery(client, interaction, interaction.options.getString('query')!); await replyMusicSuccess(interaction, result.embed); }, -}; +} satisfies Command; diff --git a/src/commands/Music/queue.js b/src/commands/Music/queue.ts similarity index 91% rename from src/commands/Music/queue.js rename to src/commands/Music/queue.ts index 66faa4eb9d..c8a439290b 100644 --- a/src/commands/Music/queue.js +++ b/src/commands/Music/queue.ts @@ -1,6 +1,7 @@ import { SlashCommandBuilder, MessageFlags } from 'discord.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { buildQueueReply } from '../../services/music/musicActions.js'; +import type { Command } from '../../types/index.js'; export default { slashOnly: true, @@ -15,10 +16,10 @@ export default { async execute(interaction, config, client) { await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); const page = (interaction.options.getInteger('page') || 1) - 1; - const payload = buildQueueReply(client, interaction.guild.id, page); + const payload = buildQueueReply(client, interaction.guild!.id, page); await InteractionHelper.safeEditReply(interaction, { embeds: payload.embeds, components: payload.components, }); }, -}; +} satisfies Command; diff --git a/src/commands/Reaction_roles/reactroles.js b/src/commands/Reaction_roles/reactroles.ts similarity index 85% rename from src/commands/Reaction_roles/reactroles.js rename to src/commands/Reaction_roles/reactroles.ts index a31d579e54..32d4323c44 100644 --- a/src/commands/Reaction_roles/reactroles.js +++ b/src/commands/Reaction_roles/reactroles.ts @@ -12,12 +12,23 @@ import { } from '../../utils/panelStatus.js'; import { startDashboardSession } from '../../utils/dashboardSession.js'; import { getReactionRoleKey } from '../../utils/database/keys.js'; +import type { AutocompleteInteraction, ButtonInteraction, ChatInputCommandInteraction, Embed, Guild, GuildBasedChannel, GuildTextBasedChannel, Message, Role, StringSelectMenuInteraction } from 'discord.js'; +import type { ReactionRoleMessage } from '../../services/reactionRoleService.js'; +import type { PanelStatus } from '../../utils/panelStatus.js'; +import type { Command, NexoClient } from '../../types/index.js'; + +/** + * Panels managed by this command are select-menu panels, whose `roles` is a list of role ids + * (the legacy emoji -> role mapping is never edited here). The JS treated `roles` as an array throughout. + * Panel channels are looked up from stored ids and are assumed to be guild text channels, as the JS did. + */ +type SelectMenuPanel = ReactionRoleMessage & { roles: string[] }; const DASHBOARD_EPHEMERAL = MessageFlags.Ephemeral; const SELECT_OPTION_LABEL_LIMIT = 100; const SELECT_OPTION_DESCRIPTION_LIMIT = 100; -function truncateText(value, maxLength) { +function truncateText(value: unknown, maxLength: number) { const text = String(value ?? ''); return text.length > maxLength ? text.substring(0, maxLength) : text; } @@ -86,7 +97,7 @@ export default { ) ), - async execute(interaction) { + async execute(interaction: ChatInputCommandInteraction) { const subcommand = interaction.options.getSubcommand(); if (subcommand === 'setup') { @@ -97,18 +108,18 @@ export default { } }, - async autocomplete(interaction) { + async autocomplete(interaction: AutocompleteInteraction) { 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 guildId = interaction.guild!.id; const client = interaction.client; - const guild = interaction.guild; + const guild = interaction.guild!; - let panels; + let panels: ReactionRoleMessage[]; try { panels = await getAllReactionRoleMessages(client, guildId); } catch { @@ -121,11 +132,11 @@ export default { return; } - const choices = []; + const choices: { name: string; value: string }[] = []; for (const panel of panels) { if (!panel.messageId || !panel.channelId) continue; - const channel = guild.channels.cache.get(panel.channelId); + const channel = guild.channels.cache.get(panel.channelId) as GuildTextBasedChannel | undefined; if (!channel) continue; const cachedTitle = channel.messages?.cache?.get(panel.messageId)?.embeds?.[0]?.title; @@ -143,17 +154,17 @@ export default { await interaction.respond([]).catch(() => {}); } } -}; +} satisfies Command; -async function handleSetup(interaction) { +async function handleSetup(interaction: ChatInputCommandInteraction) { const deferSuccess = await InteractionHelper.safeDefer(interaction); if (!deferSuccess) return; - logger.info(`Reaction role setup initiated by ${interaction.user.tag} in guild ${interaction.guild.name}`); + logger.info(`Reaction role setup initiated by ${interaction.user.tag} in guild ${interaction.guild!.name}`); - const channel = interaction.options.getChannel('channel'); - const title = interaction.options.getString('title'); - const description = interaction.options.getString('description'); + const channel = interaction.options.getChannel('channel') as GuildBasedChannel; + const title = interaction.options.getString('title')!; + const description = interaction.options.getString('description')!; if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) { throw createError( @@ -164,7 +175,7 @@ async function handleSetup(interaction) { ); } - if (!interaction.guild.members.me.permissions.has(PermissionFlagsBits.ManageRoles)) { + if (!interaction.guild!.members.me!.permissions.has(PermissionFlagsBits.ManageRoles)) { throw createError( 'Bot missing ManageRoles permission', ErrorTypes.PERMISSION, @@ -173,7 +184,7 @@ async function handleSetup(interaction) { ); } - if (!channel.permissionsFor(interaction.guild.members.me).has(PermissionFlagsBits.SendMessages)) { + if (!channel.permissionsFor(interaction.guild!.members.me!).has(PermissionFlagsBits.SendMessages)) { throw createError( `Bot cannot send messages in ${channel.name}`, ErrorTypes.PERMISSION, @@ -182,7 +193,7 @@ async function handleSetup(interaction) { ); } - const existingPanels = await getAllReactionRoleMessages(interaction.client, interaction.guildId); + const existingPanels = await getAllReactionRoleMessages(interaction.client, interaction.guildId!); if (existingPanels && existingPanels.length >= 5) { throw createError( 'Panel limit reached', @@ -192,19 +203,20 @@ async function handleSetup(interaction) { ); } - const roles = []; - const roleValidationErrors = []; - const seenRoleIds = new Set(); + const roles: Role[] = []; + const roleValidationErrors: string[] = []; + const seenRoleIds = new Set(); for (let i = 1; i <= 5; i++) { - const role = interaction.options.getRole(`role${i}`); + // Guild-only command: resolved roles are cached Role objects. + const role = interaction.options.getRole(`role${i}`) as Role | null; 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) { + 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; } @@ -219,7 +231,7 @@ async function handleSetup(interaction) { continue; } - if (role.id === interaction.guild.id) { + if (role.id === interaction.guild!.id) { roleValidationErrors.push(`**${role.name}** - Cannot use the @everyone role`); continue; } @@ -256,7 +268,7 @@ async function handleSetup(interaction) { ); } - const row = new ActionRowBuilder().addComponents( + const row = new ActionRowBuilder().addComponents( new StringSelectMenuBuilder() .setCustomId('reaction_roles') .setPlaceholder('Select your roles') @@ -291,7 +303,7 @@ async function handleSetup(interaction) { try { await createReactionRoleMessage( interaction.client, - interaction.guildId, + interaction.guildId!, channel.id, message.id, roleIds @@ -308,7 +320,7 @@ async function handleSetup(interaction) { try { await logEvent({ client: interaction.client, - guildId: interaction.guildId, + guildId: interaction.guildId!, eventType: EVENT_TYPES.REACTION_ROLE_CREATE, data: { description: `Reaction role panel created by ${interaction.user.tag}`, @@ -352,9 +364,9 @@ async function handleSetup(interaction) { }); } -async function fetchPanelDiscordMessage(guild, panelData) { +async function fetchPanelDiscordMessage(guild: Guild, panelData: SelectMenuPanel) { try { - const channel = guild.channels.cache.get(panelData.channelId); + const channel = guild.channels.cache.get(panelData.channelId) as GuildTextBasedChannel | undefined; if (!channel) return null; return await channel.messages.fetch(panelData.messageId).catch(() => null); } catch { @@ -362,16 +374,16 @@ async function fetchPanelDiscordMessage(guild, panelData) { } } -async function rebuildLivePanelMessage(guild, panelData) { +async function rebuildLivePanelMessage(guild: Guild, panelData: SelectMenuPanel) { try { - const channel = guild.channels.cache.get(panelData.channelId); + const channel = guild.channels.cache.get(panelData.channelId) as GuildTextBasedChannel | undefined; if (!channel) return; const msg = await channel.messages.fetch(panelData.messageId).catch(() => null); if (!msg || !msg.embeds[0]) return; const roleObjects = panelData.roles .map(id => guild.roles.cache.get(id)) - .filter(Boolean); + .filter((role): role is Role => Boolean(role)); if (roleObjects.length === 0) return; @@ -387,7 +399,7 @@ async function rebuildLivePanelMessage(guild, panelData) { } updatedEmbed.setFields(fields); - const selectRow = new ActionRowBuilder().addComponents( + const selectRow = new ActionRowBuilder().addComponents( new StringSelectMenuBuilder() .setCustomId('reaction_roles') .setPlaceholder('Select your roles') @@ -405,11 +417,19 @@ async function rebuildLivePanelMessage(guild, panelData) { await msg.edit({ embeds: [updatedEmbed], components: [selectRow] }); } catch (error) { - logger.warn('Could not rebuild live reaction role panel:', error.message); + logger.warn('Could not rebuild live reaction role panel:', (error as Error).message); } } -async function showPanelDashboard(interaction, panelData, discordMsg, guildId, guild, client, panelStatus = null) { +async function showPanelDashboard( + interaction: ChatInputCommandInteraction, + panelData: SelectMenuPanel, + discordMsg: Message | null | undefined, + guildId: string, + guild: Guild, + client: NexoClient, + panelStatus: PanelStatus | null = null, +) { if (!panelStatus && client) { panelStatus = await getReactionRolePanelStatus(client, guild, panelData); if (panelStatus.recoveredId) { @@ -422,8 +442,14 @@ async function showPanelDashboard(interaction, panelData, discordMsg, guildId, g await InteractionHelper.safeEditReply(interaction, { ...payload, flags: DASHBOARD_EPHEMERAL }); } -function buildReactionRoleDashboardPayload(panelData, discordMsg, guildId, guild, panelStatus = null) { - const channel = guild.channels.cache.get(panelData.channelId); +function buildReactionRoleDashboardPayload( + panelData: SelectMenuPanel, + discordMsg: Message | null | undefined, + guildId: string, + guild: Guild, + panelStatus: PanelStatus | null = null, +) { + const channel = guild.channels.cache.get(panelData.channelId) as GuildTextBasedChannel | undefined; const title = discordMsg?.embeds?.[0]?.title ?? 'Untitled Panel'; const roleList = panelData.roles.length > 0 @@ -448,7 +474,7 @@ function buildReactionRoleDashboardPayload(panelData, discordMsg, guildId, guild .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) .setTimestamp(); - const buttons = []; + const buttons: ButtonBuilder[] = []; if (showRepost) { buttons.push( @@ -496,13 +522,13 @@ function buildReactionRoleDashboardPayload(panelData, discordMsg, guildId, guild return { embeds: [embed], components: [ - new ActionRowBuilder().addComponents(buttons), - new ActionRowBuilder().addComponents(optionsSelect), + new ActionRowBuilder().addComponents(buttons), + new ActionRowBuilder().addComponents(optionsSelect), ], }; } -async function migrateReactionRoleMessageId(client, guildId, panelData, newMessageId) { +async function migrateReactionRoleMessageId(client: NexoClient, guildId: string, panelData: ReactionRoleMessage, newMessageId: string | undefined) { if (!newMessageId || panelData.messageId === newMessageId) return; const oldKey = getReactionRoleKey(guildId, panelData.messageId); panelData.messageId = newMessageId; @@ -510,8 +536,8 @@ async function migrateReactionRoleMessageId(client, guildId, panelData, newMessa await client.db.delete(oldKey).catch(() => {}); } -async function repostReactionRolePanel(guild, panelData, client, guildId, fallbackEmbed = null) { - const channel = await guild.channels.fetch(panelData.channelId).catch(() => null); +async function repostReactionRolePanel(guild: Guild, panelData: SelectMenuPanel, client: NexoClient, guildId: string, fallbackEmbed: Embed | null | undefined = null) { + const channel = (await guild.channels.fetch(panelData.channelId).catch(() => null)) as GuildTextBasedChannel | null; if (!channel) { throw createError( 'Panel channel missing', @@ -520,7 +546,7 @@ async function repostReactionRolePanel(guild, panelData, client, guildId, fallba ); } - const roleObjects = panelData.roles.map(id => guild.roles.cache.get(id)).filter(Boolean); + const roleObjects = panelData.roles.map(id => guild.roles.cache.get(id)).filter((role): role is Role => Boolean(role)); if (roleObjects.length === 0) { throw createError( 'No valid roles', @@ -541,7 +567,7 @@ async function repostReactionRolePanel(guild, panelData, client, guildId, fallba value: roleObjects.map(role => `• ${role}`).join('\n'), }); - const row = new ActionRowBuilder().addComponents( + const row = new ActionRowBuilder().addComponents( new StringSelectMenuBuilder() .setCustomId('reaction_roles') .setPlaceholder('Select your roles') @@ -562,13 +588,13 @@ async function repostReactionRolePanel(guild, panelData, client, guildId, fallba return sent; } -async function handleDashboard(interaction, selectedPanelId) { +async function handleDashboard(interaction: ChatInputCommandInteraction, selectedPanelId: string | null) { const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: DASHBOARD_EPHEMERAL }); if (!deferSuccess) return; const client = interaction.client; - const guildId = interaction.guild.id; - const guild = interaction.guild; + const guildId = interaction.guild!.id; + const guild = interaction.guild!; const panels = await getAllReactionRoleMessages(client, guildId); if (!panels?.length) { @@ -579,10 +605,11 @@ async function handleDashboard(interaction, selectedPanelId) { ); } - let panelData = selectedPanelId ? panels.find(p => p.messageId === selectedPanelId) : null; + // See SelectMenuPanel: the dashboard edits select-menu panels whose roles are a list. + let panelData = (selectedPanelId ? panels.find(p => p.messageId === selectedPanelId) : null) as SelectMenuPanel | null | undefined; if (!panelData) { if (panels.length === 1) { - panelData = panels[0]; + panelData = panels[0] as SelectMenuPanel; } else { throw createError( 'Panel required', @@ -606,7 +633,7 @@ async function handleDashboard(interaction, selectedPanelId) { ...payload, flags: DASHBOARD_EPHEMERAL, selectMenuId: `rr_opts_${guildId}`, - buttonMatcher: (customId) => + buttonMatcher: (customId: string) => customId === `rr_edit_text_${guildId}` || customId === `rr_delete_${guildId}` || customId === `rr_repost_${guildId}`, @@ -657,8 +684,8 @@ async function handleDashboard(interaction, selectedPanelId) { }); } -async function handleEditText(buttonInteraction, rootInteraction, panelData, guildId, guild, client) { - const channel = guild.channels.cache.get(panelData.channelId); +async function handleEditText(buttonInteraction: ButtonInteraction, rootInteraction: ChatInputCommandInteraction, panelData: SelectMenuPanel, guildId: string, guild: Guild, client: NexoClient) { + const channel = guild.channels.cache.get(panelData.channelId) as GuildTextBasedChannel | undefined; const discordMsg = channel ? await channel.messages.fetch(panelData.messageId).catch(() => null) : null; @@ -670,7 +697,7 @@ async function handleEditText(buttonInteraction, rootInteraction, panelData, gui .setCustomId('rr_edit_text') .setTitle('Edit Panel Text') .addComponents( - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('panel_title') .setLabel('Title') @@ -680,7 +707,7 @@ async function handleEditText(buttonInteraction, rootInteraction, panelData, gui .setMinLength(1) .setRequired(true), ), - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('panel_description') .setLabel('Description') @@ -718,7 +745,7 @@ async function handleEditText(buttonInteraction, rootInteraction, panelData, gui if (discordMsg) { const roleObjects = panelData.roles .map(id => guild.roles.cache.get(id)) - .filter(Boolean); + .filter((role): role is Role => Boolean(role)); const updatedEmbed = EmbedBuilder.from(discordMsg.embeds[0]) .setTitle(newTitle) .setDescription(newDescription); @@ -747,7 +774,7 @@ async function handleEditText(buttonInteraction, rootInteraction, panelData, gui await showPanelDashboard(rootInteraction, panelData, refreshedMsg, guildId, guild, client); } -async function handleAddRole(selectInteraction, rootInteraction, panelData, guildId, guild, client) { +async function handleAddRole(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, panelData: SelectMenuPanel, guildId: string, guild: Guild, client: NexoClient) { await selectInteraction.deferUpdate(); if (panelData.roles.length >= 25) { @@ -772,11 +799,11 @@ async function handleAddRole(selectInteraction, rootInteraction, panelData, guil ) .setColor(getColor('info')), ], - components: [new ActionRowBuilder().addComponents(roleSelect)], + components: [new ActionRowBuilder().addComponents(roleSelect)], flags: MessageFlags.Ephemeral, }); - const roleCollector = rootInteraction.channel.createMessageComponentCollector({ + const roleCollector = (rootInteraction.channel as GuildTextBasedChannel).createMessageComponentCollector({ componentType: ComponentType.RoleSelect, filter: i => i.user.id === selectInteraction.user.id && i.customId === 'rr_add_role_pick', @@ -786,7 +813,7 @@ async function handleAddRole(selectInteraction, rootInteraction, panelData, guil roleCollector.on('collect', async roleInteraction => { await roleInteraction.deferUpdate(); - const role = roleInteraction.roles.first(); + const role = roleInteraction.roles.first() as Role; if (panelData.roles.includes(role.id)) { await replyUserError(roleInteraction, { @@ -816,7 +843,7 @@ async function handleAddRole(selectInteraction, rootInteraction, panelData, guil }); return; } - if (role.position >= guild.members.me.roles.highest.position) { + if (role.position >= guild.members.me!.roles.highest.position) { await replyUserError(roleInteraction, { type: ErrorTypes.PERMISSION, message: "That role is above my highest role in the hierarchy. Move my role above it first.", @@ -835,7 +862,7 @@ async function handleAddRole(selectInteraction, rootInteraction, panelData, guil flags: MessageFlags.Ephemeral, }); - const channel = guild.channels.cache.get(panelData.channelId); + const channel = guild.channels.cache.get(panelData.channelId) as GuildTextBasedChannel | undefined; const discordMsg = channel ? await channel.messages.fetch(panelData.messageId).catch(() => null) : null; @@ -852,7 +879,7 @@ async function handleAddRole(selectInteraction, rootInteraction, panelData, guil }); } -async function handleRemoveRole(selectInteraction, rootInteraction, panelData, panels, guildId, guild, client) { +async function handleRemoveRole(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, panelData: SelectMenuPanel, panels: ReactionRoleMessage[], guildId: string, guild: Guild, client: NexoClient) { await selectInteraction.deferUpdate(); const roleOptions = panelData.roles @@ -860,7 +887,7 @@ async function handleRemoveRole(selectInteraction, rootInteraction, panelData, p const role = guild.roles.cache.get(id); return role ? { label: role.name.substring(0, 100), value: id } : null; }) - .filter(Boolean); + .filter((option): option is { label: string; value: string } => Boolean(option)); if (roleOptions.length === 0) { await replyUserError(selectInteraction, { @@ -887,11 +914,11 @@ async function handleRemoveRole(selectInteraction, rootInteraction, panelData, p .setDescription('Select the role you want to remove from this panel.') .setColor(getColor('info')), ], - components: [new ActionRowBuilder().addComponents(removeSelect)], + components: [new ActionRowBuilder().addComponents(removeSelect)], flags: MessageFlags.Ephemeral, }); - const removeCollector = rootInteraction.channel.createMessageComponentCollector({ + const removeCollector = (rootInteraction.channel as GuildTextBasedChannel).createMessageComponentCollector({ componentType: ComponentType.StringSelect, filter: i => i.user.id === selectInteraction.user.id && i.customId === 'rr_remove_role_pick', @@ -907,7 +934,7 @@ async function handleRemoveRole(selectInteraction, rootInteraction, panelData, p panelData.roles = panelData.roles.filter(id => id !== roleId); if (panelData.roles.length === 0) { - const channel = guild.channels.cache.get(panelData.channelId); + const channel = guild.channels.cache.get(panelData.channelId) as GuildTextBasedChannel | undefined; if (channel) { const msg = await channel.messages.fetch(panelData.messageId).catch(() => null); if (msg) await msg.delete().catch(() => {}); @@ -968,7 +995,7 @@ async function handleRemoveRole(selectInteraction, rootInteraction, panelData, p flags: MessageFlags.Ephemeral, }); - const channel = guild.channels.cache.get(panelData.channelId); + const channel = guild.channels.cache.get(panelData.channelId) as GuildTextBasedChannel | undefined; const discordMsg = channel ? await channel.messages.fetch(panelData.messageId).catch(() => null) : null; @@ -986,8 +1013,8 @@ async function handleRemoveRole(selectInteraction, rootInteraction, panelData, p }); } -async function handleDeletePanel(btnInteraction, rootInteraction, panelData, panels, guildId, guild, client) { - const channel = guild.channels.cache.get(panelData.channelId); +async function handleDeletePanel(btnInteraction: ButtonInteraction, rootInteraction: ChatInputCommandInteraction, panelData: SelectMenuPanel, panels: ReactionRoleMessage[], guildId: string, guild: Guild, client: NexoClient) { + const channel = guild.channels.cache.get(panelData.channelId) as GuildTextBasedChannel | undefined; const discordMsg = channel ? await channel.messages.fetch(panelData.messageId).catch(() => null) : null; diff --git a/src/commands/Search/modules/search_define.js b/src/commands/Search/modules/search_define.ts similarity index 82% rename from src/commands/Search/modules/search_define.js rename to src/commands/Search/modules/search_define.ts index 149ec0f3aa..9d2e0fb517 100644 --- a/src/commands/Search/modules/search_define.js +++ b/src/commands/Search/modules/search_define.ts @@ -1,9 +1,26 @@ -import axios from 'axios'; +import type { SubcommandModule } from '../../../types/index.js'; +import axios, { type AxiosError } from 'axios'; 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'; +interface DictionaryDefinition { + definition: string; + example?: string; +} + +interface DictionaryMeaning { + partOfSpeech?: string; + definitions: DictionaryDefinition[]; +} + +interface DictionaryEntry { + word: string; + phonetic?: string; + meanings: DictionaryMeaning[]; +} + export default { async execute(interaction) { try { @@ -12,7 +29,7 @@ export default { return; } - const word = interaction.options.getString('word'); + const word = interaction.options.getString('word')!; if (word.length < 2) { logger.warn('Define command - word too short', { @@ -23,7 +40,7 @@ export default { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Please enter a word with at least 2 characters.' }); } - const response = await axios.get( + const response = await axios.get( `https://api.dictionaryapi.dev/api/v2/entries/en/${encodeURIComponent(word)}`, { timeout: 5000 } ); @@ -72,16 +89,17 @@ export default { }); } catch (error) { + const err = error as AxiosError; logger.error('Dictionary lookup error', { - error: error.message, - stack: error.stack, + error: err.message, + stack: err.stack, userId: interaction.user.id, word: interaction.options.getString('word'), guildId: interaction.guildId, commandName: 'define' }); - if (error.response?.status === 404) { + if (err.response?.status === 404) { await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: `No definitions found for "${interaction.options.getString('word')}".` }); } else { await handleInteractionError(interaction, error, { @@ -91,4 +109,4 @@ export default { } } }, -}; +} satisfies SubcommandModule; diff --git a/src/commands/Search/modules/search_google.js b/src/commands/Search/modules/search_google.ts similarity index 84% rename from src/commands/Search/modules/search_google.js rename to src/commands/Search/modules/search_google.ts index c277046d49..179d5e0872 100644 --- a/src/commands/Search/modules/search_google.js +++ b/src/commands/Search/modules/search_google.ts @@ -1,10 +1,11 @@ +import type { SubcommandModule } from '../../../types/index.js'; import { createEmbed } from '../../../utils/embeds.js'; import { logger } from '../../../utils/logger.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { async execute(interaction) { - const query = interaction.options.getString('query'); + const query = interaction.options.getString('query')!; const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}`; const embed = createEmbed({ @@ -23,4 +24,4 @@ export default { commandName: 'google' }); }, -}; +} satisfies SubcommandModule; diff --git a/src/commands/Search/modules/search_urban.js b/src/commands/Search/modules/search_urban.ts similarity index 82% rename from src/commands/Search/modules/search_urban.js rename to src/commands/Search/modules/search_urban.ts index b382fe30c0..3bc36280da 100644 --- a/src/commands/Search/modules/search_urban.js +++ b/src/commands/Search/modules/search_urban.ts @@ -1,13 +1,28 @@ -import axios from 'axios'; +import type { SubcommandModule } from '../../../types/index.js'; +import axios, { type AxiosError } from 'axios'; 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'; +interface UrbanDefinition { + word: string; + definition: string; + example: string; + permalink: string; + thumbs_up: number; + thumbs_down: number; + author?: string; +} + +interface UrbanResponse { + list?: UrbanDefinition[]; +} + export default { async execute(interaction) { try { - const term = interaction.options.getString('term'); + const term = interaction.options.getString('term')!; if (term.length < 2) { logger.warn('Urban command - term too short', { @@ -18,7 +33,7 @@ export default { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Please enter a term with at least 2 characters.' }); } - let deferTimer = null; + let deferTimer: ReturnType | null = null; const clearDeferTimer = () => { if (deferTimer) { clearTimeout(deferTimer); @@ -36,7 +51,7 @@ export default { }); }, 1500); - const response = await axios.get( + const response = await axios.get( `https://api.urbandictionary.com/v0/define?term=${encodeURIComponent(term)}`, { timeout: 5000 } ); @@ -46,7 +61,7 @@ export default { return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: `No definitions found for "${term}" on Urban Dictionary.` }); } - const definition = response.data.list[0]; + const definition = response.data.list![0]; const cleanDefinition = definition.definition.replace(/\[|\]/g, ''); const cleanExample = definition.example.replace(/\[|\]/g, ''); @@ -96,19 +111,20 @@ export default { }); } catch (error) { + const err = error as AxiosError; logger.error('Urban Dictionary error', { - error: error.message, - stack: error.stack, + error: err.message, + stack: err.stack, userId: interaction.user.id, term: interaction.options.getString('term'), guildId: interaction.guildId, - apiStatus: error.response?.status, + apiStatus: err.response?.status, commandName: 'urban' }); - if (error.response?.status === 404 || !error.response) { + if (err.response?.status === 404 || !err.response) { await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: `No definitions found for "${interaction.options.getString('term')}" on Urban Dictionary.` }); - } else if (error.response?.status === 429) { + } else if (err.response?.status === 429) { await replyUserError(interaction, { type: ErrorTypes.RATE_LIMIT, message: 'Too many requests to Urban Dictionary. Please try again in a few minutes.' }); } else { await handleInteractionError(interaction, error, { @@ -118,4 +134,4 @@ export default { } } }, -}; +} satisfies SubcommandModule; diff --git a/src/commands/Search/search.js b/src/commands/Search/search.ts similarity index 87% rename from src/commands/Search/search.js rename to src/commands/Search/search.ts index 52c1871902..0d3b3defeb 100644 --- a/src/commands/Search/search.js +++ b/src/commands/Search/search.ts @@ -1,3 +1,4 @@ +import type { Command } from '../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; @@ -42,13 +43,13 @@ export default { switch (subcommand) { case 'define': - return await searchDefine.execute(interaction, config, client); + return await searchDefine.execute(interaction); case 'google': - return await searchGoogle.execute(interaction, config, client); + return await searchGoogle.execute(interaction); case 'urban': - return await searchUrban.execute(interaction, config, client); + return await searchUrban.execute(interaction); default: return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Unknown subcommand' }); } } -}; +} satisfies Command; diff --git a/src/commands/ServerStats/modules/serverstats_create.js b/src/commands/ServerStats/modules/serverstats_create.ts similarity index 88% rename from src/commands/ServerStats/modules/serverstats_create.js rename to src/commands/ServerStats/modules/serverstats_create.ts index e5e750a933..ce092aeabb 100644 --- a/src/commands/ServerStats/modules/serverstats_create.js +++ b/src/commands/ServerStats/modules/serverstats_create.ts @@ -1,13 +1,16 @@ import { PermissionFlagsBits, ChannelType } from 'discord.js'; +import type { ServerCounter } from '../../../services/serverstatsService.js'; import { createEmbed, successEmbed } from '../../../utils/embeds.js'; import { getServerCounters, saveServerCounters, updateCounter, getCounterBaseName, getCounterTypeLabel } from '../../../services/serverstatsService.js'; 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"); +import type { ChatInputCommandInteraction, GuildMember } from 'discord.js'; +import type { NexoClient } from '../../../types/index.js'; +export async function handleCreate(interaction: ChatInputCommandInteraction, client: NexoClient) { + const guild = interaction.guild!; + const type = interaction.options.getString("type")!; const channelType = interaction.options.getString("channel_type"); const category = interaction.options.getChannel("category"); @@ -18,7 +21,7 @@ export async function handleCreate(interaction, client) { return; } - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) { + if (!(interaction.member as GuildMember).permissions.has(PermissionFlagsBits.ManageChannels)) { await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need **Manage Channels** permission to create counters.' }).catch(logger.error); return; } @@ -55,7 +58,7 @@ export async function handleCreate(interaction, client) { return; } - const newCounter = { + const newCounter: ServerCounter = { id: Date.now().toString(), type: type, channelId: targetChannel.id, diff --git a/src/commands/ServerStats/modules/serverstats_delete.js b/src/commands/ServerStats/modules/serverstats_delete.ts similarity index 87% rename from src/commands/ServerStats/modules/serverstats_delete.js rename to src/commands/ServerStats/modules/serverstats_delete.ts index 54e3e9ccfc..dd47ce7f7b 100644 --- a/src/commands/ServerStats/modules/serverstats_delete.js +++ b/src/commands/ServerStats/modules/serverstats_delete.ts @@ -6,8 +6,10 @@ 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; +import type { ChatInputCommandInteraction, Guild, GuildMember } from 'discord.js'; +import type { NexoClient } from '../../../types/index.js'; +export async function handleDelete(interaction: ChatInputCommandInteraction, client: NexoClient) { + const guild = interaction.guild!; const counterId = interaction.options.getString("counter-id"); try { @@ -17,7 +19,7 @@ export async function handleDelete(interaction, client) { return; } - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) { + if (!(interaction.member as GuildMember).permissions.has(PermissionFlagsBits.ManageChannels)) { await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need **Manage Channels** permission to delete counters.' }).catch(logger.error); return; } @@ -44,7 +46,7 @@ export async function handleDelete(interaction, client) { color: getColor('error') }); - const row = new ActionRowBuilder().addComponents( + const row = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`counter-delete:confirm:${counterToDelete.id}:${interaction.user.id}`) .setLabel("Confirm Delete") @@ -63,7 +65,7 @@ export async function handleDelete(interaction, client) { } } -export const performDeletionByCounterId = wrapServiceBoundary(async function performDeletionByCounterId(client, guild, counterId) { +export const performDeletionByCounterId = wrapServiceBoundary(async function performDeletionByCounterId(client: NexoClient, guild: Guild, counterId: string) { const counters = await getServerCounters(client, guild.id); const counter = counters.find(c => c.id === counterId); @@ -103,7 +105,7 @@ export const performDeletionByCounterId = wrapServiceBoundary(async function per let message = `✅ **Counter Deleted Successfully!**\n\n**ID:** \`${counter.id}\`\n**Type:** ${getCounterTypeDisplay(counter.type)}`; if (channelDeleted) { - message += `\n**Channel:** ${channel.name} (deleted)`; + message += `\n**Channel:** ${channel!.name} (deleted)`; } else if (channel) { message += `\n**Channel:** ${channel.name} (failed to delete)`; } else { @@ -117,6 +119,6 @@ export const performDeletionByCounterId = wrapServiceBoundary(async function per userMessage: 'An error occurred while deleting the counter. Please try again.', }); -function getCounterTypeDisplay(type) { +function getCounterTypeDisplay(type: string) { return `${getCounterEmoji(type)} ${getCounterTypeLabel(type)}`; } \ No newline at end of file diff --git a/src/commands/ServerStats/modules/serverstats_list.js b/src/commands/ServerStats/modules/serverstats_list.ts similarity index 88% rename from src/commands/ServerStats/modules/serverstats_list.js rename to src/commands/ServerStats/modules/serverstats_list.ts index ad6d789227..1eb61b2d1a 100644 --- a/src/commands/ServerStats/modules/serverstats_list.js +++ b/src/commands/ServerStats/modules/serverstats_list.ts @@ -1,13 +1,16 @@ import { getColor } from '../../../config/bot.js'; import { PermissionFlagsBits } from 'discord.js'; +import type { ServerCounter } from '../../../services/serverstatsService.js'; import { createEmbed } from '../../../utils/embeds.js'; import { getServerCounters, saveServerCounters, getCounterEmoji as getCounterTypeEmoji, getCounterTypeLabel, getGuildCounterStats } from '../../../services/serverstatsService.js'; 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; +import type { ChatInputCommandInteraction, GuildMember } from 'discord.js'; +import type { NexoClient } from '../../../types/index.js'; +export async function handleList(interaction: ChatInputCommandInteraction, client: NexoClient) { + const guild = interaction.guild!; try { await InteractionHelper.safeDefer(interaction); @@ -16,7 +19,7 @@ export async function handleList(interaction, client) { return; } - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) { + if (!(interaction.member as GuildMember).permissions.has(PermissionFlagsBits.ManageChannels)) { await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need **Manage Channels** permission to view counters.' }).catch(logger.error); return; } @@ -25,8 +28,8 @@ export async function handleList(interaction, client) { const counters = await getServerCounters(client, guild.id); const stats = await getGuildCounterStats(guild); - const validCounters = []; - const orphanedCounters = []; + const validCounters: ServerCounter[] = []; + const orphanedCounters: ServerCounter[] = []; for (const counter of counters) { const channel = guild.channels.cache.get(counter.channelId); @@ -124,15 +127,15 @@ export async function handleList(interaction, client) { } } -function getCounterTypeDisplay(type) { +function getCounterTypeDisplay(type: string) { return `${getCounterTypeEmoji(type)} ${getCounterTypeLabel(type)}`; } -function getCounterEmoji(type) { +function getCounterEmoji(type: string) { return getCounterTypeEmoji(type); } -function getCurrentCount(stats, type) { +function getCurrentCount(stats: Awaited>, type: string) { switch (type) { case "members": return stats.totalCount; diff --git a/src/commands/ServerStats/modules/serverstats_update.js b/src/commands/ServerStats/modules/serverstats_update.ts similarity index 89% rename from src/commands/ServerStats/modules/serverstats_update.js rename to src/commands/ServerStats/modules/serverstats_update.ts index b2ebb45434..46c69bffae 100644 --- a/src/commands/ServerStats/modules/serverstats_update.js +++ b/src/commands/ServerStats/modules/serverstats_update.ts @@ -5,8 +5,10 @@ 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; +import type { ChatInputCommandInteraction, GuildMember } from 'discord.js'; +import type { NexoClient } from '../../../types/index.js'; +export async function handleUpdate(interaction: ChatInputCommandInteraction, client: NexoClient) { + const guild = interaction.guild!; const counterId = interaction.options.getString("counter-id"); const newType = interaction.options.getString("type"); @@ -17,7 +19,7 @@ export async function handleUpdate(interaction, client) { return; } - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) { + if (!(interaction.member as GuildMember).permissions.has(PermissionFlagsBits.ManageChannels)) { await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need **Manage Channels** permission to update counters.' }).catch(logger.error); return; } @@ -74,7 +76,7 @@ export async function handleUpdate(interaction, client) { const finalChannel = guild.channels.cache.get(updatedCounter.channelId); await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed(`**Counter Updated Successfully!**\n\n**Counter ID:** \`${counterId}\`\n**Type Changed:** ${getCounterEmoji(oldType)} ${getCounterTypeLabel(oldType)} → ${getCounterEmoji(newType)} ${getCounterTypeLabel(newType)}\n\n**Current Settings:**\n**Type:** ${getCounterEmoji(updatedCounter.type)} ${getCounterTypeLabel(updatedCounter.type)}\n**Channel:** ${finalChannel}\n**Channel Name:** ${finalChannel.name}\n\nThe counter will automatically update every 15 minutes.`)] + embeds: [successEmbed(`**Counter Updated Successfully!**\n\n**Counter ID:** \`${counterId}\`\n**Type Changed:** ${getCounterEmoji(oldType)} ${getCounterTypeLabel(oldType)} → ${getCounterEmoji(newType)} ${getCounterTypeLabel(newType)}\n\n**Current Settings:**\n**Type:** ${getCounterEmoji(updatedCounter.type)} ${getCounterTypeLabel(updatedCounter.type)}\n**Channel:** ${finalChannel}\n**Channel Name:** ${finalChannel!.name}\n\nThe counter will automatically update every 15 minutes.`)] }).catch(logger.error); } catch (error) { diff --git a/src/commands/ServerStats/serverstats.js b/src/commands/ServerStats/serverstats.ts similarity index 98% rename from src/commands/ServerStats/serverstats.js rename to src/commands/ServerStats/serverstats.ts index 9fe3dda3b8..ba1c6cdfa2 100644 --- a/src/commands/ServerStats/serverstats.js +++ b/src/commands/ServerStats/serverstats.ts @@ -10,6 +10,7 @@ import { handleDelete } from './modules/serverstats_delete.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; +import type { Command } from '../../types/index.js'; export default { data: new SlashCommandBuilder() .setName("serverstats") @@ -107,4 +108,4 @@ export default { await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Unknown subcommand.' }); } } -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Ticket/claim.js b/src/commands/Ticket/claim.ts similarity index 83% rename from src/commands/Ticket/claim.js rename to src/commands/Ticket/claim.ts index d1d1c1f938..6b885b566c 100644 --- a/src/commands/Ticket/claim.js +++ b/src/commands/Ticket/claim.ts @@ -1,5 +1,6 @@ +import type { Command } from '../../types/index.js'; import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, type TextChannel } from 'discord.js'; import { successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; @@ -27,7 +28,7 @@ export default { return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the `Manage Channels` permission or the configured `Ticket Staff Role` to claim tickets.' }); } - await claimTicket(interaction.channel, interaction.user); + await claimTicket(interaction.channel as TextChannel, interaction.user); await InteractionHelper.safeEditReply(interaction, { embeds: [ @@ -41,10 +42,10 @@ export default { logger.info('Ticket claimed successfully', { userId: interaction.user.id, userTag: interaction.user.tag, - channelId: interaction.channel.id, - channelName: interaction.channel.name, + channelId: interaction.channel!.id, + channelName: (interaction.channel as TextChannel).name, guildId: interaction.guildId, commandName: 'claim' }); }, -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Ticket/close.js b/src/commands/Ticket/close.ts similarity index 87% rename from src/commands/Ticket/close.js rename to src/commands/Ticket/close.ts index 056a699b9b..9f3af092b4 100644 --- a/src/commands/Ticket/close.js +++ b/src/commands/Ticket/close.ts @@ -1,5 +1,6 @@ +import type { Command } from '../../types/index.js'; import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, MessageFlags } from 'discord.js'; +import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, MessageFlags, type TextChannel } from 'discord.js'; import { successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; @@ -37,7 +38,7 @@ export default { interaction.options?.getString("reason") || "Closed via command without a specific reason."; - await closeTicket(interaction.channel, interaction.user, reason); + await closeTicket(interaction.channel as TextChannel, interaction.user, reason); await InteractionHelper.safeEditReply(interaction, { embeds: [ @@ -51,11 +52,11 @@ export default { logger.info('Ticket closed successfully', { userId: interaction.user.id, userTag: interaction.user.tag, - channelId: interaction.channel.id, - channelName: interaction.channel.name, + channelId: interaction.channel!.id, + channelName: (interaction.channel as TextChannel).name, guildId: interaction.guildId, reason: reason, commandName: 'close' }); }, -}; +} satisfies Command; diff --git a/src/commands/Ticket/modules/ticket_dashboard.js b/src/commands/Ticket/modules/ticket_dashboard.ts similarity index 84% rename from src/commands/Ticket/modules/ticket_dashboard.js rename to src/commands/Ticket/modules/ticket_dashboard.ts index efadfde941..0c8ec7a9f0 100644 --- a/src/commands/Ticket/modules/ticket_dashboard.js +++ b/src/commands/Ticket/modules/ticket_dashboard.ts @@ -1,3 +1,4 @@ +import type { Command, GuildConfig, NexoClient, SubcommandModule } from '../../../types/index.js'; import { getColor } from '../../../config/bot.js'; import { ActionRowBuilder, @@ -15,26 +16,33 @@ import { MessageFlags, ComponentType, EmbedBuilder, + type ButtonInteraction, + type CategoryChannel, + type ChatInputCommandInteraction, + type Guild, + type GuildTextBasedChannel, + type StringSelectMenuInteraction, } from 'discord.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; import { successEmbed, infoEmbed } from '../../../utils/embeds.js'; import { logger } from '../../../utils/logger.js'; -import { NexoBotError, ErrorTypes, replyUserError } from '../../../utils/errorHandler.js'; +import { NexoBotError, ErrorTypes, replyUserError, type ErrorLike } from '../../../utils/errorHandler.js'; import { getGuildConfig, setGuildConfig } from '../../../services/config/guildConfig.js'; -import { getGuildTicketStats } from '../../../utils/database/tickets.js'; +import { getGuildTicketStats, type GuildTicketStats } from '../../../utils/database/tickets.js'; import { getUserTicketCount } from '../../../services/ticket.js'; import { getTicketPanelStatus, messageHasButtonCustomId, formatPanelStatusField, + type PanelStatus, } from '../../../utils/panelStatus.js'; import { startDashboardSession } from '../../../utils/dashboardSession.js'; -function buildButtonRow(guildConfig, guildId, disabled = false, panelStatus = null) { +function buildButtonRow(guildConfig: GuildConfig, guildId: string, disabled = false, panelStatus: PanelStatus | null = null) { const dmEnabled = guildConfig.dmOnClose !== false; const showRepost = panelStatus?.exists === false && panelStatus?.reason === 'panel_deleted'; - const buttons = []; + const buttons: ButtonBuilder[] = []; if (showRepost) { buttons.push( @@ -68,10 +76,10 @@ function buildButtonRow(guildConfig, guildId, disabled = false, panelStatus = nu .setDisabled(disabled), ); - return new ActionRowBuilder().addComponents(buttons); + return new ActionRowBuilder().addComponents(buttons); } -async function persistPanelMessageId(client, guildId, guildConfig, messageId) { +async function persistPanelMessageId(client: NexoClient, guildId: string, guildConfig: GuildConfig, messageId: string | null | undefined) { if (!messageId || guildConfig.ticketPanelMessageId === messageId) return; guildConfig.ticketPanelMessageId = messageId; if (client.db) { @@ -79,15 +87,15 @@ async function persistPanelMessageId(client, guildId, guildConfig, messageId) { } } -function buildPanelEmbed(config) { +function buildPanelEmbed(config: GuildConfig) { return new EmbedBuilder() .setTitle('Support Tickets') .setDescription(config.ticketPanelMessage || 'Click the button below to create a support ticket.') .setColor(getColor('info')); } -function buildPanelButtonRow(config) { - return new ActionRowBuilder().addComponents( +function buildPanelButtonRow(config: GuildConfig) { + return new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('create_ticket') .setLabel(config.ticketButtonLabel || 'Create Ticket') @@ -96,8 +104,8 @@ function buildPanelButtonRow(config) { ); } -async function repostTicketPanel(client, guild, guildConfig, guildId) { - const channel = await guild.channels.fetch(guildConfig.ticketPanelChannelId).catch(() => null); +async function repostTicketPanel(client: NexoClient, guild: Guild, guildConfig: GuildConfig, guildId: string) { + const channel = await guild.channels.fetch(guildConfig.ticketPanelChannelId!).catch(() => null) as GuildTextBasedChannel | null; if (!channel) { throw new NexoBotError( 'Panel channel missing', @@ -115,7 +123,7 @@ async function repostTicketPanel(client, guild, guildConfig, guildId) { return sentPanel; } -function formatCloseDuration(ms) { +function formatCloseDuration(ms: number | null | undefined) { if (ms == null) return '`N/A`'; const hours = Math.floor(ms / 3_600_000); const minutes = Math.floor((ms % 3_600_000) / 60_000); @@ -123,7 +131,7 @@ function formatCloseDuration(ms) { return `${minutes}m`; } -function buildDashboardEmbed(config, guild, panelStatus = null, ticketStats = null) { +function buildDashboardEmbed(config: GuildConfig, guild: Guild, panelStatus: PanelStatus | null = null, ticketStats: GuildTicketStats | null = null) { const panelChannel = config.ticketPanelChannelId ? `<#${config.ticketPanelChannelId}>` : '`Not set`'; const staffRole = config.ticketStaffRoleId ? `<@&${config.ticketStaffRoleId}>` : '`Not set`'; const ticketLogsChannel = config.ticketLogsChannelId ? `<#${config.ticketLogsChannelId}>` : '`Not set`'; @@ -174,7 +182,7 @@ function buildDashboardEmbed(config, guild, panelStatus = null, ticketStats = nu .setTimestamp(); } -function buildSelectMenu(guildId) { +function buildSelectMenu(guildId: string) { return new StringSelectMenuBuilder() .setCustomId(`ticket_config_${guildId}`) .setPlaceholder('Select a setting to configure...') @@ -217,9 +225,9 @@ function buildSelectMenu(guildId) { ); } -async function refreshDashboard(rootInteraction, guildConfig, guildId, client) { +async function refreshDashboard(rootInteraction: ChatInputCommandInteraction, guildConfig: GuildConfig, guildId: string, client: NexoClient) { const panelStatus = client - ? await getTicketPanelStatus(client, rootInteraction.guild, guildConfig) + ? await getTicketPanelStatus(client, rootInteraction.guild!, guildConfig) : null; const ticketStats = client ? await getGuildTicketStats(guildId) : null; @@ -228,14 +236,14 @@ async function refreshDashboard(rootInteraction, guildConfig, guildId, client) { } const buttonRow = buildButtonRow(guildConfig, guildId, false, panelStatus); - const selectRow = new ActionRowBuilder().addComponents(buildSelectMenu(guildId)); + const selectRow = new ActionRowBuilder().addComponents(buildSelectMenu(guildId)); await InteractionHelper.safeEditReply(rootInteraction, { - embeds: [buildDashboardEmbed(guildConfig, rootInteraction.guild, panelStatus, ticketStats)], + embeds: [buildDashboardEmbed(guildConfig, rootInteraction.guild!, panelStatus, ticketStats)], components: [buttonRow, selectRow], }).catch(() => {}); } -async function updateLivePanel(client, guild, config, guildId) { +async function updateLivePanel(client: NexoClient, guild: Guild, config: GuildConfig, guildId: string) { if (!config.ticketPanelChannelId) return false; try { const panelStatus = await getTicketPanelStatus(client, guild, config); @@ -250,7 +258,7 @@ async function updateLivePanel(client, guild, config, guildId) { }); return true; } catch (error) { - logger.warn('Failed to update live ticket panel:', error.message); + logger.warn('Failed to update live ticket panel:', (error as ErrorLike).message); return false; } } @@ -259,7 +267,7 @@ export default { prefixOnly: false, async execute(interaction, config, client) { try { - const guildId = interaction.guild.id; + const guildId = interaction.guild!.id; const guildConfig = await getGuildConfig(client, guildId); if (!guildConfig.ticketPanelChannelId) { @@ -270,19 +278,19 @@ export default { ); } - const panelStatus = await getTicketPanelStatus(client, interaction.guild, guildConfig); + const panelStatus = await getTicketPanelStatus(client, interaction.guild!, guildConfig); if (panelStatus.recoveredId) { await persistPanelMessageId(client, guildId, guildConfig, panelStatus.recoveredId); } const ticketStats = await getGuildTicketStats(guildId); - const selectRow = new ActionRowBuilder().addComponents(buildSelectMenu(guildId)); + const selectRow = new ActionRowBuilder().addComponents(buildSelectMenu(guildId)); const buttonRow = buildButtonRow(guildConfig, guildId, false, panelStatus); await startDashboardSession({ interaction, - embeds: [buildDashboardEmbed(guildConfig, interaction.guild, panelStatus, ticketStats)], + embeds: [buildDashboardEmbed(guildConfig, interaction.guild!, panelStatus, ticketStats)], components: [buttonRow, selectRow], selectMenuId: `ticket_config_${guildId}`, buttonMatcher: (customId) => @@ -335,20 +343,20 @@ export default { if (error instanceof NexoBotError) throw error; logger.error('Unexpected error in ticket_config:', error); throw new NexoBotError( - `Ticket config failed: ${error.message}`, + `Ticket config failed: ${(error as ErrorLike).message}`, ErrorTypes.UNKNOWN, 'Failed to open the ticket configuration dashboard.', ); } }, -}; +} satisfies SubcommandModule & Pick; -async function handlePanelMessage(selectInteraction, rootInteraction, guildConfig, guildId, client) { +async function handlePanelMessage(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, guildConfig: GuildConfig, guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId('ticket_cfg_panel_msg') .setTitle('📝 Edit Panel Message') .addComponents( - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('panel_msg_input') .setLabel('Panel Message') @@ -380,7 +388,7 @@ async function handlePanelMessage(selectInteraction, rootInteraction, guildConfi guildConfig.ticketPanelMessage = newMessage; await setGuildConfig(client, guildId, guildConfig); - const panelUpdated = await updateLivePanel(client, rootInteraction.guild, guildConfig, guildId); + const panelUpdated = await updateLivePanel(client, rootInteraction.guild!, guildConfig, guildId); await submitted.reply({ embeds: [ @@ -399,12 +407,12 @@ async function handlePanelMessage(selectInteraction, rootInteraction, guildConfi await refreshDashboard(rootInteraction, guildConfig, guildId, client); } -async function handleButtonLabel(selectInteraction, rootInteraction, guildConfig, guildId, client) { +async function handleButtonLabel(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, guildConfig: GuildConfig, guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId('ticket_cfg_btn_label') .setTitle('🏷️ Edit Button Label') .addComponents( - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('btn_label_input') .setLabel('Button Label (max 80 characters)') @@ -433,7 +441,7 @@ async function handleButtonLabel(selectInteraction, rootInteraction, guildConfig guildConfig.ticketButtonLabel = newLabel; await setGuildConfig(client, guildId, guildConfig); - const panelUpdated = await updateLivePanel(client, rootInteraction.guild, guildConfig, guildId); + const panelUpdated = await updateLivePanel(client, rootInteraction.guild!, guildConfig, guildId); await submitted.reply({ embeds: [ @@ -452,7 +460,7 @@ async function handleButtonLabel(selectInteraction, rootInteraction, guildConfig await refreshDashboard(rootInteraction, guildConfig, guildId, client); } -async function handleStaffRole(selectInteraction, rootInteraction, guildConfig, guildId, client) { +async function handleStaffRole(selectInteraction: StringSelectMenuInteraction | ButtonInteraction, rootInteraction: ChatInputCommandInteraction, guildConfig: GuildConfig, guildId: string, client: NexoClient) { await selectInteraction.deferUpdate(); const roleSelect = new RoleSelectMenuBuilder() @@ -460,7 +468,7 @@ async function handleStaffRole(selectInteraction, rootInteraction, guildConfig, .setPlaceholder('Select the staff role...') .setMaxValues(1); - const row = new ActionRowBuilder().addComponents(roleSelect); + const row = new ActionRowBuilder().addComponents(roleSelect); await selectInteraction.followUp({ embeds: [ @@ -475,7 +483,7 @@ async function handleStaffRole(selectInteraction, rootInteraction, guildConfig, flags: MessageFlags.Ephemeral, }); - const roleCollector = rootInteraction.channel.createMessageComponentCollector({ + const roleCollector = rootInteraction.channel!.createMessageComponentCollector({ componentType: ComponentType.RoleSelect, filter: i => i.user.id === selectInteraction.user.id && i.customId === 'ticket_cfg_staff_role', @@ -485,7 +493,7 @@ async function handleStaffRole(selectInteraction, rootInteraction, guildConfig, roleCollector.on('collect', async roleInteraction => { await roleInteraction.deferUpdate(); - const role = roleInteraction.roles.first(); + const role = roleInteraction.roles.first()!; guildConfig.ticketStaffRoleId = role.id; await setGuildConfig(client, guildId, guildConfig); @@ -508,7 +516,7 @@ async function handleStaffRole(selectInteraction, rootInteraction, guildConfig, }); } -async function handleOpenCategory(selectInteraction, rootInteraction, guildConfig, guildId, client) { +async function handleOpenCategory(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, guildConfig: GuildConfig, guildId: string, client: NexoClient) { await selectInteraction.deferUpdate(); const channelSelect = new ChannelSelectMenuBuilder() @@ -526,11 +534,11 @@ async function handleOpenCategory(selectInteraction, rootInteraction, guildConfi ) .setColor(getColor('info')), ], - components: [new ActionRowBuilder().addComponents(channelSelect)], + components: [new ActionRowBuilder().addComponents(channelSelect)], flags: MessageFlags.Ephemeral, }); - const catCollector = rootInteraction.channel.createMessageComponentCollector({ + const catCollector = rootInteraction.channel!.createMessageComponentCollector({ componentType: ComponentType.ChannelSelect, filter: i => i.user.id === selectInteraction.user.id && i.customId === 'ticket_cfg_open_cat', @@ -540,7 +548,7 @@ async function handleOpenCategory(selectInteraction, rootInteraction, guildConfi catCollector.on('collect', async catInteraction => { await catInteraction.deferUpdate(); - const category = catInteraction.channels.first(); + const category = catInteraction.channels.first() as CategoryChannel; guildConfig.ticketCategoryId = category.id; await setGuildConfig(client, guildId, guildConfig); @@ -568,7 +576,7 @@ async function handleOpenCategory(selectInteraction, rootInteraction, guildConfi }); } -async function handleClosedCategory(selectInteraction, rootInteraction, guildConfig, guildId, client) { +async function handleClosedCategory(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, guildConfig: GuildConfig, guildId: string, client: NexoClient) { await selectInteraction.deferUpdate(); const channelSelect = new ChannelSelectMenuBuilder() @@ -586,11 +594,11 @@ async function handleClosedCategory(selectInteraction, rootInteraction, guildCon ) .setColor(getColor('info')), ], - components: [new ActionRowBuilder().addComponents(channelSelect)], + components: [new ActionRowBuilder().addComponents(channelSelect)], flags: MessageFlags.Ephemeral, }); - const catCollector = rootInteraction.channel.createMessageComponentCollector({ + const catCollector = rootInteraction.channel!.createMessageComponentCollector({ componentType: ComponentType.ChannelSelect, filter: i => i.user.id === selectInteraction.user.id && i.customId === 'ticket_cfg_closed_cat', @@ -600,7 +608,7 @@ async function handleClosedCategory(selectInteraction, rootInteraction, guildCon catCollector.on('collect', async catInteraction => { await catInteraction.deferUpdate(); - const category = catInteraction.channels.first(); + const category = catInteraction.channels.first() as CategoryChannel; guildConfig.ticketClosedCategoryId = category.id; await setGuildConfig(client, guildId, guildConfig); @@ -628,12 +636,12 @@ async function handleClosedCategory(selectInteraction, rootInteraction, guildCon }); } -async function handleMaxTickets(selectInteraction, rootInteraction, guildConfig, guildId, client) { +async function handleMaxTickets(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, guildConfig: GuildConfig, guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId('ticket_cfg_max_tickets') .setTitle('Set Max Tickets per User') .addComponents( - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('max_tickets_input') .setLabel('Max Open Tickets (1–10)') @@ -685,7 +693,7 @@ async function handleMaxTickets(selectInteraction, rootInteraction, guildConfig, await refreshDashboard(rootInteraction, guildConfig, guildId, client); } -async function handleDmOnClose(btnInteraction, rootInteraction, guildConfig, guildId, client) { +async function handleDmOnClose(btnInteraction: ButtonInteraction, rootInteraction: ChatInputCommandInteraction, guildConfig: GuildConfig, guildId: string, client: NexoClient) { await btnInteraction.deferUpdate(); const newState = guildConfig.dmOnClose === false; @@ -705,7 +713,7 @@ async function handleDmOnClose(btnInteraction, rootInteraction, guildConfig, gui await refreshDashboard(rootInteraction, guildConfig, guildId, client); } -async function handleLogsChannel(selectInteraction, rootInteraction, guildConfig, guildId, client) { +async function handleLogsChannel(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, guildConfig: GuildConfig, guildId: string, client: NexoClient) { await selectInteraction.deferUpdate(); const channelSelect = new ChannelSelectMenuBuilder() @@ -721,11 +729,11 @@ async function handleLogsChannel(selectInteraction, rootInteraction, guildConfig .setDescription('Choose where ticket feedback, lifecycle events (open, close, claim, etc.), and other logs will be sent.') .setColor(getColor('info')), ], - components: [new ActionRowBuilder().addComponents(channelSelect)], + components: [new ActionRowBuilder().addComponents(channelSelect)], flags: MessageFlags.Ephemeral, }); - const collector = rootInteraction.channel.createMessageComponentCollector({ + const collector = rootInteraction.channel!.createMessageComponentCollector({ componentType: ComponentType.ChannelSelect, filter: i => i.user.id === selectInteraction.user.id && i.customId === 'ticket_cfg_logs_channel', time: 60_000, @@ -734,7 +742,7 @@ async function handleLogsChannel(selectInteraction, rootInteraction, guildConfig collector.on('collect', async channelInteraction => { await channelInteraction.deferUpdate(); - const channel = channelInteraction.channels.first(); + const channel = channelInteraction.channels.first()!; guildConfig.ticketLogsChannelId = channel.id; await setGuildConfig(client, guildId, guildConfig); @@ -757,7 +765,7 @@ async function handleLogsChannel(selectInteraction, rootInteraction, guildConfig }); } -async function handleTranscriptChannel(selectInteraction, rootInteraction, guildConfig, guildId, client) { +async function handleTranscriptChannel(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, guildConfig: GuildConfig, guildId: string, client: NexoClient) { await selectInteraction.deferUpdate(); const channelSelect = new ChannelSelectMenuBuilder() @@ -773,11 +781,11 @@ async function handleTranscriptChannel(selectInteraction, rootInteraction, guild .setDescription('Choose where auto-generated transcripts will be sent when tickets are deleted.') .setColor(getColor('info')) ], - components: [new ActionRowBuilder().addComponents(channelSelect)], + components: [new ActionRowBuilder().addComponents(channelSelect)], flags: MessageFlags.Ephemeral }); - const collector = rootInteraction.channel.createMessageComponentCollector({ + const collector = rootInteraction.channel!.createMessageComponentCollector({ componentType: ComponentType.ChannelSelect, filter: i => i.user.id === selectInteraction.user.id && i.customId === 'ticket_cfg_transcript_channel', time: 60_000, @@ -786,7 +794,7 @@ async function handleTranscriptChannel(selectInteraction, rootInteraction, guild collector.on('collect', async channelInteraction => { await channelInteraction.deferUpdate(); - const channel = channelInteraction.channels.first(); + const channel = channelInteraction.channels.first()!; guildConfig.ticketTranscriptChannelId = channel.id; await setGuildConfig(client, guildId, guildConfig); @@ -809,7 +817,7 @@ async function handleTranscriptChannel(selectInteraction, rootInteraction, guild }); } -async function handleCheckUser(selectInteraction, rootInteraction, guildConfig, guildId, client) { +async function handleCheckUser(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, guildConfig: GuildConfig, guildId: string, client: NexoClient) { await selectInteraction.deferUpdate(); const userSelect = new UserSelectMenuBuilder() @@ -817,7 +825,7 @@ async function handleCheckUser(selectInteraction, rootInteraction, guildConfig, .setPlaceholder('Select a user to check...') .setMaxValues(1); - const row = new ActionRowBuilder().addComponents(userSelect); + const row = new ActionRowBuilder().addComponents(userSelect); await selectInteraction.followUp({ embeds: [ @@ -830,7 +838,7 @@ async function handleCheckUser(selectInteraction, rootInteraction, guildConfig, flags: MessageFlags.Ephemeral, }); - const userCollector = rootInteraction.channel.createMessageComponentCollector({ + const userCollector = rootInteraction.channel!.createMessageComponentCollector({ componentType: ComponentType.UserSelect, filter: i => i.user.id === selectInteraction.user.id && i.customId === 'ticket_cfg_check_user', @@ -840,7 +848,7 @@ async function handleCheckUser(selectInteraction, rootInteraction, guildConfig, userCollector.on('collect', async userInteraction => { await userInteraction.deferUpdate(); - const targetUser = userInteraction.users.first(); + const targetUser = userInteraction.users.first()!; const maxTickets = guildConfig.maxTicketsPerUser || 3; const openCount = await getUserTicketCount(guildId, targetUser.id); const atLimit = openCount >= maxTickets; @@ -874,10 +882,10 @@ async function handleCheckUser(selectInteraction, rootInteraction, guildConfig, }); } -async function handleRepostPanel(btnInteraction, rootInteraction, guildConfig, guildId, client) { +async function handleRepostPanel(btnInteraction: ButtonInteraction, rootInteraction: ChatInputCommandInteraction, guildConfig: GuildConfig, guildId: string, client: NexoClient) { await btnInteraction.deferUpdate(); - const panelStatus = await getTicketPanelStatus(client, rootInteraction.guild, guildConfig); + const panelStatus = await getTicketPanelStatus(client, rootInteraction.guild!, guildConfig); if (panelStatus.exists) { await btnInteraction.followUp({ embeds: [infoEmbed('Panel Already Active', 'The ticket panel is already posted in the configured channel.')], @@ -887,7 +895,7 @@ async function handleRepostPanel(btnInteraction, rootInteraction, guildConfig, g return; } - const sentPanel = await repostTicketPanel(client, rootInteraction.guild, guildConfig, guildId); + const sentPanel = await repostTicketPanel(client, rootInteraction.guild!, guildConfig, guildId); await btnInteraction.followUp({ embeds: [ @@ -904,12 +912,12 @@ async function handleRepostPanel(btnInteraction, rootInteraction, guildConfig, g await refreshDashboard(rootInteraction, guildConfig, guildId, client); } -async function handleDeleteSystem(btnInteraction, rootInteraction, guildConfig, guildId, client) { +async function handleDeleteSystem(btnInteraction: ButtonInteraction, rootInteraction: ChatInputCommandInteraction, guildConfig: GuildConfig, guildId: string, client: NexoClient) { const deleteModal = new ModalBuilder() .setCustomId('ticket_delete_confirm_modal') .setTitle('Delete Ticket System') .addComponents( - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('delete_confirmation') .setLabel('Type "DELETE" to confirm') @@ -945,7 +953,7 @@ async function handleDeleteSystem(btnInteraction, rootInteraction, guildConfig, await submitted.deferUpdate(); - const keysToDelete = [ + const keysToDelete: (keyof GuildConfig)[] = [ 'ticketPanelChannelId', 'ticketPanelMessageId', 'ticketStaffRoleId', @@ -959,7 +967,7 @@ async function handleDeleteSystem(btnInteraction, rootInteraction, guildConfig, if (guildConfig.ticketPanelChannelId) { try { - const panelChannel = await client.guilds.cache.get(guildId)?.channels.fetch(guildConfig.ticketPanelChannelId).catch(() => null); + const panelChannel = await client.guilds.cache.get(guildId)?.channels.fetch(guildConfig.ticketPanelChannelId).catch(() => null) as GuildTextBasedChannel | null | undefined; if (panelChannel) { if (guildConfig.ticketPanelMessageId) { const panelMessage = await panelChannel.messages.fetch(guildConfig.ticketPanelMessageId).catch(() => null); @@ -976,7 +984,7 @@ async function handleDeleteSystem(btnInteraction, rootInteraction, guildConfig, } } } catch (panelDeleteError) { - logger.warn('Could not delete ticket panel message:', panelDeleteError.message); + logger.warn('Could not delete ticket panel message:', (panelDeleteError as ErrorLike).message); } } @@ -989,7 +997,7 @@ async function handleDeleteSystem(btnInteraction, rootInteraction, guildConfig, ); } } catch (ticketDeleteError) { - logger.warn('Could not clear ticket records from database:', ticketDeleteError.message); + logger.warn('Could not clear ticket records from database:', (ticketDeleteError as ErrorLike).message); } for (const key of keysToDelete) { diff --git a/src/commands/Ticket/priority.js b/src/commands/Ticket/priority.ts similarity index 86% rename from src/commands/Ticket/priority.js rename to src/commands/Ticket/priority.ts index fde4195156..c424e024cb 100644 --- a/src/commands/Ticket/priority.js +++ b/src/commands/Ticket/priority.ts @@ -1,5 +1,6 @@ +import type { Command } from '../../types/index.js'; import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, type TextChannel } from 'discord.js'; import { successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; @@ -42,8 +43,8 @@ export default { 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 priorityLevel = interaction.options.getString("level"); - await updateTicketPriority(interaction.channel, priorityLevel, interaction.user); + const priorityLevel = interaction.options.getString("level")!; + await updateTicketPriority(interaction.channel as TextChannel, priorityLevel, interaction.user); await InteractionHelper.safeEditReply(interaction, { embeds: [ @@ -57,11 +58,11 @@ export default { logger.info('Ticket priority updated successfully', { userId: interaction.user.id, userTag: interaction.user.tag, - channelId: interaction.channel.id, - channelName: interaction.channel.name, + channelId: interaction.channel!.id, + channelName: (interaction.channel as TextChannel).name, guildId: interaction.guildId, priority: priorityLevel, commandName: 'priority' }); }, -}; +} satisfies Command; diff --git a/src/commands/Ticket/ticket.js b/src/commands/Ticket/ticket.ts similarity index 95% rename from src/commands/Ticket/ticket.js rename to src/commands/Ticket/ticket.ts index 982747b118..6e25d5b573 100644 --- a/src/commands/Ticket/ticket.js +++ b/src/commands/Ticket/ticket.ts @@ -1,10 +1,11 @@ +import type { Command } from '../../types/index.js'; import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js'; +import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags, type GuildMember, type TextChannel } from 'discord.js'; import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.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'; +import { handleInteractionError, replyUserError, ErrorTypes, type ErrorLike } from '../../utils/errorHandler.js'; import ticketConfig from './modules/ticket_dashboard.js'; @@ -100,7 +101,7 @@ export default { } if ( - !interaction.member.permissions.has( + !(interaction.member as GuildMember).permissions.has( PermissionFlagsBits.ManageChannels, ) ) { @@ -119,13 +120,13 @@ export default { } if (subcommand === "setup") { - const existingConfig = await getGuildConfig(client, interaction.guildId); + const existingConfig = await getGuildConfig(client, interaction.guildId!); if (existingConfig?.ticketPanelChannelId) { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `This server already has a ticket system set up (panel in <#${existingConfig.ticketPanelChannelId}>).\n\nOnly one ticket system is supported per server. Use \`/ticket dashboard\` to edit or update the existing setup, or select **Delete System** from the dashboard to remove it and start fresh.` }); } const panelChannel = - interaction.options.getChannel("panel_channel"); + interaction.options.getChannel("panel_channel") as TextChannel; const categoryChannel = interaction.options.getChannel("category"); const closedCategoryChannel = interaction.options.getChannel("closed_category"); const staffRole = interaction.options.getRole("staff_role"); @@ -142,7 +143,7 @@ description: panelMessage, color: getColor('info') }); - const ticketButton = new ActionRowBuilder().addComponents( + const ticketButton = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId("create_ticket") .setLabel(buttonLabel) @@ -274,8 +275,8 @@ description: panelMessage, } catch (error) { logger.error('Ticket setup error', { - error: error.message, - stack: error.stack, + error: (error as ErrorLike).message, + stack: (error as ErrorLike).stack, userId: interaction.user.id, guildId: interaction.guildId, commandName: 'ticket_setup' @@ -296,4 +297,4 @@ description: panelMessage, } } } -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Tools/baseconvert.js b/src/commands/Tools/baseconvert.ts similarity index 93% rename from src/commands/Tools/baseconvert.js rename to src/commands/Tools/baseconvert.ts index 1ef560dc7e..0c8ffe09e9 100644 --- a/src/commands/Tools/baseconvert.js +++ b/src/commands/Tools/baseconvert.ts @@ -4,8 +4,16 @@ import { logger } from '../../utils/logger.js'; import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { getColor } from '../../config/bot.js'; +import type { Command } from '../../types/index.js'; -const BASE_ALPHABETS = { +interface BaseInfo { + base: number; + prefix: string; + name: string; + alphabet: string; +} + +const BASE_ALPHABETS: Record = { 'BIN': { base: 2, prefix: '0b', name: 'Binary', alphabet: '01' }, 'OCT': { base: 8, prefix: '0o', name: 'Octal', alphabet: '0-7' }, 'DEC': { base: 10, prefix: '', name: 'Decimal', alphabet: '0-9' }, @@ -17,7 +25,7 @@ const BASE_ALPHABETS = { }; const BASE_NAMES = Object.entries(BASE_ALPHABETS).map(([key, { name }]) => ({ name: `${key} (${name})`, value: key })); -const BASE_CHARSETS = { +const BASE_CHARSETS: Record = { BIN: '01', OCT: '01234567', DEC: '0123456789', @@ -27,7 +35,7 @@ const BASE_CHARSETS = { B62: '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', }; -function parseBigIntFromBase(value, baseKey) { +function parseBigIntFromBase(value: string, baseKey: string): bigint { if (baseKey === 'B64') { const bytes = Buffer.from(value, 'base64'); return bytes.reduce((acc, byte) => (acc * 256n) + BigInt(byte), 0n); @@ -56,7 +64,7 @@ function parseBigIntFromBase(value, baseKey) { return result; } -function formatBigIntToBase(value, baseKey) { +function formatBigIntToBase(value: bigint, baseKey: string): string { if (baseKey === 'B64') { if (value === 0n) { return Buffer.from([0]).toString('base64'); @@ -124,8 +132,8 @@ export default { return; } - const numberStr = interaction.options.getString('number').trim(); - const fromBase = interaction.options.getString('from'); + 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]; @@ -162,7 +170,7 @@ export default { }); } - let decimalValue; + let decimalValue: bigint; try { if (fromBase === 'B64') { decimalValue = parseBigIntFromBase(cleanNumber, fromBase); @@ -227,4 +235,4 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); } }, -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Tools/calculate.js b/src/commands/Tools/calculate.ts similarity index 88% rename from src/commands/Tools/calculate.js rename to src/commands/Tools/calculate.ts index 233b88ab8f..00603a6b43 100644 --- a/src/commands/Tools/calculate.js +++ b/src/commands/Tools/calculate.ts @@ -1,17 +1,34 @@ -import { SlashCommandBuilder, ButtonBuilder, ButtonStyle, ActionRowBuilder } from 'discord.js'; +import { SlashCommandBuilder, ButtonBuilder, ButtonStyle, ActionRowBuilder, type MessageComponentInteraction } from 'discord.js'; import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { evaluateMathExpression } from '../../utils/safeMathParser.js'; +import type { Command } from '../../types/index.js'; + +/** Pending operand-modal context, read by handlers/calculateModals. */ +export interface CalculationContext { + expression: string; + formattedResult: string; + operator: string; + messageId: string | undefined; + channelId: string; + userId: string; +} + +interface CalculationHistoryEntry { + expression: string; + result: string; + timestamp: number; +} -const calculationContexts = new Map(); +const calculationContexts = new Map(); -function evaluate(expression) { +function evaluate(expression: string) { return evaluateMathExpression(expression); } -const calculationHistory = new Map(); +const calculationHistory = new Map(); const MAX_HISTORY = 5; export { calculationContexts }; @@ -40,7 +57,7 @@ export default { return; } - const expression = interaction.options.getString("expression"); + const expression = interaction.options.getString("expression")!; if ( !/^[0-9+\-*/.()^%! ,<>=&|~?:\[\]{}a-z√π∞°]+$/i.test(expression) @@ -108,7 +125,7 @@ export default { calculationHistory.set(userId, []); } - const history = calculationHistory.get(userId); + const history = calculationHistory.get(userId)!; history.unshift({ expression, result: formattedResult, @@ -119,7 +136,7 @@ export default { history.pop(); } - const row = new ActionRowBuilder().addComponents( + const row = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`calc_${interaction.id}_add`) .setLabel("+") @@ -154,12 +171,12 @@ export default { components: [row], }); - const filter = (i) => + const filter = (i: MessageComponentInteraction) => i.customId.startsWith(`calc_${interaction.id}`) && i.user.id === interaction.user.id; const BUTTON_TIMEOUT = 300000; const collector = - interaction.channel.createMessageComponentCollector({ + interaction.channel!.createMessageComponentCollector({ filter, time: BUTTON_TIMEOUT, }); @@ -222,7 +239,8 @@ export default { expression, formattedResult, operator, - messageId: interaction.message?.id, + // ChatInputCommandInteraction has no .message, so this was always undefined in JS + messageId: undefined, channelId: interaction.channelId, userId: i.user.id }); @@ -272,7 +290,7 @@ export default { collector.on("end", (collected, reason) => { if (reason === "timeout") { const disabledRow = - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId( `calc_${interaction.id}_expired`, @@ -290,7 +308,7 @@ export default { }) .catch(console.error); } else { - const disabledRow = ActionRowBuilder.from( + const disabledRow = ActionRowBuilder.from( row, ).setComponents( row.components.map((component) => @@ -307,18 +325,19 @@ export default { logger.error('Calculation error:', error); let errorMessage = 'Failed to evaluate the expression.'; + const errorText = error instanceof Error ? error.message : String(error); - if (error.message.includes('Unexpected type')) { + if (errorText.includes('Unexpected type')) { errorMessage += 'The expression contains an unsupported operation or function.'; - } else if (error.message.includes('Undefined symbol')) { + } else if (errorText.includes('Undefined symbol')) { errorMessage += 'The expression contains an undefined variable or function.'; - } else if (error.message.includes('Brackets not balanced')) { + } else if (errorText.includes('Brackets not balanced')) { errorMessage += 'The expression has unbalanced brackets.'; } else if ( - error.message.includes('Unexpected operator') || - error.message.includes('Unexpected character') + errorText.includes('Unexpected operator') || + errorText.includes('Unexpected character') ) { errorMessage += 'The expression contains an invalid operator or character.'; @@ -332,4 +351,4 @@ export default { }); } }, -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Tools/countdown.js b/src/commands/Tools/countdown.ts similarity index 79% rename from src/commands/Tools/countdown.js rename to src/commands/Tools/countdown.ts index d7561ddbf3..a7b583a578 100644 --- a/src/commands/Tools/countdown.js +++ b/src/commands/Tools/countdown.ts @@ -1,10 +1,22 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; +import { SlashCommandBuilder, MessageFlags, type ActionRowBuilder, type ButtonBuilder, type GuildTextBasedChannel, type Message } from 'discord.js'; import { successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { createControlButtons, formatTime, startCountdown } from '../../handlers/countdownButtons.js'; +import type { Command } from '../../types/index.js'; -const activeCountdowns = new Map(); +/** Live countdown state, shared with handlers/countdownButtons. */ +export interface CountdownData { + message: Message; + endTime: number; + remainingTime: number; + isPaused: boolean; + title: string; + lastUpdate: number; + interval: ReturnType | null; +} + +const activeCountdowns = new Map(); export { activeCountdowns }; @@ -63,19 +75,19 @@ export default { const endTime = Date.now() + totalSeconds * 1000; const countdownId = `${interaction.channelId}-${Date.now()}`; - const row = createControlButtons(countdownId); + const row = createControlButtons(countdownId) as ActionRowBuilder; const initialEmbed = successEmbed( `⏱️ ${title}`, `Time remaining: **${formatTime(totalSeconds)}**`, ); - const message = await interaction.channel.send({ + const message = await (interaction.channel as GuildTextBasedChannel).send({ embeds: [initialEmbed], components: [row], }); - const countdownData = { + const countdownData: CountdownData = { message, endTime, remainingTime: totalSeconds * 1000, @@ -93,4 +105,4 @@ export default { flags: MessageFlags.Ephemeral, }); }, -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Tools/embedbuilder.js b/src/commands/Tools/embedbuilder.ts similarity index 87% rename from src/commands/Tools/embedbuilder.js rename to src/commands/Tools/embedbuilder.ts index 1a99152ebf..92be6d42a4 100644 --- a/src/commands/Tools/embedbuilder.js +++ b/src/commands/Tools/embedbuilder.ts @@ -16,12 +16,49 @@ import { EmbedBuilder, LabelBuilder, RadioGroupBuilder, + type ButtonInteraction, + type ChatInputCommandInteraction, + type EmbedAuthorOptions, + type EmbedFooterOptions, + type Guild, + type NewsChannel, + type TextChannel, } from 'discord.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { NexoBotError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { getColor } from '../../config/bot.js'; +import type { Command } from '../../types/index.js'; + +interface EmbedAuthorState { + name: string; + iconUrl: string | null; + url: string | null; +} + +interface EmbedFooterState { + text: string; + iconUrl: string | null; +} + +interface EmbedFieldState { + name: string; + value: string; + inline: boolean; +} + +interface EmbedBuilderState { + title: string | null; + description: string | null; + color: string | number | null; + author: EmbedAuthorState | null; + footer: EmbedFooterState | null; + thumbnail: string | null; + image: string | null; + timestamp: boolean; + fields: EmbedFieldState[]; +} const MAX_FIELDS = 25; const IDLE_TIMEOUT = 900_000; @@ -40,7 +77,7 @@ const COLOR_PRESETS = [ { label: 'Custom Hex...', value: '__custom__', emoji: '' }, ]; -function isValidUrl(str) { +function isValidUrl(str: string) { try { const url = new URL(str); return url.protocol === 'http:' || url.protocol === 'https:'; @@ -49,11 +86,11 @@ function isValidUrl(str) { } } -function isValidHex(str) { +function isValidHex(str: string) { return /^#[0-9A-Fa-f]{6}$/.test(str); } -function resolveEmbedColor(value) { +function resolveEmbedColor(value: string | number | null) { try { const resolved = getColor(value || 'primary'); if (typeof resolved === 'number' && Number.isFinite(resolved) && resolved >= 0 && resolved <= 0xffffff) { @@ -65,7 +102,7 @@ function resolveEmbedColor(value) { return getColor('primary'); } -function buildPreviewEmbed(state) { +function buildPreviewEmbed(state: EmbedBuilderState) { const embed = new EmbedBuilder(); if (state.title) embed.setTitle(state.title.substring(0, 256)); @@ -74,14 +111,14 @@ function buildPreviewEmbed(state) { embed.setColor(resolveEmbedColor(state.color)); if (state.author?.name) { - const obj = { name: state.author.name.substring(0, 256) }; + const obj: EmbedAuthorOptions = { name: state.author.name.substring(0, 256) }; if (state.author.iconUrl && isValidUrl(state.author.iconUrl)) obj.iconURL = state.author.iconUrl; if (state.author.url && isValidUrl(state.author.url)) obj.url = state.author.url; embed.setAuthor(obj); } if (state.footer?.text) { - const obj = { text: state.footer.text.substring(0, 2048) }; + const obj: EmbedFooterOptions = { text: state.footer.text.substring(0, 2048) }; if (state.footer.iconUrl && isValidUrl(state.footer.iconUrl)) obj.iconURL = state.footer.iconUrl; embed.setFooter(obj); } @@ -104,8 +141,8 @@ function buildPreviewEmbed(state) { return embed; } -function buildDashboardEmbed(state) { - const trunc = (str, n) => +function buildDashboardEmbed(state: EmbedBuilderState) { + const trunc = (str: string, n: number) => str.length > n ? str.substring(0, n) + '…' : str; const lines = [ @@ -127,8 +164,8 @@ function buildDashboardEmbed(state) { .setFooter({ text: 'The preview above updates live · Closes after 5 min of inactivity' }); } -function buildMainMenu(state) { - const primaryRow = new ActionRowBuilder().addComponents( +function buildMainMenu(state: EmbedBuilderState) { + const primaryRow = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('eb_main_edit_content') .setLabel('Edit Content') @@ -151,7 +188,7 @@ function buildMainMenu(state) { .setEmoji('📤'), ); - const secondaryRow = new ActionRowBuilder().addComponents( + const secondaryRow = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('eb_main_add_field') .setLabel(`Add Field (${state.fields.length}/${MAX_FIELDS})`) @@ -176,7 +213,7 @@ function buildMainMenu(state) { .setEmoji('🕐'), ); - const tertiaryRow = new ActionRowBuilder().addComponents( + const tertiaryRow = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('eb_main_reorder_fields') .setLabel('Reorder Fields') @@ -198,19 +235,19 @@ function buildMainMenu(state) { return [primaryRow, secondaryRow, tertiaryRow]; } -async function refreshDashboard(interaction, state) { +async function refreshDashboard(interaction: ChatInputCommandInteraction, state: EmbedBuilderState) { return await InteractionHelper.safeEditReply(interaction, { embeds: [buildPreviewEmbed(state), buildDashboardEmbed(state)], components: buildMainMenu(state), }); } -async function handleEditContent(selectInteraction, rootInteraction, state) { +async function handleEditContent(selectInteraction: ButtonInteraction, rootInteraction: ChatInputCommandInteraction, state: EmbedBuilderState) { const modal = new ModalBuilder() .setCustomId('eb_content') .setTitle('Edit Content') .addComponents( - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('eb_title') .setLabel('Title (max 256 characters)') @@ -220,7 +257,7 @@ async function handleEditContent(selectInteraction, rootInteraction, state) { .setRequired(false) .setPlaceholder('My Embed Title'), ), - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('eb_description') .setLabel('Description (max 4000 characters)') @@ -252,7 +289,7 @@ async function handleEditContent(selectInteraction, rootInteraction, state) { await refreshDashboard(rootInteraction, state); } -async function handleSetColor(selectInteraction, rootInteraction, state) { +async function handleSetColor(selectInteraction: ButtonInteraction, rootInteraction: ChatInputCommandInteraction, state: EmbedBuilderState) { await selectInteraction.deferUpdate().catch(() => {}); const colorSelect = new StringSelectMenuBuilder() @@ -277,11 +314,11 @@ async function handleSetColor(selectInteraction, rootInteraction, state) { ) .setColor(getColor('info')), ], - components: [new ActionRowBuilder().addComponents(colorSelect)], + components: [new ActionRowBuilder().addComponents(colorSelect)], flags: MessageFlags.Ephemeral, }); - const colorCollector = rootInteraction.channel.createMessageComponentCollector({ + const colorCollector = rootInteraction.channel!.createMessageComponentCollector({ componentType: ComponentType.StringSelect, filter: i => i.user.id === selectInteraction.user.id && i.customId === 'eb_color_pick', @@ -298,7 +335,7 @@ async function handleSetColor(selectInteraction, rootInteraction, state) { .setCustomId('eb_custom_hex') .setTitle('Custom Color') .addComponents( - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('hex_value') .setLabel('Hex Color Code') @@ -341,17 +378,17 @@ async function handleSetColor(selectInteraction, rootInteraction, state) { await refreshDashboard(rootInteraction, state); } catch (error) { - logger.warn('Embed builder color picker interaction failed:', error.message); + logger.warn('Embed builder color picker interaction failed:', error instanceof Error ? error.message : String(error)); } }); } -async function handleSetAuthor(selectInteraction, rootInteraction, state) { +async function handleSetAuthor(selectInteraction: ButtonInteraction, rootInteraction: ChatInputCommandInteraction, state: EmbedBuilderState) { const modal = new ModalBuilder() .setCustomId('eb_author') .setTitle('Set Author') .addComponents( - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('author_name') .setLabel('Author Name (leave blank to remove)') @@ -361,7 +398,7 @@ async function handleSetAuthor(selectInteraction, rootInteraction, state) { .setRequired(false) .setPlaceholder('Your Name'), ), - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('author_icon') .setLabel('Author Icon URL (optional)') @@ -370,7 +407,7 @@ async function handleSetAuthor(selectInteraction, rootInteraction, state) { .setRequired(false) .setPlaceholder('https://example.com/icon.png'), ), - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('author_url') .setLabel('Author Link URL (optional)') @@ -418,12 +455,12 @@ async function handleSetAuthor(selectInteraction, rootInteraction, state) { await refreshDashboard(rootInteraction, state); } -async function handleSetFooter(selectInteraction, rootInteraction, state) { +async function handleSetFooter(selectInteraction: ButtonInteraction, rootInteraction: ChatInputCommandInteraction, state: EmbedBuilderState) { const modal = new ModalBuilder() .setCustomId('eb_footer') .setTitle('Set Footer') .addComponents( - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('footer_text') .setLabel('Footer Text (leave blank to remove)') @@ -433,7 +470,7 @@ async function handleSetFooter(selectInteraction, rootInteraction, state) { .setRequired(false) .setPlaceholder('Built with Nexo'), ), - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('footer_icon') .setLabel('Footer Icon URL (optional)') @@ -473,7 +510,7 @@ async function handleSetFooter(selectInteraction, rootInteraction, state) { await refreshDashboard(rootInteraction, state); } -async function handleSetImages(selectInteraction, rootInteraction, state) { +async function handleSetImages(selectInteraction: ButtonInteraction, rootInteraction: ChatInputCommandInteraction, state: EmbedBuilderState) { await selectInteraction.deferUpdate().catch(() => {}); const imageSelect = new StringSelectMenuBuilder() @@ -513,11 +550,11 @@ async function handleSetImages(selectInteraction, rootInteraction, state) { ) .setColor(getColor('info')), ], - components: [new ActionRowBuilder().addComponents(imageSelect)], + components: [new ActionRowBuilder().addComponents(imageSelect)], flags: MessageFlags.Ephemeral, }); - const imgMenuCollector = rootInteraction.channel.createMessageComponentCollector({ + const imgMenuCollector = rootInteraction.channel!.createMessageComponentCollector({ componentType: ComponentType.StringSelect, filter: i => i.user.id === selectInteraction.user.id && i.customId === 'eb_image_pick', @@ -548,7 +585,7 @@ async function handleSetImages(selectInteraction, rootInteraction, state) { .setCustomId('eb_image_url') .setTitle(isThumb ? 'Set Thumbnail' : 'Set Large Image') .addComponents( - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('image_url') .setLabel('Image URL') @@ -587,12 +624,12 @@ async function handleSetImages(selectInteraction, rootInteraction, state) { await submitted.deferUpdate().catch(() => {}); await refreshDashboard(rootInteraction, state); } catch (error) { - logger.warn('Embed builder image picker interaction failed:', error.message); + logger.warn('Embed builder image picker interaction failed:', error instanceof Error ? error.message : String(error)); } }); } -async function handleAddField(selectInteraction, rootInteraction, state) { +async function handleAddField(selectInteraction: ButtonInteraction, rootInteraction: ChatInputCommandInteraction, state: EmbedBuilderState) { if (state.fields.length >= MAX_FIELDS) { await selectInteraction.deferUpdate(); await replyUserError(selectInteraction, { @@ -664,7 +701,7 @@ async function handleAddField(selectInteraction, rootInteraction, state) { await refreshDashboard(rootInteraction, state); } -async function handleEditField(selectInteraction, rootInteraction, state) { +async function handleEditField(selectInteraction: ButtonInteraction, rootInteraction: ChatInputCommandInteraction, state: EmbedBuilderState) { await selectInteraction.deferUpdate(); const pickSelect = new StringSelectMenuBuilder() @@ -689,11 +726,11 @@ async function handleEditField(selectInteraction, rootInteraction, state) { .setDescription('Select the field you want to modify.') .setColor(getColor('info')), ], - components: [new ActionRowBuilder().addComponents(pickSelect)], + components: [new ActionRowBuilder().addComponents(pickSelect)], flags: MessageFlags.Ephemeral, }); - const pickCollector = rootInteraction.channel.createMessageComponentCollector({ + const pickCollector = rootInteraction.channel!.createMessageComponentCollector({ componentType: ComponentType.StringSelect, filter: i => i.user.id === selectInteraction.user.id && i.customId === 'eb_edit_field_pick', @@ -776,12 +813,12 @@ async function handleEditField(selectInteraction, rootInteraction, state) { await submitted.deferUpdate().catch(() => {}); await refreshDashboard(rootInteraction, state); } catch (error) { - logger.warn('Embed builder field edit interaction failed:', error.message); + logger.warn('Embed builder field edit interaction failed:', error instanceof Error ? error.message : String(error)); } }); } -async function handleRemoveField(selectInteraction, rootInteraction, state) { +async function handleRemoveField(selectInteraction: ButtonInteraction, rootInteraction: ChatInputCommandInteraction, state: EmbedBuilderState) { await selectInteraction.deferUpdate(); const pickSelect = new StringSelectMenuBuilder() @@ -806,11 +843,11 @@ async function handleRemoveField(selectInteraction, rootInteraction, state) { .setDescription('Select the field you want to delete.') .setColor(getColor('warning')), ], - components: [new ActionRowBuilder().addComponents(pickSelect)], + components: [new ActionRowBuilder().addComponents(pickSelect)], flags: MessageFlags.Ephemeral, }); - const removeCollector = rootInteraction.channel.createMessageComponentCollector({ + const removeCollector = rootInteraction.channel!.createMessageComponentCollector({ componentType: ComponentType.StringSelect, filter: i => i.user.id === selectInteraction.user.id && i.customId === 'eb_remove_field_pick', @@ -826,7 +863,7 @@ async function handleRemoveField(selectInteraction, rootInteraction, state) { }); } -async function handleReorderFields(selectInteraction, rootInteraction, state) { +async function handleReorderFields(selectInteraction: ButtonInteraction, rootInteraction: ChatInputCommandInteraction, state: EmbedBuilderState) { await selectInteraction.deferUpdate(); const pickSelect = new StringSelectMenuBuilder() @@ -851,11 +888,11 @@ async function handleReorderFields(selectInteraction, rootInteraction, state) { .setDescription('Select a field, then use the arrows to move it up or down.') .setColor(getColor('info')), ], - components: [new ActionRowBuilder().addComponents(pickSelect)], + components: [new ActionRowBuilder().addComponents(pickSelect)], flags: MessageFlags.Ephemeral, }); - const pickCollector = rootInteraction.channel.createMessageComponentCollector({ + const pickCollector = rootInteraction.channel!.createMessageComponentCollector({ componentType: ComponentType.StringSelect, filter: i => i.user.id === selectInteraction.user.id && i.customId === 'eb_reorder_pick', @@ -895,11 +932,11 @@ async function handleReorderFields(selectInteraction, rootInteraction, state) { ) .setColor(getColor('info')), ], - components: [new ActionRowBuilder().addComponents(upBtn, downBtn, cancelBtn)], + components: [new ActionRowBuilder().addComponents(upBtn, downBtn, cancelBtn)], flags: MessageFlags.Ephemeral, }); - const dirCollector = rootInteraction.channel.createMessageComponentCollector({ + const dirCollector = rootInteraction.channel!.createMessageComponentCollector({ componentType: ComponentType.Button, filter: i => i.user.id === selectInteraction.user.id && @@ -926,7 +963,7 @@ async function handleReorderFields(selectInteraction, rootInteraction, state) { }); } -async function handlePostEmbed(selectInteraction, rootInteraction, state, guild) { +async function handlePostEmbed(selectInteraction: ButtonInteraction, rootInteraction: ChatInputCommandInteraction, state: EmbedBuilderState, guild: Guild) { if ( !state.title && !state.description && @@ -955,11 +992,11 @@ async function handlePostEmbed(selectInteraction, rootInteraction, state, guild) .setDescription('Select the channel where this embed will be sent.') .setColor(getColor('info')), ], - components: [new ActionRowBuilder().addComponents(chanSelect)], + components: [new ActionRowBuilder().addComponents(chanSelect)], flags: MessageFlags.Ephemeral, }); - const chanCollector = rootInteraction.channel.createMessageComponentCollector({ + const chanCollector = rootInteraction.channel!.createMessageComponentCollector({ componentType: ComponentType.ChannelSelect, filter: i => i.user.id === selectInteraction.user.id && i.customId === 'eb_post_channel', @@ -969,7 +1006,7 @@ async function handlePostEmbed(selectInteraction, rootInteraction, state, guild) chanCollector.on('collect', async chanInter => { await chanInter.deferUpdate(); - const channel = chanInter.channels.first(); + const channel = chanInter.channels.first() as TextChannel | NewsChannel | undefined; if (!channel) { await replyUserError(chanInter, { @@ -979,7 +1016,7 @@ async function handlePostEmbed(selectInteraction, rootInteraction, state, guild) return; } - const perms = channel.permissionsFor(guild.members.me); + const perms = channel.permissionsFor(guild.members.me!); if (!perms?.has([PermissionFlagsBits.SendMessages, PermissionFlagsBits.EmbedLinks])) { await replyUserError(chanInter, { type: ErrorTypes.PERMISSION, @@ -1003,7 +1040,7 @@ async function handlePostEmbed(selectInteraction, rootInteraction, state, guild) }); } -async function handleJsonExport(selectInteraction, rootInteraction, state) { +async function handleJsonExport(selectInteraction: ButtonInteraction, rootInteraction: ChatInputCommandInteraction, state: EmbedBuilderState) { await selectInteraction.deferUpdate(); const previewEmbed = buildPreviewEmbed(state); @@ -1052,9 +1089,9 @@ export default { }); if (!deferSuccess) return; - const guild = interaction.guild; + const guild = interaction.guild!; - const state = { + const state: EmbedBuilderState = { title: null, description: null, color: getColor('primary'), @@ -1068,7 +1105,7 @@ export default { await refreshDashboard(interaction, state); - const collector = interaction.channel.createMessageComponentCollector({ + const collector = interaction.channel!.createMessageComponentCollector({ componentType: ComponentType.Button, filter: i => i.user.id === interaction.user.id && i.customId.startsWith('eb_main_'), @@ -1149,10 +1186,10 @@ export default { if (error instanceof NexoBotError) throw error; logger.error('Unexpected error in embedbuilder:', error); throw new NexoBotError( - `embedbuilder failed: ${error.message}`, + `embedbuilder failed: ${error instanceof Error ? error.message : String(error)}`, ErrorTypes.UNKNOWN, 'Failed to open the embed builder.', ); } }, -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Tools/generatepassword.js b/src/commands/Tools/generatepassword.ts similarity index 98% rename from src/commands/Tools/generatepassword.js rename to src/commands/Tools/generatepassword.ts index 51628bfed0..5a49afd0a2 100644 --- a/src/commands/Tools/generatepassword.js +++ b/src/commands/Tools/generatepassword.ts @@ -5,6 +5,7 @@ import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/ import { logger } from '../../utils/logger.js'; import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import type { Command } from '../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('generatepassword') @@ -143,4 +144,4 @@ export default { embeds: [embed], }); }, -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Tools/hexcolor.js b/src/commands/Tools/hexcolor.ts similarity index 89% rename from src/commands/Tools/hexcolor.js rename to src/commands/Tools/hexcolor.ts index 36736e3a6e..5db0f05f97 100644 --- a/src/commands/Tools/hexcolor.js +++ b/src/commands/Tools/hexcolor.ts @@ -1,10 +1,11 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; +import { SlashCommandBuilder, MessageFlags, type HexColorString } from 'discord.js'; import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; 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'; +import type { Command } from '../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('hexcolor') @@ -55,7 +56,7 @@ export default { `**HSL:** \`${rgbToHsl(r, g, b)}\`\n` + `**Name:** ${colorName || 'Custom Color'}` ) - .setColor(hexColor) + .setColor(hexColor as HexColorString) .setImage(colorPreviewUrl); if (isRandom) { @@ -71,12 +72,12 @@ export default { } ); }, -}; +} satisfies Command; -function rgbToHsl(r, g, b) { +function rgbToHsl(r: number, g: number, b: number): string { r /= 255, g /= 255, b /= 255; const max = Math.max(r, g, b), min = Math.min(r, g, b); - let h, s, l = (max + min) / 2; + let h: number | undefined, s: number, l = (max + min) / 2; if (max === min) { h = s = 0; @@ -88,14 +89,14 @@ h = s = 0; case g: h = (b - r) / d + 2; break; case b: h = (r - g) / d + 4; break; } - h /= 6; + h! /= 6; } - return `hsl(${Math.round(h * 360)}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%)`; + return `hsl(${Math.round(h! * 360)}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%)`; } -function getColorName(hex) { - const colors = { +function getColorName(hex: string): string | null { + const colors: Record = { '#FF0000': 'Red', '#00FF00': 'Green', '#0000FF': 'Blue', diff --git a/src/commands/Tools/poll.js b/src/commands/Tools/poll.ts similarity index 94% rename from src/commands/Tools/poll.js rename to src/commands/Tools/poll.ts index 772f1500c5..36be331c00 100644 --- a/src/commands/Tools/poll.js +++ b/src/commands/Tools/poll.ts @@ -1,8 +1,9 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; +import { SlashCommandBuilder, MessageFlags, type GuildTextBasedChannel } 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 { InteractionHelper } from '../../utils/interactionHelper.js'; +import type { Command } from '../../types/index.js'; const EMOJIS = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣', '🔟']; const MAX_OPTIONS = 10; export default { @@ -98,7 +99,7 @@ export default { description ); - const message = await interaction.channel.send({ embeds: [embed] }); + const message = await (interaction.channel as GuildTextBasedChannel).send({ embeds: [embed] }); for (let i = 0; i < options.length; i++) { await message.react(EMOJIS[i]); @@ -109,4 +110,4 @@ export default { content: '✅ Poll created successfully!', }); }, -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Tools/randomuser.js b/src/commands/Tools/randomuser.ts similarity index 88% rename from src/commands/Tools/randomuser.js rename to src/commands/Tools/randomuser.ts index 4dc15a6fd1..0ffec4e28b 100644 --- a/src/commands/Tools/randomuser.js +++ b/src/commands/Tools/randomuser.ts @@ -1,9 +1,10 @@ -import { SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; +import { SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, type ColorResolvable, type MessageComponentInteraction } from 'discord.js'; import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { getColor } from '../../config/bot.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import type { Command } from '../../types/index.js'; export default { data: new SlashCommandBuilder() @@ -83,7 +84,7 @@ export default { const user = selectedMember.user; const joinDate = selectedMember.joinedAt; const roles = selectedMember.roles.cache - .filter(role => role.id !== interaction.guild.id) + .filter(role => role.id !== interaction.guild!.id) .sort((a, b) => b.position - a.position) .map(role => role.toString()) .slice(0, 10); @@ -92,15 +93,16 @@ export default { '🎲 Random User Selected', shouldMention ? `${selectedMember}` : `**${user.username}**` ) - .setThumbnail(user.displayAvatarURL({ dynamic: true, size: 256 })) + .setThumbnail(user.displayAvatarURL({ 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'); + // NOTE: 'primary' is not a valid ColorResolvable (discord.js throws ColorConvert); kept as-is from JS + .setColor('primary' as ColorResolvable); - const row = new ActionRowBuilder() + const row = new ActionRowBuilder() .addComponents( new ButtonBuilder() .setCustomId(`randomuser_${interaction.user.id}_again`) @@ -115,12 +117,12 @@ export default { allowedMentions: { users: shouldMention ? [user.id] : [] } }); - const filter = (i) => i.customId === `randomuser_${interaction.user.id}_again` && i.user.id === interaction.user.id; + const filter = (i: MessageComponentInteraction) => 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 => { + let newMembers = interaction.guild!.members.cache.filter(member => { if (member.user.bot && !includeBots) return false; if (onlineOnly && member.presence?.status === 'offline') return false; @@ -149,7 +151,7 @@ export default { const newUser = newSelectedMember.user; const newRoles = newSelectedMember.roles.cache - .filter(r => r.id !== interaction.guild.id) + .filter(r => r.id !== interaction.guild!.id) .sort((a, b) => b.position - a.position) .map(r => r.toString()) .slice(0, 10); @@ -158,7 +160,7 @@ export default { '🎲 Random User Selected', shouldMention ? `${newSelectedMember}` : `**${newUser.username}**` ) - .setThumbnail(newUser.displayAvatarURL({ dynamic: true, size: 256 })) + .setThumbnail(newUser.displayAvatarURL({ size: 256 })) .addFields( { name: 'Username', value: newUser.username, inline: true }, { name: 'Bot', value: newUser.bot ? 'Yes' : 'No', inline: true }, @@ -183,11 +185,11 @@ export default { }); collector.on('end', () => { - const disabledRow = ActionRowBuilder.from(row).setComponents( + 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 +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Tools/shorten.js b/src/commands/Tools/shorten.ts similarity index 94% rename from src/commands/Tools/shorten.js rename to src/commands/Tools/shorten.ts index b78057fb1f..b0e21d38fc 100644 --- a/src/commands/Tools/shorten.js +++ b/src/commands/Tools/shorten.ts @@ -4,6 +4,7 @@ import { logger } from '../../utils/logger.js'; import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { getColor } from '../../config/bot.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import type { Command } from '../../types/index.js'; export default { data: new SlashCommandBuilder() @@ -37,7 +38,7 @@ export default { return; } - const url = interaction.options.getString("url"); + const url = interaction.options.getString("url")!; const custom = interaction.options.getString("custom"); try { @@ -73,7 +74,7 @@ export default { } }); } catch (networkError) { - const message = networkError?.name === 'AbortError' + const message = (networkError as { name?: string } | null | undefined)?.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, { @@ -119,4 +120,4 @@ export default { embeds: [embed], }); }, -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Tools/time.js b/src/commands/Tools/time.ts similarity index 97% rename from src/commands/Tools/time.js rename to src/commands/Tools/time.ts index d26db76f23..0fd885678a 100644 --- a/src/commands/Tools/time.js +++ b/src/commands/Tools/time.ts @@ -3,6 +3,7 @@ import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/ import { logger } from '../../utils/logger.js'; import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import type { Command } from '../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('time') @@ -59,4 +60,4 @@ export default { } ); }, -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Tools/unixtime.js b/src/commands/Tools/unixtime.ts similarity index 95% rename from src/commands/Tools/unixtime.js rename to src/commands/Tools/unixtime.ts index 7e8e7e7b91..a02f4b4c14 100644 --- a/src/commands/Tools/unixtime.js +++ b/src/commands/Tools/unixtime.ts @@ -3,6 +3,7 @@ import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from ' import { logger } from '../../utils/logger.js'; import { getColor } from '../../config/bot.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import type { Command } from '../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('unixtime') @@ -35,4 +36,4 @@ export default { } ); }, -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Utility/avatar.js b/src/commands/Utility/avatar.ts similarity index 88% rename from src/commands/Utility/avatar.js rename to src/commands/Utility/avatar.ts index b9caf87daf..cd07a9dbe6 100644 --- a/src/commands/Utility/avatar.js +++ b/src/commands/Utility/avatar.ts @@ -3,6 +3,7 @@ import { createEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import type { Command } from '../../types/index.js'; export default { data: new SlashCommandBuilder() .setName("avatar") @@ -17,7 +18,7 @@ export default { async execute(interaction) { const user = interaction.options.getUser("target") || interaction.user; - const avatarUrl = user.displayAvatarURL({ size: 2048, dynamic: true }); + const avatarUrl = user.displayAvatarURL({ size: 2048 }); const embed = createEmbed({ title: `${user.username}'s Avatar`, @@ -32,4 +33,4 @@ export default { guildId: interaction.guildId }); } -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Utility/firstmsg.js b/src/commands/Utility/firstmsg.ts similarity index 86% rename from src/commands/Utility/firstmsg.js rename to src/commands/Utility/firstmsg.ts index 1624921387..b577016337 100644 --- a/src/commands/Utility/firstmsg.js +++ b/src/commands/Utility/firstmsg.ts @@ -1,7 +1,8 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; +import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, type GuildTextBasedChannel } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { logger } from '../../utils/logger.js'; +import type { Command } from '../../types/index.js'; export default { data: new SlashCommandBuilder() .setName("firstmsg") @@ -21,7 +22,7 @@ export default { return; } - const messages = await interaction.channel.messages.fetch({ + const messages = await (interaction.channel as GuildTextBasedChannel).messages.fetch({ limit: 1, after: '1', cache: false @@ -45,7 +46,7 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( - "First Message in #" + interaction.channel.name, + "First Message in #" + (interaction.channel as GuildTextBasedChannel).name, `Message Link: ${messageLink}` ), ], @@ -58,4 +59,4 @@ export default { guildId: interaction.guildId }); }, -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Utility/modules/report.js b/src/commands/Utility/modules/report.ts similarity index 86% rename from src/commands/Utility/modules/report.js rename to src/commands/Utility/modules/report.ts index 86b34b7773..ba80f8ea76 100644 --- a/src/commands/Utility/modules/report.js +++ b/src/commands/Utility/modules/report.ts @@ -5,6 +5,7 @@ import { formatLogLine, resolveUserAuthor } from '../../../utils/logging/logEmbe import { InteractionHelper } from '../../../utils/interactionHelper.js'; import { replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; import { logger } from '../../../utils/logger.js'; +import type { SubcommandModule } from '../../../types/index.js'; export default { async execute(interaction, config, client) { @@ -14,9 +15,9 @@ export default { return; } - const targetUser = interaction.options.getUser('user'); - const reason = interaction.options.getString('reason'); - const guildId = interaction.guildId; + const targetUser = interaction.options.getUser('user')!; + const reason = interaction.options.getString('reason')!; + const guildId = interaction.guildId!; const guildConfig = await getGuildConfig(client, guildId); const reportChannelId = resolveLogChannel(guildConfig, 'reports'); @@ -25,8 +26,8 @@ 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`.' }); } - const ownerMention = interaction.guild.ownerId - ? `<@${interaction.guild.ownerId}> New report!` + const ownerMention = interaction.guild!.ownerId + ? `<@${interaction.guild!.ownerId}> New report!` : 'New report!'; await logEvent({ @@ -39,7 +40,7 @@ export default { lines: [ formatLogLine('Reported User', `${targetUser.tag} (\`${targetUser.id}\`)`), formatLogLine('Reported By', `${interaction.user.tag} (\`${interaction.user.id}\`)`), - formatLogLine('Channel', interaction.channel.toString()), + formatLogLine('Channel', interaction.channel!.toString()), ], blockFields: [{ name: 'Reason', value: reason }], author: await resolveUserAuthor(client, targetUser.id), @@ -61,4 +62,4 @@ export default { reasonLength: reason.length, }); }, -}; +} satisfies SubcommandModule; diff --git a/src/commands/Utility/modules/report_setchannel.js b/src/commands/Utility/modules/report_setchannel.ts similarity index 80% rename from src/commands/Utility/modules/report_setchannel.js rename to src/commands/Utility/modules/report_setchannel.ts index 0da04de754..1d3f6546eb 100644 --- a/src/commands/Utility/modules/report_setchannel.js +++ b/src/commands/Utility/modules/report_setchannel.ts @@ -1,18 +1,19 @@ -import { PermissionsBitField } from 'discord.js'; +import { PermissionsBitField, type GuildMember } from 'discord.js'; import { successEmbed } from '../../../utils/embeds.js'; 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'; +import type { SubcommandModule } from '../../../types/index.js'; export default { async execute(interaction, config, client) { - if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { + if (!(interaction.member as GuildMember).permissions.has(PermissionsBitField.Flags.ManageGuild)) { return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need **Manage Server** permissions to set the report channel.' }); } - const channel = interaction.options.getChannel('channel'); - const guildId = interaction.guildId; + const channel = interaction.options.getChannel('channel')!; + const guildId = interaction.guildId!; try { await setLogChannel(client, guildId, 'reports', channel.id); @@ -29,4 +30,4 @@ export default { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Could not save the channel configuration.' }); } }, -}; +} satisfies SubcommandModule; diff --git a/src/commands/Utility/report.js b/src/commands/Utility/report.ts similarity index 96% rename from src/commands/Utility/report.js rename to src/commands/Utility/report.ts index 561aaf983c..347b3fcd7c 100644 --- a/src/commands/Utility/report.js +++ b/src/commands/Utility/report.ts @@ -4,6 +4,7 @@ import { InteractionHelper } from '../../utils/interactionHelper.js'; import report from './modules/report.js'; import reportSetchannel from './modules/report_setchannel.js'; +import type { Command } from '../../types/index.js'; export default { data: new SlashCommandBuilder() @@ -55,4 +56,4 @@ export default { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Unknown subcommand.' }); }, -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Utility/serverinfo.js b/src/commands/Utility/serverinfo.ts similarity index 94% rename from src/commands/Utility/serverinfo.js rename to src/commands/Utility/serverinfo.ts index fd417d6845..03609baf1b 100644 --- a/src/commands/Utility/serverinfo.js +++ b/src/commands/Utility/serverinfo.ts @@ -2,6 +2,7 @@ import { SlashCommandBuilder } from 'discord.js'; import { createEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import type { Command } from '../../types/index.js'; export default { data: new SlashCommandBuilder() @@ -19,7 +20,7 @@ export default { return; } - const guild = interaction.guild; + const guild = interaction.guild!; const owner = await guild.fetchOwner(); const createdTimestamp = Math.floor(guild.createdAt.getTime() / 1000); @@ -55,4 +56,4 @@ export default { memberCount: guild.memberCount }); }, -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Utility/todo.js b/src/commands/Utility/todo.ts similarity index 91% rename from src/commands/Utility/todo.js rename to src/commands/Utility/todo.ts index af82306340..39f1667ea3 100644 --- a/src/commands/Utility/todo.js +++ b/src/commands/Utility/todo.ts @@ -5,6 +5,40 @@ import { logger } from '../../utils/logger.js'; import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import crypto from 'crypto'; +import type { Command } from '../../types/index.js'; + +/** Personal task stored under `todo_{userId}`. */ +export interface TodoTask { + id: number; + text: string; + completed: boolean; + createdAt: string; +} + +/** Stored under `todo_{userId}`. */ +export interface TodoList { + tasks: TodoTask[]; + nextId: number; +} + +/** Task in a shared list. */ +export interface SharedTodoTask extends TodoTask { + createdBy: string; + completedBy?: string; +} + +/** Stored under `shared_todo_{listId}`. */ +export interface SharedTodoList { + id: string; + name: string | null; + creatorId: string; + members: string[]; + tasks: SharedTodoTask[]; + nextId: number; + createdAt: string; + ok?: boolean; + error?: unknown; +} function generateShareId() { return crypto.randomBytes(16).toString('hex'); @@ -139,9 +173,9 @@ export default { const subcommand = interaction.options.getSubcommand(); const shareSubcommand = interaction.options.getSubcommandGroup() === 'share' ? interaction.options.getSubcommand() : null; - async function getOrCreateSharedList(listId, creatorId = null, listName = null) { + async function getOrCreateSharedList(listId: string, creatorId: string | null = null, listName: string | null = null): Promise { const listKey = `shared_todo_${listId}`; - let listData = await getFromDb(listKey, null); + let listData = await getFromDb(listKey, null); if (!listData || (listData.ok === false && listData.error)) { if (creatorId) { @@ -187,7 +221,7 @@ export default { await getOrCreateSharedList(listId, userId, listName); - const userSharedLists = await getFromDb(`user_shared_lists_${userId}`, []); + const userSharedLists = await getFromDb(`user_shared_lists_${userId}`, []); const sharedListsArray = Array.isArray(userSharedLists) ? userSharedLists : []; if (!sharedListsArray.includes(listId)) { sharedListsArray.push(listId); @@ -206,8 +240,8 @@ export default { } case 'add': { - const listId = interaction.options.getString('list_id'); - const memberToAdd = interaction.options.getUser('user'); + const listId = interaction.options.getString('list_id')!; + const memberToAdd = interaction.options.getUser('user')!; const listData = await getOrCreateSharedList(listId); if (!listData) { @@ -222,7 +256,7 @@ export default { listData.members.push(memberToAdd.id); await setInDb(`shared_todo_${listId}`, listData); - const memberLists = await getFromDb(`user_shared_lists_${memberToAdd.id}`, []); + const memberLists = await getFromDb(`user_shared_lists_${memberToAdd.id}`, []); const memberListsArray = Array.isArray(memberLists) ? memberLists : []; if (!memberListsArray.includes(listId)) { memberListsArray.push(listId); @@ -242,7 +276,7 @@ export default { } case 'view': { - const listId = interaction.options.getString('list_id'); + const listId = interaction.options.getString('list_id')!; const listData = await getOrCreateSharedList(listId); if (!listData) { @@ -255,11 +289,11 @@ export default { if (listData.tasks.length === 0) { const memberList = listData.members.map(memberId => { - const member = interaction.guild.members.cache.get(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 owner = interaction.guild!.members.cache.get(listData.creatorId); const ownerName = owner ? owner.user.username : `<@${listData.creatorId}>`; return await InteractionHelper.safeEditReply(interaction, { @@ -273,7 +307,7 @@ export default { ) ], components: [ - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`shared_todo_add_${listId}`) .setLabel('Add Task') @@ -300,11 +334,11 @@ export default { .join('\n'); const memberList = listData.members.map(memberId => { - const member = interaction.guild.members.cache.get(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 owner = interaction.guild!.members.cache.get(listData.creatorId); const ownerName = owner ? owner.user.username : `<@${listData.creatorId}>`; const fullListDisplay = `📋 **${listData.name}**\n\n` + @@ -317,7 +351,7 @@ export default { successEmbed(`Shared List (ID: \`${listId}\`)`, fullListDisplay) ], components: [ - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`shared_todo_add_${listId}`) .setLabel('Add Task') @@ -336,8 +370,8 @@ export default { } case 'addtask': { - const listId = interaction.options.getString('list_id'); - const taskText = interaction.options.getString('task'); + const listId = interaction.options.getString('list_id')!; + const taskText = interaction.options.getString('task')!; const listData = await getOrCreateSharedList(listId); @@ -349,7 +383,7 @@ export default { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You don\'t have access to this list.' }); } - const newTask = { + const newTask: SharedTodoTask = { id: listData.nextId++, text: taskText, completed: false, @@ -368,7 +402,7 @@ export default { } case 'remove': { - const listId = interaction.options.getString('list_id'); + const listId = interaction.options.getString('list_id')!; const taskNumber = interaction.options.getInteger('number'); const listData = await getOrCreateSharedList(listId); @@ -401,7 +435,7 @@ export default { const dbKey = `todo_${userId}`; - const userData = await getFromDb(dbKey, { + const userData = await getFromDb(dbKey, { tasks: [], nextId: 1 }); @@ -411,9 +445,9 @@ export default { switch (subcommand) { case 'add': { - const taskText = interaction.options.getString('task'); + const taskText = interaction.options.getString('task')!; - const newTask = { + const newTask: TodoTask = { id: userData.nextId++, text: taskText, completed: false, @@ -498,4 +532,4 @@ export default { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Invalid subcommand.' }); } }, -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Utility/userinfo.js b/src/commands/Utility/userinfo.ts similarity index 94% rename from src/commands/Utility/userinfo.js rename to src/commands/Utility/userinfo.ts index 2455b0f4a9..fedd63ab8f 100644 --- a/src/commands/Utility/userinfo.js +++ b/src/commands/Utility/userinfo.ts @@ -2,6 +2,7 @@ import { SlashCommandBuilder } from 'discord.js'; import { createEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import type { Command } from '../../types/index.js'; export default { data: new SlashCommandBuilder() .setName("userinfo") @@ -24,7 +25,7 @@ export default { } const user = interaction.options.getUser("target") || interaction.user; - const member = interaction.guild.members.cache.get(user.id); + 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; @@ -69,4 +70,4 @@ export default { guildId: interaction.guildId }); }, -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Utility/weather.js b/src/commands/Utility/weather.ts similarity index 81% rename from src/commands/Utility/weather.js rename to src/commands/Utility/weather.ts index a76c3eeb09..7e83fa5f7d 100644 --- a/src/commands/Utility/weather.js +++ b/src/commands/Utility/weather.ts @@ -3,9 +3,37 @@ import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/ import { logger } from '../../utils/logger.js'; import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import type { Command } from '../../types/index.js'; const GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search"; const WEATHER_URL = "https://api.open-meteo.com/v1/forecast"; +interface GeocodingResult { + latitude: number; + longitude: number; + name: string; + country: string; +} + +interface GeocodingResponse { + results?: GeocodingResult[]; +} + +interface CurrentWeather { + temperature?: number | null; + relativehumidity?: number; + relative_humidity_2m?: number; + windspeed?: number | null; + weathercode?: number; + weather_code?: number; +} + +interface WeatherResponse { + error?: boolean; + reason?: string; + current?: CurrentWeather; + current_weather?: CurrentWeather; +} + export default { data: new SlashCommandBuilder() .setName("weather") @@ -31,9 +59,9 @@ export default { const city = interaction.options.getString("city"); const geoResponse = await fetch( - `${GEOCODING_URL}?name=${encodeURIComponent(city)}`, + `${GEOCODING_URL}?name=${encodeURIComponent(city!)}`, ); - const geoData = await geoResponse.json(); + const geoData = await geoResponse.json() as GeocodingResponse; if (!geoData.results || geoData.results.length === 0) { logger.info(`Weather command - city not found`, { @@ -51,7 +79,7 @@ export default { const weatherResponse = await fetch( `${WEATHER_URL}?latitude=${latitude}&longitude=${longitude}¤t_weather=true`, ); - const weatherData = await weatherResponse.json(); + const weatherData = await weatherResponse.json() as WeatherResponse; if (weatherData.error) { logger.error(`Weather API error`, { @@ -64,7 +92,7 @@ export default { return; } - const current = weatherData.current || weatherData.current_weather || {}; + const current: CurrentWeather = 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"; @@ -103,9 +131,11 @@ export default { guildId: interaction.guildId }); }, -}; +} satisfies Command; -function getWeatherDescription(code) { +function getWeatherDescription(codeValue: number | null) { + // Number(null) === 0, same coercion the relational operators applied in JS + const code = Number(codeValue); if (code >= 0 && code <= 3) { return { description: "Clear sky / Partly cloudy", emoji: "" }; } else if (code >= 45 && code <= 48) { diff --git a/src/commands/Utility/wipedata.js b/src/commands/Utility/wipedata.ts similarity index 95% rename from src/commands/Utility/wipedata.js rename to src/commands/Utility/wipedata.ts index 941265dca8..3a6d0cf756 100644 --- a/src/commands/Utility/wipedata.js +++ b/src/commands/Utility/wipedata.ts @@ -4,6 +4,7 @@ import { getConfirmationButtons } from '../../utils/components.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import type { Command } from '../../types/index.js'; export default { slashOnly: true, data: new SlashCommandBuilder() @@ -38,4 +39,4 @@ export default { guildId: interaction.guildId }); } -}; \ No newline at end of file +} satisfies Command; \ No newline at end of file diff --git a/src/commands/Verification/autoverify.js b/src/commands/Verification/autoverify.ts similarity index 100% rename from src/commands/Verification/autoverify.js rename to src/commands/Verification/autoverify.ts diff --git a/src/commands/Verification/modules/autoVerify.js b/src/commands/Verification/modules/autoVerify.ts similarity index 89% rename from src/commands/Verification/modules/autoVerify.js rename to src/commands/Verification/modules/autoVerify.ts index 31ca0e881d..a74a8c0377 100644 --- a/src/commands/Verification/modules/autoVerify.js +++ b/src/commands/Verification/modules/autoVerify.ts @@ -1,5 +1,6 @@ import { botConfig, getColor } from '../../../config/bot.js'; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; +import type { ChatInputCommandInteraction, Guild } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed, infoEmbed } from '../../../utils/embeds.js'; import { getGuildConfig, setGuildConfig } from '../../../services/config/guildConfig.js'; import { withErrorHandling, createError, ErrorTypes } from '../../../utils/errorHandler.js'; @@ -8,6 +9,8 @@ import { logger } from '../../../utils/logger.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; import { getWelcomeConfig } from '../../../utils/database.js'; import autoVerifyDashboard from './autoVerifyDashboard.js'; +import type { AutoVerifyConfig, VerificationConfig } from '../../../services/verificationService.js'; +import type { Command, NexoClient } from '../../../types/index.js'; const autoVerifyDefaults = botConfig.verification?.autoVerify || {}; const minAccountAgeDays = autoVerifyDefaults.minAccountAge ?? 1; @@ -55,9 +58,9 @@ export default { ), async execute(interaction, config, client) { - const wrappedExecute = withErrorHandling(async () => { + const wrappedExecute = withErrorHandling(async (..._args: unknown[]) => { const subcommand = interaction.options.getSubcommand(); - const guild = interaction.guild; + const guild = interaction.guild!; switch (subcommand) { case "setup": @@ -76,12 +79,12 @@ export default { return await wrappedExecute(interaction, config, client); } -}; +} satisfies Command; -async function handleSetup(interaction, guild, client) { - const criteria = interaction.options.getString("criteria"); +async function handleSetup(interaction: ChatInputCommandInteraction, guild: Guild, client: NexoClient) { + const criteria = interaction.options.getString("criteria")!; const accountAgeDays = interaction.options.getInteger("account_age_days") || defaultAccountAgeDays; - const targetRole = interaction.options.getRole("role"); + const targetRole = interaction.options.getRole("role")!; await InteractionHelper.safeDefer(interaction); @@ -146,16 +149,18 @@ async function handleSetup(interaction, guild, client) { validateAutoVerifyCriteria(criteria, criteria === 'account_age' ? accountAgeDays : 1); if (!guildConfig.verification) { - guildConfig.verification = {}; + guildConfig.verification = {} as VerificationConfig; } - guildConfig.verification.autoVerify = { + // Stored record keeps the extra `configuredVia` key; the schema type does not list it. + const autoVerifyConfig = { enabled: true, - criteria: criteria, + criteria: criteria as AutoVerifyConfig["criteria"], accountAgeDays: criteria === "account_age" ? accountAgeDays : null, roleId: targetRole.id, configuredVia: 'setup' }; + guildConfig.verification.autoVerify = autoVerifyConfig; await setGuildConfig(client, guild.id, guildConfig); diff --git a/src/commands/Verification/modules/autoVerifyDashboard.js b/src/commands/Verification/modules/autoVerifyDashboard.ts similarity index 83% rename from src/commands/Verification/modules/autoVerifyDashboard.js rename to src/commands/Verification/modules/autoVerifyDashboard.ts index 48c77c9b9f..84d811a43f 100644 --- a/src/commands/Verification/modules/autoVerifyDashboard.js +++ b/src/commands/Verification/modules/autoVerifyDashboard.ts @@ -13,6 +13,7 @@ import { ComponentType, EmbedBuilder, } from 'discord.js'; +import type { ButtonInteraction, ChatInputCommandInteraction, Guild, StringSelectMenuInteraction } from 'discord.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; import { successEmbed } from '../../../utils/embeds.js'; import { logger } from '../../../utils/logger.js'; @@ -21,13 +22,15 @@ import { getGuildConfig, setGuildConfig } from '../../../services/config/guildCo import { getWelcomeConfig } from '../../../utils/database.js'; import { validateAutoVerifyCriteria } from '../../../services/verificationService.js'; import { botHasPermission } from '../../../utils/permissionGuard.js'; +import type { AutoVerifyConfig } from '../../../services/verificationService.js'; +import type { GuildConfig, NexoClient, SubcommandModule } from '../../../types/index.js'; const autoVerifyDefaults = botConfig.verification?.autoVerify || {}; const minAccountAgeDays = autoVerifyDefaults.minAccountAge ?? 1; const maxAccountAgeDays = autoVerifyDefaults.maxAccountAge ?? 365; const defaultAccountAgeDays = autoVerifyDefaults.defaultAccountAgeDays ?? 7; -function buildDashboardEmbed(cfg, guild, conflictSummary = '') { +function buildDashboardEmbed(cfg: GuildConfig, guild: Guild, conflictSummary = '') { const autoVerify = cfg.verification?.autoVerify; const autoVerifyRole = autoVerify?.roleId ? guild.roles.cache.get(autoVerify.roleId) : null; @@ -65,7 +68,7 @@ function buildDashboardEmbed(cfg, guild, conflictSummary = '') { .setTimestamp(); } -function buildSelectMenu(guildId) { +function buildSelectMenu(guildId: string) { return new StringSelectMenuBuilder() .setCustomId(`autoverify_cfg_${guildId}`) .setPlaceholder('Select a setting to configure...') @@ -83,9 +86,9 @@ function buildSelectMenu(guildId) { ); } -function buildButtonRow(cfg, guildId, disabled = false) { +function buildButtonRow(cfg: GuildConfig, guildId: string, disabled = false) { const autoVerifyOn = cfg.verification?.autoVerify?.enabled === true; - return new ActionRowBuilder().addComponents( + return new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`autoverify_cfg_criteria_${guildId}`) .setLabel('Change Criteria') @@ -101,7 +104,7 @@ function buildButtonRow(cfg, guildId, disabled = false) { ); } -async function refreshDashboard(rootInteraction, cfg, guildId, client) { +async function refreshDashboard(rootInteraction: ChatInputCommandInteraction, cfg: GuildConfig, guildId: string, client: NexoClient) { try { const selectMenu = buildSelectMenu(guildId); @@ -120,19 +123,19 @@ async function refreshDashboard(rootInteraction, cfg, guildId, client) { conflictSummary = conflicts.join('\n'); } } catch (error) { - logger.warn('Could not fetch autoverify dashboard conflicts:', error.message); + logger.warn('Could not fetch autoverify dashboard conflicts:', (error as Error).message); } await InteractionHelper.safeEditReply(rootInteraction, { - embeds: [buildDashboardEmbed(cfg, rootInteraction.guild, conflictSummary)], + embeds: [buildDashboardEmbed(cfg, rootInteraction.guild!, conflictSummary)], components: [ buildButtonRow(cfg, guildId), - new ActionRowBuilder().addComponents(selectMenu), + new ActionRowBuilder().addComponents(selectMenu), ], flags: MessageFlags.Ephemeral, }); } catch (error) { - logger.debug('Could not refresh autoverify dashboard (interaction may have expired):', error.message); + logger.debug('Could not refresh autoverify dashboard (interaction may have expired):', (error as Error).message); } } @@ -140,7 +143,7 @@ export default { prefixOnly: false, async execute(interaction, config, client) { try { - const guildId = interaction.guild.id; + const guildId = interaction.guild!.id; const guildConfig = await getGuildConfig(client, guildId); if (!guildConfig.verification?.autoVerify?.enabled) { @@ -149,7 +152,7 @@ export default { const verificationEnabled = Boolean(guildConfig.verification?.enabled); const autoRoleConfigured = Boolean(guildConfig.autoRole) || (Array.isArray(welcomeConfig.roleIds) && welcomeConfig.roleIds.length > 0); - const blockingMessage = []; + const blockingMessage: string[] = []; if (verificationEnabled) blockingMessage.push('Verification system is enabled'); if (autoRoleConfigured) blockingMessage.push('AutoRole is configured'); @@ -189,19 +192,19 @@ export default { conflictSummary = conflicts.join('\n'); } } catch (error) { - logger.warn('Could not fetch autoverify dashboard conflicts:', error.message); + logger.warn('Could not fetch autoverify dashboard conflicts:', (error as Error).message); } await InteractionHelper.safeEditReply(interaction, { - embeds: [buildDashboardEmbed(guildConfig, interaction.guild, conflictSummary)], + embeds: [buildDashboardEmbed(guildConfig, interaction.guild!, conflictSummary)], components: [ buildButtonRow(guildConfig, guildId), - new ActionRowBuilder().addComponents(selectMenu), + new ActionRowBuilder().addComponents(selectMenu), ], flags: MessageFlags.Ephemeral, }); - const collector = interaction.channel.createMessageComponentCollector({ + const collector = interaction.channel!.createMessageComponentCollector({ componentType: ComponentType.StringSelect, filter: i => i.user.id === interaction.user.id && i.customId === `autoverify_cfg_${guildId}`, @@ -221,7 +224,7 @@ export default { } } catch (error) { if (error instanceof NexoBotError) { - logger.debug(`Autoverify config validation error: ${error.message}`); + logger.debug(`Autoverify config validation error: ${(error as Error).message}`); } else { logger.error('Unexpected autoverify dashboard error:', error); } @@ -242,7 +245,7 @@ export default { } }); - const btnCollector = interaction.channel.createMessageComponentCollector({ + const btnCollector = interaction.channel!.createMessageComponentCollector({ componentType: ComponentType.Button, filter: i => i.user.id === interaction.user.id && @@ -256,14 +259,14 @@ export default { await handleCriteria(btnInteraction, interaction, guildConfig, guildId, client); } else if (btnInteraction.customId === `autoverify_cfg_toggle_${guildId}`) { await btnInteraction.deferUpdate().catch(() => null); - guildConfig.verification.autoVerify.enabled = !guildConfig.verification.autoVerify.enabled; + guildConfig.verification!.autoVerify!.enabled = !guildConfig.verification!.autoVerify!.enabled; await setGuildConfig(client, guildId, guildConfig); await btnInteraction.followUp({ embeds: [ successEmbed( '✅ Status Updated', - `Auto-verification is now **${guildConfig.verification.autoVerify.enabled ? 'enabled' : 'disabled'}**.`, + `Auto-verification is now **${guildConfig.verification!.autoVerify!.enabled ? 'enabled' : 'disabled'}**.`, ), ], flags: MessageFlags.Ephemeral, @@ -272,7 +275,7 @@ export default { await refreshDashboard(interaction, guildConfig, guildId, client); } } catch (err) { - logger.debug('Button interaction error:', err.message); + logger.debug('Button interaction error:', (err as Error).message); } }); @@ -290,7 +293,7 @@ export default { flags: MessageFlags.Ephemeral, }); } catch (error) { - logger.debug('Could not update dashboard on timeout:', error.message); + logger.debug('Could not update dashboard on timeout:', (error as Error).message); } } }); @@ -298,15 +301,15 @@ export default { if (error instanceof NexoBotError) throw error; logger.error('Unexpected error in autoverify_dashboard:', error); throw new NexoBotError( - `Auto-verification dashboard failed: ${error.message}`, + `Auto-verification dashboard failed: ${(error as Error).message}`, ErrorTypes.UNKNOWN, 'Failed to open the auto-verification dashboard.', ); } }, -}; +} satisfies SubcommandModule & { prefixOnly: boolean }; -async function handleCriteria(selectInteraction, rootInteraction, guildConfig, guildId, client) { +async function handleCriteria(selectInteraction: ButtonInteraction, rootInteraction: ChatInputCommandInteraction, guildConfig: GuildConfig, guildId: string, client: NexoClient) { if (!selectInteraction.deferred) { await selectInteraction.deferUpdate().catch(() => null); @@ -333,11 +336,11 @@ async function handleCriteria(selectInteraction, rootInteraction, guildConfig, g await selectInteraction.followUp({ embeds: [criteriaEmbed], - components: [new ActionRowBuilder().addComponents(criteriaMenu)], + components: [new ActionRowBuilder().addComponents(criteriaMenu)], flags: MessageFlags.Ephemeral, }); - const criteriaCollector = rootInteraction.channel.createMessageComponentCollector({ + const criteriaCollector = rootInteraction.channel!.createMessageComponentCollector({ componentType: ComponentType.StringSelect, filter: i => i.user.id === selectInteraction.user.id && i.customId === 'autoverify_criteria_select', @@ -349,12 +352,12 @@ async function handleCriteria(selectInteraction, rootInteraction, guildConfig, g await criteriaInteraction.deferUpdate(); const newCriteria = criteriaInteraction.values[0]; - guildConfig.verification.autoVerify.criteria = newCriteria; + guildConfig.verification!.autoVerify!.criteria = newCriteria as AutoVerifyConfig['criteria']; if (newCriteria !== 'account_age') { - guildConfig.verification.autoVerify.accountAgeDays = null; - } else if (!guildConfig.verification.autoVerify.accountAgeDays) { - guildConfig.verification.autoVerify.accountAgeDays = defaultAccountAgeDays; + guildConfig.verification!.autoVerify!.accountAgeDays = null; + } else if (!guildConfig.verification!.autoVerify!.accountAgeDays) { + guildConfig.verification!.autoVerify!.accountAgeDays = defaultAccountAgeDays; } await setGuildConfig(client, guildId, guildConfig); @@ -362,7 +365,7 @@ async function handleCriteria(selectInteraction, rootInteraction, guildConfig, g let criteriaDisplay = ''; switch (newCriteria) { case 'account_age': - criteriaDisplay = `Account Age (${guildConfig.verification.autoVerify.accountAgeDays} days)`; + criteriaDisplay = `Account Age (${guildConfig.verification!.autoVerify!.accountAgeDays} days)`; break; case 'none': criteriaDisplay = 'No Criteria'; @@ -387,7 +390,7 @@ async function handleCriteria(selectInteraction, rootInteraction, guildConfig, g }); } -async function handleRole(selectInteraction, rootInteraction, guildConfig, guildId, client) { +async function handleRole(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, guildConfig: GuildConfig, guildId: string, client: NexoClient) { await selectInteraction.deferUpdate(); const roleSelect = new RoleSelectMenuBuilder() @@ -402,11 +405,11 @@ async function handleRole(selectInteraction, rootInteraction, guildConfig, guild .setDescription('Select the role to assign to auto-verified users.') .setColor(getColor('info')), ], - components: [new ActionRowBuilder().addComponents(roleSelect)], + components: [new ActionRowBuilder().addComponents(roleSelect)], flags: MessageFlags.Ephemeral, }); - const roleCollector = rootInteraction.channel.createMessageComponentCollector({ + const roleCollector = rootInteraction.channel!.createMessageComponentCollector({ componentType: ComponentType.RoleSelect, filter: i => i.user.id === selectInteraction.user.id && i.customId === 'autoverify_role_select', @@ -416,9 +419,9 @@ async function handleRole(selectInteraction, rootInteraction, guildConfig, guild roleCollector.on('collect', async roleInteraction => { await roleInteraction.deferUpdate(); - const role = roleInteraction.roles.first(); + const role = roleInteraction.roles.first()!; - if (role.id === rootInteraction.guild.id || role.managed) { + if (role.id === rootInteraction.guild!.id || role.managed) { await replyUserError(roleInteraction, { type: ErrorTypes.VALIDATION, message: 'Please choose a normal assignable role (not @everyone or a bot-managed role).', @@ -426,8 +429,8 @@ async function handleRole(selectInteraction, rootInteraction, guildConfig, guild return; } - const botMember = rootInteraction.guild.members.me; - if (role.position >= botMember.roles.highest.position) { + const botMember = rootInteraction.guild!.members.me; + if (role.position >= botMember!.roles.highest.position) { await replyUserError(roleInteraction, { type: ErrorTypes.PERMISSION, message: 'The selected role must be below my highest role in the server role hierarchy.', @@ -435,7 +438,7 @@ async function handleRole(selectInteraction, rootInteraction, guildConfig, guild return; } - guildConfig.verification.autoVerify.roleId = role.id; + guildConfig.verification!.autoVerify!.roleId = role.id; await setGuildConfig(client, guildId, guildConfig); await roleInteraction.followUp({ @@ -456,18 +459,18 @@ async function handleRole(selectInteraction, rootInteraction, guildConfig, guild }); } -async function handleAccountAge(selectInteraction, rootInteraction, guildConfig, guildId, client) { +async function handleAccountAge(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, guildConfig: GuildConfig, guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId('autoverify_account_age_modal') .setTitle('Set Account Age Requirement') .addComponents( - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('age_input') .setLabel('Minimum Account Age (days)') .setStyle(TextInputStyle.Short) .setPlaceholder(`Between ${minAccountAgeDays} and ${maxAccountAgeDays}`) - .setValue((guildConfig.verification.autoVerify.accountAgeDays || defaultAccountAgeDays).toString()) + .setValue((guildConfig.verification!.autoVerify!.accountAgeDays || defaultAccountAgeDays).toString()) .setRequired(true), ), ); @@ -492,7 +495,7 @@ async function handleAccountAge(selectInteraction, rootInteraction, guildConfig, return; } - guildConfig.verification.autoVerify.accountAgeDays = days; + guildConfig.verification!.autoVerify!.accountAgeDays = days; await setGuildConfig(client, guildId, guildConfig); await submitted.reply({ diff --git a/src/commands/Verification/modules/verification_dashboard.js b/src/commands/Verification/modules/verification_dashboard.ts similarity index 84% rename from src/commands/Verification/modules/verification_dashboard.js rename to src/commands/Verification/modules/verification_dashboard.ts index cd2a0fe340..3f48ff3b1d 100644 --- a/src/commands/Verification/modules/verification_dashboard.js +++ b/src/commands/Verification/modules/verification_dashboard.ts @@ -15,6 +15,7 @@ import { ComponentType, EmbedBuilder, } from 'discord.js'; +import type { ChatInputCommandInteraction, Guild, StringSelectMenuInteraction, TextChannel } from 'discord.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; import { successEmbed } from '../../../utils/embeds.js'; import { logger } from '../../../utils/logger.js'; @@ -27,11 +28,14 @@ import { formatPanelStatusField, } from '../../../utils/panelStatus.js'; import { startDashboardSession } from '../../../utils/dashboardSession.js'; +import type { PanelStatus } from '../../../utils/panelStatus.js'; +import type { VerificationConfig } from '../../../services/verificationService.js'; +import type { NexoClient, SubcommandModule } from '../../../types/index.js'; -async function updateLivePanel(guild, cfg) { +async function updateLivePanel(guild: Guild, cfg: VerificationConfig) { if (!cfg.channelId || !cfg.messageId) return; try { - const channel = guild.channels.cache.get(cfg.channelId); + const channel = guild.channels.cache.get(cfg.channelId) as TextChannel | undefined; if (!channel) return; const msg = await channel.messages.fetch(cfg.messageId).catch(() => null); if (!msg) return; @@ -41,7 +45,7 @@ async function updateLivePanel(guild, cfg) { .setDescription(cfg.message || botConfig.verification.defaultMessage) .setColor(getColor('success')); - const verifyButton = new ActionRowBuilder().addComponents( + const verifyButton = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('verify_user') .setLabel(cfg.buttonText || botConfig.verification.defaultButtonText) @@ -51,11 +55,11 @@ async function updateLivePanel(guild, cfg) { await msg.edit({ embeds: [verifyEmbed], components: [verifyButton] }); } catch (error) { - logger.warn('Could not update live verification panel:', error.message); + logger.warn('Could not update live verification panel:', (error as Error).message); } } -function buildDashboardEmbed(cfg, guild, verifiedUserCount = 0, conflictSummary = '', panelStatus = null) { +function buildDashboardEmbed(cfg: VerificationConfig, guild: Guild, verifiedUserCount = 0, conflictSummary = '', panelStatus: PanelStatus | null = null) { const channel = cfg.channelId ? `<#${cfg.channelId}>` : '`Not set`'; const role = cfg.roleId ? `<@&${cfg.roleId}>` : '`Not set`'; const rawMsg = cfg.message || botConfig.verification.defaultMessage; @@ -87,7 +91,7 @@ function buildDashboardEmbed(cfg, guild, verifiedUserCount = 0, conflictSummary .setTimestamp(); } -function buildSelectMenu(guildId) { +function buildSelectMenu(guildId: string) { return new StringSelectMenuBuilder() .setCustomId(`verif_cfg_${guildId}`) .setPlaceholder('Select a setting to configure...') @@ -115,12 +119,12 @@ function buildSelectMenu(guildId) { ); } -function buildButtonRow(cfg, guildId, disabled = false, panelStatus = null) { +function buildButtonRow(cfg: VerificationConfig, guildId: string, disabled = false, panelStatus: PanelStatus | null = null) { const systemOn = cfg.enabled !== false; const showRepost = systemOn && panelStatus?.exists === false && panelStatus?.reason === 'panel_deleted'; - const buttons = []; + const buttons: ButtonBuilder[] = []; if (showRepost) { buttons.push( @@ -142,11 +146,11 @@ function buildButtonRow(cfg, guildId, disabled = false, panelStatus = null) { .setDisabled(disabled), ); - return new ActionRowBuilder().addComponents(buttons); + return new ActionRowBuilder().addComponents(buttons); } -async function repostVerificationPanel(guild, cfg) { - const channel = await guild.channels.fetch(cfg.channelId).catch(() => null); +async function repostVerificationPanel(guild: Guild, cfg: VerificationConfig) { + const channel = (await guild.channels.fetch(cfg.channelId!).catch(() => null)) as TextChannel | null; if (!channel) { throw new NexoBotError( 'Panel channel missing', @@ -160,7 +164,7 @@ async function repostVerificationPanel(guild, cfg) { .setDescription(cfg.message || botConfig.verification.defaultMessage) .setColor(getColor('success')); - const verifyButton = new ActionRowBuilder().addComponents( + const verifyButton = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('verify_user') .setLabel(cfg.buttonText || botConfig.verification.defaultButtonText) @@ -171,16 +175,16 @@ async function repostVerificationPanel(guild, cfg) { return channel.send({ embeds: [verifyEmbed], components: [verifyButton] }); } -async function refreshDashboard(rootInteraction, cfg, guildId, client) { +async function refreshDashboard(rootInteraction: ChatInputCommandInteraction, cfg: VerificationConfig, guildId: string, client: NexoClient) { try { const selectMenu = buildSelectMenu(guildId); let verifiedUserCount = 0; let conflictSummary = ''; - let panelStatus = null; + let panelStatus: PanelStatus | null = null; if (cfg.channelId && cfg.enabled !== false) { - panelStatus = await getVerificationPanelStatus(client, rootInteraction.guild, cfg); + panelStatus = await getVerificationPanelStatus(client, rootInteraction.guild!, cfg); if (panelStatus.recoveredId) { cfg.messageId = panelStatus.recoveredId; const latestConfig = await getGuildConfig(client, guildId); @@ -190,7 +194,7 @@ async function refreshDashboard(rootInteraction, cfg, guildId, client) { } try { - const verifiedRole = rootInteraction.guild.roles.cache.get(cfg.roleId); + const verifiedRole = rootInteraction.guild!.roles.cache.get(cfg.roleId!); if (verifiedRole) { verifiedUserCount = verifiedRole.members.size; } @@ -209,19 +213,19 @@ async function refreshDashboard(rootInteraction, cfg, guildId, client) { conflictSummary = conflicts.join('\n'); } } catch (error) { - logger.warn('Could not fetch verification dashboard details:', error.message); + logger.warn('Could not fetch verification dashboard details:', (error as Error).message); } await InteractionHelper.safeEditReply(rootInteraction, { - embeds: [buildDashboardEmbed(cfg, rootInteraction.guild, verifiedUserCount, conflictSummary, panelStatus)], + embeds: [buildDashboardEmbed(cfg, rootInteraction.guild!, verifiedUserCount, conflictSummary, panelStatus)], components: [ buildButtonRow(cfg, guildId, false, panelStatus), - new ActionRowBuilder().addComponents(selectMenu), + new ActionRowBuilder().addComponents(selectMenu), ], flags: MessageFlags.Ephemeral, }); } catch (error) { - logger.debug('Could not refresh verification dashboard (interaction may have expired):', error.message); + logger.debug('Could not refresh verification dashboard (interaction may have expired):', (error as Error).message); } } @@ -229,7 +233,7 @@ export default { prefixOnly: false, async execute(interaction, config, client) { try { - const guildId = interaction.guild.id; + const guildId = interaction.guild!.id; const guildConfig = await getGuildConfig(client, guildId); const cfg = guildConfig.verification; @@ -245,10 +249,10 @@ export default { let verifiedUserCount = 0; let conflictSummary = ''; - let panelStatus = null; + let panelStatus: PanelStatus | null = null; if (cfg.channelId && cfg.enabled !== false) { - panelStatus = await getVerificationPanelStatus(client, interaction.guild, cfg); + panelStatus = await getVerificationPanelStatus(client, interaction.guild!, cfg); if (panelStatus.recoveredId) { cfg.messageId = panelStatus.recoveredId; guildConfig.verification = cfg; @@ -257,7 +261,7 @@ export default { } try { - const verifiedRole = interaction.guild.roles.cache.get(cfg.roleId); + const verifiedRole = interaction.guild!.roles.cache.get(cfg.roleId!); if (verifiedRole) { verifiedUserCount = verifiedRole.members.size; } @@ -275,15 +279,15 @@ export default { conflictSummary = conflicts.join('\n'); } } catch (error) { - logger.warn('Could not fetch verification dashboard details:', error.message); + logger.warn('Could not fetch verification dashboard details:', (error as Error).message); } await startDashboardSession({ interaction, - embeds: [buildDashboardEmbed(cfg, interaction.guild, verifiedUserCount, conflictSummary, panelStatus)], + embeds: [buildDashboardEmbed(cfg, interaction.guild!, verifiedUserCount, conflictSummary, panelStatus)], components: [ buildButtonRow(cfg, guildId, false, panelStatus), - new ActionRowBuilder().addComponents(buildSelectMenu(guildId)), + new ActionRowBuilder().addComponents(buildSelectMenu(guildId)), ], flags: MessageFlags.Ephemeral, selectMenuId: `verif_cfg_${guildId}`, @@ -309,7 +313,7 @@ export default { onButton: async (btnInteraction) => { if (btnInteraction.customId === `verif_cfg_repost_${guildId}`) { await btnInteraction.deferUpdate(); - const newMsg = await repostVerificationPanel(interaction.guild, cfg); + const newMsg = await repostVerificationPanel(interaction.guild!, cfg); cfg.messageId = newMsg.id; const latestConfig = await getGuildConfig(client, guildId); latestConfig.verification = cfg; @@ -338,7 +342,7 @@ export default { cfg.enabled = !wasEnabled; if (!cfg.enabled && cfg.channelId && cfg.messageId) { - const channel = interaction.guild.channels.cache.get(cfg.channelId); + const channel = interaction.guild!.channels.cache.get(cfg.channelId) as TextChannel | undefined; if (channel) { const msg = await channel.messages.fetch(cfg.messageId).catch(() => null); if (msg) await msg.delete().catch(() => {}); @@ -347,10 +351,10 @@ export default { if (cfg.enabled && cfg.channelId) { try { - const newMsg = await repostVerificationPanel(interaction.guild, cfg); + const newMsg = await repostVerificationPanel(interaction.guild!, cfg); cfg.messageId = newMsg.id; } catch (error) { - logger.warn('Could not re-post verification panel on re-enable:', error.message); + logger.warn('Could not re-post verification panel on re-enable:', (error as Error).message); } } @@ -387,15 +391,15 @@ export default { if (error instanceof NexoBotError) throw error; logger.error('Unexpected error in verification_dashboard:', error); throw new NexoBotError( - `Verification dashboard failed: ${error.message}`, + `Verification dashboard failed: ${(error as Error).message}`, ErrorTypes.UNKNOWN, 'Failed to open the verification dashboard.', ); } }, -}; +} satisfies SubcommandModule & { prefixOnly: boolean }; -async function handleChannel(selectInteraction, rootInteraction, cfg, guildId, client) { +async function handleChannel(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, cfg: VerificationConfig, guildId: string, client: NexoClient) { await selectInteraction.deferUpdate(); const channelSelect = new ChannelSelectMenuBuilder() @@ -413,11 +417,11 @@ async function handleChannel(selectInteraction, rootInteraction, cfg, guildId, c ) .setColor(getColor('info')), ], - components: [new ActionRowBuilder().addComponents(channelSelect)], + components: [new ActionRowBuilder().addComponents(channelSelect)], flags: MessageFlags.Ephemeral, }); - const chanCollector = rootInteraction.channel.createMessageComponentCollector({ + const chanCollector = rootInteraction.channel!.createMessageComponentCollector({ componentType: ComponentType.ChannelSelect, filter: i => i.user.id === selectInteraction.user.id && i.customId === 'verif_cfg_channel', @@ -427,7 +431,7 @@ async function handleChannel(selectInteraction, rootInteraction, cfg, guildId, c chanCollector.on('collect', async chanInteraction => { await chanInteraction.deferUpdate(); - const newChannel = chanInteraction.channels.first(); + const newChannel = chanInteraction.channels.first() as TextChannel | undefined; if (!botHasPermission(newChannel, ['ViewChannel', 'SendMessages', 'EmbedLinks'])) { await replyUserError(chanInteraction, { @@ -438,7 +442,7 @@ async function handleChannel(selectInteraction, rootInteraction, cfg, guildId, c } if (cfg.channelId && cfg.messageId) { - const oldChannel = rootInteraction.guild.channels.cache.get(cfg.channelId); + const oldChannel = rootInteraction.guild!.channels.cache.get(cfg.channelId) as TextChannel | undefined; if (oldChannel) { try { const oldMsg = await oldChannel.messages.fetch(cfg.messageId).catch(() => null); @@ -456,7 +460,7 @@ async function handleChannel(selectInteraction, rootInteraction, cfg, guildId, c .setDescription(cfg.message || botConfig.verification.defaultMessage) .setColor(getColor('success')); - const verifyButton = new ActionRowBuilder().addComponents( + const verifyButton = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('verify_user') .setLabel(cfg.buttonText || botConfig.verification.defaultButtonText) @@ -464,14 +468,14 @@ async function handleChannel(selectInteraction, rootInteraction, cfg, guildId, c .setEmoji('✅'), ); - const newMsg = await newChannel.send({ embeds: [verifyEmbed], components: [verifyButton] }); + const newMsg = await newChannel!.send({ embeds: [verifyEmbed], components: [verifyButton] }); cfg.messageId = newMsg.id; } catch (error) { - logger.warn('Could not post verification panel in new channel:', error.message); + logger.warn('Could not post verification panel in new channel:', (error as Error).message); } } - cfg.channelId = newChannel.id; + cfg.channelId = newChannel!.id; const latestConfig = await getGuildConfig(client, guildId); latestConfig.verification = cfg; await setGuildConfig(client, guildId, latestConfig); @@ -494,7 +498,7 @@ async function handleChannel(selectInteraction, rootInteraction, cfg, guildId, c }); } -async function handleRole(selectInteraction, rootInteraction, cfg, guildId, client) { +async function handleRole(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, cfg: VerificationConfig, guildId: string, client: NexoClient) { await selectInteraction.deferUpdate(); const roleSelect = new RoleSelectMenuBuilder() @@ -511,11 +515,11 @@ async function handleRole(selectInteraction, rootInteraction, cfg, guildId, clie ) .setColor(getColor('info')), ], - components: [new ActionRowBuilder().addComponents(roleSelect)], + components: [new ActionRowBuilder().addComponents(roleSelect)], flags: MessageFlags.Ephemeral, }); - const roleCollector = rootInteraction.channel.createMessageComponentCollector({ + const roleCollector = rootInteraction.channel!.createMessageComponentCollector({ componentType: ComponentType.RoleSelect, filter: i => i.user.id === selectInteraction.user.id && i.customId === 'verif_cfg_role', @@ -525,9 +529,9 @@ async function handleRole(selectInteraction, rootInteraction, cfg, guildId, clie roleCollector.on('collect', async roleInteraction => { await roleInteraction.deferUpdate(); - const role = roleInteraction.roles.first(); - const guild = rootInteraction.guild; - const botMember = guild.members.me; + const role = roleInteraction.roles.first()!; + const guild = rootInteraction.guild!; + const botMember = guild.members.me!; if (role.id === guild.id || role.managed) { await replyUserError(roleInteraction, { @@ -568,13 +572,13 @@ async function handleRole(selectInteraction, rootInteraction, cfg, guildId, clie }); } -async function handleMessage(selectInteraction, rootInteraction, cfg, guildId, client) { +async function handleMessage(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, cfg: VerificationConfig, guildId: string, client: NexoClient) { try { const modal = new ModalBuilder() .setCustomId('verif_cfg_message') .setTitle('Edit Verification Message') .addComponents( - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('message_input') .setLabel('Message shown on the verification panel embed') @@ -604,7 +608,7 @@ async function handleMessage(selectInteraction, rootInteraction, cfg, guildId, c latestConfig.verification = cfg; await setGuildConfig(client, guildId, latestConfig); - await updateLivePanel(rootInteraction.guild, cfg); + await updateLivePanel(rootInteraction.guild!, cfg); await submitted.reply({ embeds: [successEmbed('Message Updated', 'The verification panel has been updated with the new message.')], @@ -618,13 +622,13 @@ async function handleMessage(selectInteraction, rootInteraction, cfg, guildId, c } } -async function handleButtonText(selectInteraction, rootInteraction, cfg, guildId, client) { +async function handleButtonText(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, cfg: VerificationConfig, guildId: string, client: NexoClient) { try { const modal = new ModalBuilder() .setCustomId('verif_cfg_button_text') .setTitle('Edit Button Text') .addComponents( - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('button_text_input') .setLabel('Button label (max 80 characters)') @@ -654,7 +658,7 @@ async function handleButtonText(selectInteraction, rootInteraction, cfg, guildId latestConfig.verification = cfg; await setGuildConfig(client, guildId, latestConfig); - await updateLivePanel(rootInteraction.guild, cfg); + await updateLivePanel(rootInteraction.guild!, cfg); await submitted.reply({ embeds: [successEmbed('Button Text Updated', `The verify button now reads **${cfg.buttonText}**.`)], diff --git a/src/commands/Verification/verification.js b/src/commands/Verification/verification.ts similarity index 93% rename from src/commands/Verification/verification.js rename to src/commands/Verification/verification.ts index 8906809fe7..61c57ba67b 100644 --- a/src/commands/Verification/verification.js +++ b/src/commands/Verification/verification.ts @@ -1,5 +1,6 @@ import { botConfig, getColor } from '../../config/bot.js'; import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js'; +import type { ChatInputCommandInteraction, Guild, TextChannel } from 'discord.js'; import { createEmbed, infoEmbed, successEmbed } from '../../utils/embeds.js'; import { getGuildConfig, setGuildConfig } from '../../services/config/guildConfig.js'; import { withErrorHandling, createError, ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; @@ -8,6 +9,7 @@ import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { getWelcomeConfig } from '../../utils/database.js'; import verificationDashboard from './modules/verification_dashboard.js'; +import type { Command, NexoClient } from '../../types/index.js'; export default { data: new SlashCommandBuilder() @@ -63,9 +65,9 @@ export default { ), async execute(interaction, config, client) { - const wrappedExecute = withErrorHandling(async () => { + const wrappedExecute = withErrorHandling(async (..._args: unknown[]) => { const subcommand = interaction.options.getSubcommand(); - const guild = interaction.guild; + const guild = interaction.guild!; if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { throw createError( @@ -95,11 +97,11 @@ export default { return await wrappedExecute(interaction, config, client); } -}; +} satisfies Command; -async function handleSetup(interaction, guild, client) { - const verificationChannel = interaction.options.getChannel("verification_channel"); - const verifiedRole = interaction.options.getRole("verified_role"); +async function handleSetup(interaction: ChatInputCommandInteraction, guild: Guild, client: NexoClient) { + const verificationChannel = interaction.options.getChannel("verification_channel") as TextChannel; + const verifiedRole = interaction.options.getRole("verified_role")!; const message = interaction.options.getString("message") || botConfig.verification.defaultMessage; const buttonText = interaction.options.getString("button_text") || botConfig.verification.defaultButtonText; const botMember = guild.members.me; @@ -187,7 +189,7 @@ async function handleSetup(interaction, guild, client) { color: getColor('success') }); - const verifyButton = new ActionRowBuilder().addComponents( + const verifyButton = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId("verify_user") .setLabel(buttonText) @@ -223,8 +225,8 @@ async function handleSetup(interaction, guild, client) { }); } -async function handleRemove(interaction, guild, client) { - const targetUser = interaction.options.getUser("user"); +async function handleRemove(interaction: ChatInputCommandInteraction, guild: Guild, client: NexoClient) { + const targetUser = interaction.options.getUser("user")!; const result = await removeVerification(client, guild.id, targetUser.id, { moderatorId: interaction.user.id, diff --git a/src/commands/Verification/verify.js b/src/commands/Verification/verify.ts similarity index 91% rename from src/commands/Verification/verify.js rename to src/commands/Verification/verify.ts index 7c203ac8d6..abf3bf787e 100644 --- a/src/commands/Verification/verify.js +++ b/src/commands/Verification/verify.ts @@ -3,6 +3,7 @@ import { infoEmbed, successEmbed } from '../../utils/embeds.js'; import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { verifyUser } from '../../services/verificationService.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import type { Command } from '../../types/index.js'; export default { data: new SlashCommandBuilder() @@ -10,7 +11,7 @@ export default { .setDescription('Verify yourself and gain access to the server'), async execute(interaction, config, client) { - const guild = interaction.guild; + const guild = interaction.guild!; const result = await verifyUser(client, guild.id, interaction.user.id, { source: 'command_self', @@ -32,4 +33,4 @@ export default { flags: MessageFlags.Ephemeral }); } -}; +} satisfies Command; diff --git a/src/commands/Welcome/autorole.js b/src/commands/Welcome/autorole.ts similarity index 93% rename from src/commands/Welcome/autorole.js rename to src/commands/Welcome/autorole.ts index 81764e4c46..149a48c8de 100644 --- a/src/commands/Welcome/autorole.js +++ b/src/commands/Welcome/autorole.ts @@ -1,12 +1,14 @@ import { getColor } from '../../config/bot.js'; import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, EmbedBuilder, MessageFlags } from 'discord.js'; +import type { ChatInputCommandInteraction, Role } from 'discord.js'; import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { getGuildConfig } from '../../services/config/guildConfig.js'; import { ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; +import type { Command } from '../../types/index.js'; -function createAutoroleInfoEmbed(description) { +function createAutoroleInfoEmbed(description: string) { return new EmbedBuilder() .setColor(getColor('primary')) .setDescription(description) @@ -54,11 +56,11 @@ export default { return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the **Manage Server** permission to use `/autorole`.' }); } - const { options, guild, client } = interaction; + const { options, guild, client } = interaction as ChatInputCommandInteraction<'cached'>; const subcommand = options.getSubcommand(); if (subcommand === 'add') { - const role = options.getRole('role'); + const role = options.getRole('role')!; const guildConfig = await getGuildConfig(client, guild.id); const verificationEnabled = Boolean(guildConfig.verification?.enabled); @@ -68,7 +70,7 @@ export default { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You cannot add AutoRole while the verification system or AutoVerify is enabled. Disable those first.' }); } - if (role.position >= guild.members.me.roles.highest.position) { + if (role.position >= guild.members.me!.roles.highest.position) { logger.warn(`[Autorole] User ${interaction.user.tag} tried to add role ${role.name} (${role.id}) higher than bot's highest role in ${guild.name}`); return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'I can\'t assign roles that are higher than my highest role.' }); } @@ -103,7 +105,7 @@ export default { } else if (subcommand === 'remove') { - const role = options.getRole('role'); + const role = options.getRole('role')!; try { const config = await getWelcomeConfig(client, guild.id); @@ -149,7 +151,7 @@ export default { await updateWelcomeConfig(client, guild.id, { roleIds: singleRoleIds }); - logger.info(`[Autorole] Trimmed auto-role list to one role in ${interaction.guild.name}`); + logger.info(`[Autorole] Trimmed auto-role list to one role in ${interaction.guild!.name}`); } if (singleRoleIds.length === 0) { @@ -160,8 +162,8 @@ export default { } const roles = await guild.roles.fetch(); - const validRoles = []; - const invalidRoleIds = []; + const validRoles: Role[] = []; + const invalidRoleIds: string[] = []; for (const roleId of singleRoleIds) { const role = roles.get(roleId); @@ -173,7 +175,7 @@ export default { } if (invalidRoleIds.length > 0) { - logger.info(`[Autorole] Cleaning up ${invalidRoleIds.length} invalid role(s) from guild ${interaction.guild.name}`); + logger.info(`[Autorole] Cleaning up ${invalidRoleIds.length} invalid role(s) from guild ${interaction.guild!.name}`); const updatedRoles = singleRoleIds.filter(id => !invalidRoleIds.includes(id)); await updateWelcomeConfig(client, guild.id, { roleIds: updatedRoles @@ -204,4 +206,4 @@ export default { } } }, -}; \ No newline at end of file +} satisfies Command; diff --git a/src/commands/Welcome/goodbye.js b/src/commands/Welcome/goodbye.ts similarity index 91% rename from src/commands/Welcome/goodbye.js rename to src/commands/Welcome/goodbye.ts index 3d45b93309..95a4a18bbc 100644 --- a/src/commands/Welcome/goodbye.js +++ b/src/commands/Welcome/goodbye.ts @@ -1,10 +1,13 @@ import { getColor } from '../../config/bot.js'; import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, EmbedBuilder, MessageFlags } from 'discord.js'; +import type { ChatInputCommandInteraction } from 'discord.js'; import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; import { formatWelcomeMessage, truncateForEmbedField } from '../../utils/welcome.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; +import type { Command } from '../../types/index.js'; +import type { WelcomeEmbedConfig } from '../../utils/database.js'; export default { data: new SlashCommandBuilder() @@ -44,7 +47,7 @@ export default { return; } - const { options, guild, client } = interaction; + const { options, guild, client } = interaction as ChatInputCommandInteraction<'cached'>; if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the **Manage Server** permission to use `/goodbye`.' }); @@ -53,7 +56,7 @@ export default { const subcommand = options.getSubcommand(); if (subcommand === 'setup') { - const channel = options.getChannel('channel'); + const channel = options.getChannel('channel')!; const message = options.getString('message'); const image = options.getString('image'); const ping = options.getBoolean('ping') ?? false; @@ -84,13 +87,14 @@ export default { goodbyeChannelId: channel.id, leaveMessage: message, goodbyePing: ping, + // Stored color is numeric (getColor); WelcomeEmbedConfig.color is typed string-only. leaveEmbed: { title: "Goodbye {user.tag}", description: message, color: getColor('error'), footer: `Goodbye from ${guild.name}!`, ...(image && { image: { url: image } }) - } + } as Record as WelcomeEmbedConfig }); logger.info(`[Goodbye] Setup configured by ${interaction.user.tag} for guild ${guild.name} (${guild.id})`); @@ -122,4 +126,4 @@ export default { } } }, -}; \ No newline at end of file +} satisfies Command; diff --git a/src/commands/Welcome/greet.js b/src/commands/Welcome/greet.ts similarity index 96% rename from src/commands/Welcome/greet.js rename to src/commands/Welcome/greet.ts index b2d760e52b..a854d43eee 100644 --- a/src/commands/Welcome/greet.js +++ b/src/commands/Welcome/greet.ts @@ -3,6 +3,7 @@ import { InteractionHelper } from '../../utils/interactionHelper.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError, NexoBotError, ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; import greetDashboard from './modules/greet_dashboard.js'; +import type { Command } from '../../types/index.js'; export default { slashOnly: true, @@ -37,4 +38,4 @@ export default { await handleInteractionError(interaction, error, { command: 'greet' }); } }, -}; \ No newline at end of file +} satisfies Command; diff --git a/src/commands/Welcome/modules/greet_dashboard.js b/src/commands/Welcome/modules/greet_dashboard.ts similarity index 86% rename from src/commands/Welcome/modules/greet_dashboard.js rename to src/commands/Welcome/modules/greet_dashboard.ts index a8c1e3a92e..d644e788e2 100644 --- a/src/commands/Welcome/modules/greet_dashboard.js +++ b/src/commands/Welcome/modules/greet_dashboard.ts @@ -17,14 +17,24 @@ import { FileUploadBuilder, TextDisplayBuilder, } from 'discord.js'; +import type { + ChatInputCommandInteraction, + Guild, + GuildBasedChannel, + InteractionReplyOptions, + MessageComponentInteraction, + StringSelectMenuInteraction, +} from 'discord.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; import { successEmbed } from '../../../utils/embeds.js'; import { logger } from '../../../utils/logger.js'; import { NexoBotError, ErrorTypes, replyUserError } from '../../../utils/errorHandler.js'; import { getWelcomeConfig, saveWelcomeConfig } from '../../../utils/database.js'; import { botHasPermission } from '../../../utils/permissionGuard.js'; +import type { WelcomeConfig, WelcomeEmbedConfig } from '../../../utils/database.js'; +import type { NexoClient, SubcommandModule } from '../../../types/index.js'; -async function deferComponent(interaction) { +async function deferComponent(interaction: MessageComponentInteraction) { if (interaction.deferred || interaction.replied) { return true; } @@ -33,23 +43,23 @@ async function deferComponent(interaction) { await interaction.deferUpdate(); return true; } catch (error) { - logger.debug('Component interaction expired or already acknowledged:', error.message); + logger.debug('Component interaction expired or already acknowledged:', (error as Error).message); return false; } } -async function sendEphemeralFollowUp(interaction, payload) { +async function sendEphemeralFollowUp(interaction: MessageComponentInteraction, payload: InteractionReplyOptions) { try { await interaction.followUp({ ...payload, flags: MessageFlags.Ephemeral, }); } catch (error) { - logger.debug('Failed to send ephemeral follow-up:', error.message); + logger.debug('Failed to send ephemeral follow-up:', (error as Error).message); } } -function buildDashboardEmbed(cfg, guild) { +function buildDashboardEmbed(cfg: WelcomeConfig, guild: Guild) { const welcomeChannel = cfg.channelId ? `<#${cfg.channelId}>` : '`Not set`'; const goodbyeChannel = cfg.goodbyeChannelId ? `<#${cfg.goodbyeChannelId}>` : '`Not set`'; @@ -78,7 +88,7 @@ function buildDashboardEmbed(cfg, guild) { .setTimestamp(); } -function buildSelectMenu(guildId) { +function buildSelectMenu(guildId: string) { return new StringSelectMenuBuilder() .setCustomId(`greet_cfg_${guildId}`) .setPlaceholder('Select a setting to configure...') @@ -116,14 +126,14 @@ function buildSelectMenu(guildId) { ); } -function buildButtonRow(cfg, guildId, disabled = false) { +function buildButtonRow(cfg: WelcomeConfig, guildId: string, disabled = false) { const welcomeOn = cfg.enabled === true; const goodbyeOn = cfg.goodbyeEnabled === true; const welcomePingOn = cfg.welcomePing === true; const goodbyePingOn = cfg.goodbyePing === true; return [ - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`greet_cfg_toggle_welcome_${guildId}`) .setLabel('Welcome') @@ -137,7 +147,7 @@ function buildButtonRow(cfg, guildId, disabled = false) { .setEmoji('🔴') .setDisabled(disabled), ), - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`greet_cfg_ping_welcome_${guildId}`) .setLabel('Ping Welcome') @@ -154,18 +164,18 @@ function buildButtonRow(cfg, guildId, disabled = false) { ]; } -async function refreshDashboard(rootInteraction, cfg, guildId) { +async function refreshDashboard(rootInteraction: ChatInputCommandInteraction, cfg: WelcomeConfig, guildId: string) { try { const selectMenu = buildSelectMenu(guildId); await InteractionHelper.safeEditReply(rootInteraction, { - embeds: [buildDashboardEmbed(cfg, rootInteraction.guild)], + embeds: [buildDashboardEmbed(cfg, rootInteraction.guild!)], components: [ ...buildButtonRow(cfg, guildId), - new ActionRowBuilder().addComponents(selectMenu), + new ActionRowBuilder().addComponents(selectMenu), ], }); } catch (error) { - logger.debug('Could not refresh greet dashboard (interaction may have expired):', error.message); + logger.debug('Could not refresh greet dashboard (interaction may have expired):', (error as Error).message); } } @@ -173,7 +183,7 @@ export default { prefixOnly: false, async execute(interaction, config, client) { try { - const guildId = interaction.guild.id; + const guildId = interaction.guild!.id; const cfg = await getWelcomeConfig(client, guildId); if (!cfg.channelId && !cfg.goodbyeChannelId) { @@ -192,14 +202,14 @@ export default { const selectMenu = buildSelectMenu(guildId); await InteractionHelper.safeEditReply(interaction, { - embeds: [buildDashboardEmbed(cfg, interaction.guild)], + embeds: [buildDashboardEmbed(cfg, interaction.guild!)], components: [ ...buildButtonRow(cfg, guildId), - new ActionRowBuilder().addComponents(selectMenu), + new ActionRowBuilder().addComponents(selectMenu), ], }); - const collector = interaction.channel.createMessageComponentCollector({ + const collector = interaction.channel!.createMessageComponentCollector({ componentType: ComponentType.StringSelect, filter: i => i.user.id === interaction.user.id && i.customId === `greet_cfg_${guildId}`, @@ -231,7 +241,7 @@ export default { } } catch (error) { if (error instanceof NexoBotError) { - logger.debug(`Greet config validation error: ${error.message}`); + logger.debug(`Greet config validation error: ${(error as Error).message}`); } else { logger.error('Unexpected greet dashboard error:', error); } @@ -252,7 +262,7 @@ export default { } }); - const btnCollector = interaction.channel.createMessageComponentCollector({ + const btnCollector = interaction.channel!.createMessageComponentCollector({ componentType: ComponentType.Button, filter: i => i.user.id === interaction.user.id && @@ -337,7 +347,7 @@ export default { components: [], }); } catch (error) { - logger.debug('Could not update dashboard on timeout:', error.message); + logger.debug('Could not update dashboard on timeout:', (error as Error).message); } } }); @@ -345,15 +355,15 @@ export default { if (error instanceof NexoBotError) throw error; logger.error('Unexpected error in greet_dashboard:', error); throw new NexoBotError( - `Greet dashboard failed: ${error.message}`, + `Greet dashboard failed: ${(error as Error).message}`, ErrorTypes.UNKNOWN, 'Failed to open the greet dashboard.', ); } }, -}; +} satisfies SubcommandModule & { prefixOnly: boolean }; -async function handleWelcomeChannel(selectInteraction, rootInteraction, cfg, guildId, client) { +async function handleWelcomeChannel(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, cfg: WelcomeConfig, guildId: string, client: NexoClient) { if (!await deferComponent(selectInteraction)) { return; } @@ -373,10 +383,10 @@ async function handleWelcomeChannel(selectInteraction, rootInteraction, cfg, gui ) .setColor(getColor('info')), ], - components: [new ActionRowBuilder().addComponents(channelSelect)], + components: [new ActionRowBuilder().addComponents(channelSelect)], }); - const chanCollector = rootInteraction.channel.createMessageComponentCollector({ + const chanCollector = rootInteraction.channel!.createMessageComponentCollector({ componentType: ComponentType.ChannelSelect, filter: i => i.user.id === selectInteraction.user.id && i.customId === 'greet_cfg_welcome_channel', @@ -388,7 +398,7 @@ async function handleWelcomeChannel(selectInteraction, rootInteraction, cfg, gui if (!await deferComponent(chanInteraction)) { return; } - const channel = chanInteraction.channels.first(); + const channel = chanInteraction.channels.first() as GuildBasedChannel | undefined; if (!botHasPermission(channel, ['ViewChannel', 'SendMessages', 'EmbedLinks'])) { await replyUserError(chanInteraction, { @@ -398,7 +408,7 @@ async function handleWelcomeChannel(selectInteraction, rootInteraction, cfg, gui return; } - cfg.channelId = channel.id; + cfg.channelId = channel!.id; await saveWelcomeConfig(client, guildId, cfg); await sendEphemeralFollowUp(chanInteraction, { @@ -418,12 +428,12 @@ async function handleWelcomeChannel(selectInteraction, rootInteraction, cfg, gui }); } -async function handleWelcomeMessage(selectInteraction, rootInteraction, cfg, guildId, client) { +async function handleWelcomeMessage(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, cfg: WelcomeConfig, guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId('greet_cfg_welcome_message') .setTitle('Edit Welcome Message') .addComponents( - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('message_input') .setLabel('Message (variables: {user}, {server}, etc)') @@ -462,7 +472,7 @@ async function handleWelcomeMessage(selectInteraction, rootInteraction, cfg, gui await refreshDashboard(rootInteraction, cfg, guildId); } -async function handleWelcomeImage(selectInteraction, rootInteraction, cfg, guildId, client) { +async function handleWelcomeImage(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, cfg: WelcomeConfig, guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId('greet_cfg_welcome_image') .setTitle('Set Welcome Image'); @@ -536,7 +546,7 @@ async function handleWelcomeImage(selectInteraction, rootInteraction, cfg, guild await refreshDashboard(rootInteraction, cfg, guildId); } -async function handleWelcomePing(selectInteraction, rootInteraction, cfg, guildId, client) { +async function handleWelcomePing(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, cfg: WelcomeConfig, guildId: string, client: NexoClient) { if (!await deferComponent(selectInteraction)) { return; } @@ -556,7 +566,7 @@ async function handleWelcomePing(selectInteraction, rootInteraction, cfg, guildI await refreshDashboard(rootInteraction, cfg, guildId); } -async function handleGoodbyeChannel(selectInteraction, rootInteraction, cfg, guildId, client) { +async function handleGoodbyeChannel(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, cfg: WelcomeConfig, guildId: string, client: NexoClient) { if (!await deferComponent(selectInteraction)) { return; } @@ -576,10 +586,10 @@ async function handleGoodbyeChannel(selectInteraction, rootInteraction, cfg, gui ) .setColor(getColor('info')), ], - components: [new ActionRowBuilder().addComponents(channelSelect)], + components: [new ActionRowBuilder().addComponents(channelSelect)], }); - const chanCollector = rootInteraction.channel.createMessageComponentCollector({ + const chanCollector = rootInteraction.channel!.createMessageComponentCollector({ componentType: ComponentType.ChannelSelect, filter: i => i.user.id === selectInteraction.user.id && i.customId === 'greet_cfg_goodbye_channel', @@ -591,7 +601,7 @@ async function handleGoodbyeChannel(selectInteraction, rootInteraction, cfg, gui if (!await deferComponent(chanInteraction)) { return; } - const channel = chanInteraction.channels.first(); + const channel = chanInteraction.channels.first() as GuildBasedChannel | undefined; if (!botHasPermission(channel, ['ViewChannel', 'SendMessages', 'EmbedLinks'])) { await replyUserError(chanInteraction, { @@ -601,7 +611,7 @@ async function handleGoodbyeChannel(selectInteraction, rootInteraction, cfg, gui return; } - cfg.goodbyeChannelId = channel.id; + cfg.goodbyeChannelId = channel!.id; await saveWelcomeConfig(client, guildId, cfg); await sendEphemeralFollowUp(chanInteraction, { @@ -621,12 +631,12 @@ async function handleGoodbyeChannel(selectInteraction, rootInteraction, cfg, gui }); } -async function handleGoodbyeMessage(selectInteraction, rootInteraction, cfg, guildId, client) { +async function handleGoodbyeMessage(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, cfg: WelcomeConfig, guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId('greet_cfg_goodbye_message') .setTitle('Edit Goodbye Message') .addComponents( - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('message_input') .setLabel('Message (variables: {user}, {server}, etc)') @@ -665,7 +675,7 @@ async function handleGoodbyeMessage(selectInteraction, rootInteraction, cfg, gui await refreshDashboard(rootInteraction, cfg, guildId); } -async function handleGoodbyeImage(selectInteraction, rootInteraction, cfg, guildId, client) { +async function handleGoodbyeImage(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, cfg: WelcomeConfig, guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId('greet_cfg_goodbye_image') .setTitle('Set Goodbye Image'); @@ -683,7 +693,7 @@ async function handleGoodbyeImage(selectInteraction, rootInteraction, cfg, guild .setValue( typeof cfg.leaveEmbed?.image === 'string' ? cfg.leaveEmbed.image - : cfg.leaveEmbed?.image?.url || '' + : (cfg.leaveEmbed?.image as { url?: string } | undefined)?.url || '' ) .setRequired(false), ); @@ -732,7 +742,7 @@ async function handleGoodbyeImage(selectInteraction, rootInteraction, cfg, guild } } - const nextLeaveEmbed = { ...(cfg.leaveEmbed || {}) }; + const nextLeaveEmbed: WelcomeEmbedConfig = { ...(cfg.leaveEmbed || {}) }; if (imageUrl) { nextLeaveEmbed.image = imageUrl; } else { @@ -750,7 +760,7 @@ async function handleGoodbyeImage(selectInteraction, rootInteraction, cfg, guild await refreshDashboard(rootInteraction, cfg, guildId); } -async function handleGoodbyePing(selectInteraction, rootInteraction, cfg, guildId, client) { +async function handleGoodbyePing(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, cfg: WelcomeConfig, guildId: string, client: NexoClient) { if (!await deferComponent(selectInteraction)) { return; } diff --git a/src/commands/Welcome/welcome.js b/src/commands/Welcome/welcome.ts similarity index 94% rename from src/commands/Welcome/welcome.js rename to src/commands/Welcome/welcome.ts index 840ba7bdd3..f38ee2cb62 100644 --- a/src/commands/Welcome/welcome.js +++ b/src/commands/Welcome/welcome.ts @@ -1,10 +1,12 @@ import { getColor } from '../../config/bot.js'; import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, EmbedBuilder, MessageFlags } from 'discord.js'; +import type { ChatInputCommandInteraction } from 'discord.js'; import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; import { formatWelcomeMessage, truncateForEmbedField } from '../../utils/welcome.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; +import type { Command } from '../../types/index.js'; export default { data: new SlashCommandBuilder() @@ -45,11 +47,11 @@ export default { return; } } catch (deferError) { - logger.error(`Welcome defer error`, { error: deferError.message }); + logger.error(`Welcome defer error`, { error: deferError instanceof Error ? deferError.message : String(deferError) }); return; } - const { options, guild, client } = interaction; + const { options, guild, client } = interaction as ChatInputCommandInteraction<'cached'>; if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the **Manage Server** permission to use `/welcome`.' }); @@ -58,7 +60,7 @@ export default { const subcommand = options.getSubcommand(); if (subcommand === 'setup') { - const channel = options.getChannel('channel'); + const channel = options.getChannel('channel')!; const message = options.getString('message'); const image = options.getString('image'); const ping = options.getBoolean('ping') ?? false; @@ -121,4 +123,4 @@ export default { } } }, -}; \ No newline at end of file +} satisfies Command; diff --git a/src/events/channelDelete.js b/src/events/channelDelete.ts similarity index 98% rename from src/events/channelDelete.js rename to src/events/channelDelete.ts index c8fd68f445..9f74204f29 100644 --- a/src/events/channelDelete.js +++ b/src/events/channelDelete.ts @@ -7,6 +7,7 @@ import { } from '../utils/database.js'; import { getServerCounters, saveServerCounters } from '../services/serverstatsService.js'; import { logger } from '../utils/logger.js'; +import type { BotEvent } from '../types/index.js'; export default { name: 'channelDelete', @@ -96,4 +97,4 @@ if (channel.type !== 2 && channel.type !== 4) { logger.error(`Error in channelDelete event for guild ${guildId}:`, error); } } -}; \ No newline at end of file +} satisfies BotEvent<'channelDelete'>; \ No newline at end of file diff --git a/src/events/guildCreate.js b/src/events/guildCreate.ts similarity index 87% rename from src/events/guildCreate.js rename to src/events/guildCreate.ts index 7972714a59..091913d928 100644 --- a/src/events/guildCreate.js +++ b/src/events/guildCreate.ts @@ -1,6 +1,7 @@ import { Events } from 'discord.js'; import { logger } from '../utils/logger.js'; import { getGuildConfig, setGuildConfig } from '../services/config/guildConfig.js'; +import type { BotEvent } from '../types/index.js'; export default { name: Events.GuildCreate, @@ -19,4 +20,4 @@ export default { logger.error(`Error initializing guild ${guild?.id} on join:`, error); } }, -}; +} satisfies BotEvent; diff --git a/src/events/guildMemberAdd.js b/src/events/guildMemberAdd.ts similarity index 82% rename from src/events/guildMemberAdd.js rename to src/events/guildMemberAdd.ts index cb77b477b4..8930805706 100644 --- a/src/events/guildMemberAdd.js +++ b/src/events/guildMemberAdd.ts @@ -1,4 +1,5 @@ import { Events, EmbedBuilder, PermissionFlagsBits } from 'discord.js'; +import type { ColorResolvable, Guild, GuildMember, GuildTextBasedChannel, Role } from 'discord.js'; import { getColor, botConfig } from '../config/bot.js'; import { getGuildConfig } from '../services/config/guildConfig.js'; import { getWelcomeConfig } from '../utils/database.js'; @@ -7,6 +8,10 @@ import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; import { getServerCounters, updateCounter } from '../services/serverstatsService.js'; import { setBirthday as dbSetBirthday } from '../utils/database.js'; import { logger } from '../utils/logger.js'; +import type { BirthdayEntry } from '../utils/database.js'; +import type { VerificationConfig } from '../services/verificationService.js'; +import type { BotEvent, NexoClient } from '../types/index.js'; +import type { ErrorLike } from '../utils/errorHandler.js'; export default { name: Events.GuildMemberAdd, @@ -48,12 +53,12 @@ export default { const canEmbed = permissions.has(PermissionFlagsBits.EmbedLinks); if (!canEmbed) { - await channel.send({ + await (channel as GuildTextBasedChannel).send({ content: messageContent || welcomeMessage }); } else { const embed = new EmbedBuilder() - .setColor(welcomeConfig.welcomeEmbed?.color || getColor('success')) + .setColor((welcomeConfig.welcomeEmbed?.color || getColor('success')) as ColorResolvable) .setTitle(embedTitle) .setDescription(welcomeMessage) .setThumbnail(user.displayAvatarURL()) @@ -64,14 +69,17 @@ export default { .setTimestamp() .setFooter({ text: embedFooter }); + // Stored embed config may carry an image object (not part of WelcomeEmbedConfig). + const welcomeEmbedImage = welcomeConfig.welcomeEmbed?.image as { url?: string } | undefined; if (welcomeConfig.welcomeImage) { embed.setImage(welcomeConfig.welcomeImage); - } else if (welcomeConfig.welcomeEmbed?.image?.url) { - embed.setImage(welcomeConfig.welcomeEmbed.image.url); + } else if (welcomeEmbedImage?.url) { + embed.setImage(welcomeEmbedImage.url); } - await channel.send({ - content: messageContent, + await (channel as GuildTextBasedChannel).send({ + // discord.js serializes null content as empty; the typings only allow string | undefined. + content: messageContent as string | undefined, embeds: [embed] }); } @@ -118,7 +126,7 @@ export default { `**Members:** ${guild.memberCount}`, ], quoted: false, - thumbnail: user.displayAvatarURL({ dynamic: true }), + thumbnail: user.displayAvatarURL(), userId: user.id, } }); @@ -139,7 +147,7 @@ export default { try { const backupKey = `guild:${guild.id}:birthdays:left`; - const backup = (await member.client.db.get(backupKey)) || {}; + const backup = (await member.client.db.get>(backupKey)) || {}; if (backup[user.id]) { const { month, day } = backup[user.id]; await dbSetBirthday(member.client, guild.id, user.id, month, day); @@ -155,9 +163,9 @@ export default { logger.error('Error in guildMemberAdd event:', error); } } -}; +} satisfies BotEvent; -async function handleVerification(member, guild, verificationConfig, client) { +async function handleVerification(member: GuildMember, guild: Guild, verificationConfig: VerificationConfig, client: NexoClient) { const { autoVerifyOnJoin } = await import('../services/verificationService.js'); try { @@ -184,12 +192,12 @@ async function handleVerification(member, guild, verificationConfig, client) { guildId: guild.id, userId: member.id, userTag: member.user.tag, - error: error.message + error: (error as ErrorLike).message }); } } -async function assignRoleSafely(member, role) { +async function assignRoleSafely(member: GuildMember, role: Role) { try { await member.roles.add(role); } catch (error) { diff --git a/src/events/guildMemberRemove.js b/src/events/guildMemberRemove.ts similarity index 87% rename from src/events/guildMemberRemove.js rename to src/events/guildMemberRemove.ts index a550082635..e0d990165c 100644 --- a/src/events/guildMemberRemove.js +++ b/src/events/guildMemberRemove.ts @@ -1,4 +1,5 @@ import { Events, EmbedBuilder, PermissionFlagsBits } from 'discord.js'; +import type { ColorResolvable } from 'discord.js'; import { getColor, botConfig } from '../config/bot.js'; import { getWelcomeConfig, getUserApplications, deleteApplication } from '../utils/database.js'; import { formatWelcomeMessage } from '../utils/welcome.js'; @@ -7,6 +8,8 @@ import { getServerCounters, updateCounter } from '../services/serverstatsService import { getGuildBirthdays, deleteBirthday } from '../utils/database.js'; import { deleteUserLevelData } from '../services/leveling/leveling.js'; import { logger } from '../utils/logger.js'; +import type { BirthdayEntry } from '../utils/database.js'; +import type { BotEvent } from '../types/index.js'; export default { name: Events.GuildMemberRemove, @@ -54,7 +57,7 @@ export default { const embed = new EmbedBuilder() .setTitle(embedTitle) .setDescription(goodbyeMessage) - .setColor(welcomeConfig.leaveEmbed?.color || getColor('error')) + .setColor((welcomeConfig.leaveEmbed?.color || getColor('error')) as ColorResolvable) .setThumbnail(user.displayAvatarURL()) .addFields( { name: 'User', value: `${user.tag} (${user.id})`, inline: true }, @@ -63,10 +66,12 @@ export default { .setTimestamp() .setFooter({ text: embedFooter }); - if (typeof welcomeConfig.leaveEmbed?.image === 'string') { - embed.setImage(welcomeConfig.leaveEmbed.image); - } else if (welcomeConfig.leaveEmbed?.image?.url) { - embed.setImage(welcomeConfig.leaveEmbed.image.url); + // Stored embed config may carry an image string or object (not part of WelcomeEmbedConfig). + const leaveEmbedImage = welcomeConfig.leaveEmbed?.image as string | { url?: string } | undefined; + if (typeof leaveEmbedImage === 'string') { + embed.setImage(leaveEmbedImage); + } else if (leaveEmbedImage?.url) { + embed.setImage(leaveEmbedImage.url); } await channel.send({ @@ -92,7 +97,7 @@ export default { `**Members:** ${guild.memberCount}`, ], quoted: false, - thumbnail: user.displayAvatarURL({ dynamic: true }), + thumbnail: user.displayAvatarURL(), userId: user.id, } }); @@ -115,7 +120,7 @@ export default { const birthdays = await getGuildBirthdays(member.client, guild.id); if (birthdays[user.id]) { const backupKey = `guild:${guild.id}:birthdays:left`; - const backup = (await member.client.db.get(backupKey)) || {}; + const backup = (await member.client.db.get>(backupKey)) || {}; backup[user.id] = birthdays[user.id]; await member.client.db.set(backupKey, backup); await deleteBirthday(member.client, guild.id, user.id); @@ -148,4 +153,4 @@ export default { logger.error('Error in guildMemberRemove event:', error); } } -}; \ No newline at end of file +} satisfies BotEvent; \ No newline at end of file diff --git a/src/events/guildMemberUpdate.js b/src/events/guildMemberUpdate.ts similarity index 87% rename from src/events/guildMemberUpdate.js rename to src/events/guildMemberUpdate.ts index 80dc788067..0bd6f4fc04 100644 --- a/src/events/guildMemberUpdate.js +++ b/src/events/guildMemberUpdate.ts @@ -1,6 +1,7 @@ import { Events } from 'discord.js'; import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; import { logger } from '../utils/logger.js'; +import type { BotEvent } from '../types/index.js'; export default { name: Events.GuildMemberUpdate, @@ -23,7 +24,7 @@ export default { `**Before:** ${oldMember.nickname || '*(no nickname)*'}`, `**After:** ${newMember.nickname || '*(no nickname)*'}`, ], - thumbnail: newMember.user.displayAvatarURL({ dynamic: true }), + thumbnail: newMember.user.displayAvatarURL(), userId: newMember.user.id, } }); @@ -35,4 +36,4 @@ export default { logger.error('Error in guildMemberUpdate event:', error); } } -}; \ No newline at end of file +} satisfies BotEvent; \ No newline at end of file diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.ts similarity index 88% rename from src/events/interactionCreate.js rename to src/events/interactionCreate.ts index 50a468b73e..a8547f39c6 100644 --- a/src/events/interactionCreate.js +++ b/src/events/interactionCreate.ts @@ -1,4 +1,5 @@ import { Events, MessageFlags } from 'discord.js'; +import type { GuildTextBasedChannel, Interaction } from 'discord.js'; import { logger } from '../utils/logger.js'; import { getGuildConfig } from '../services/config/guildConfig.js'; import { @@ -19,8 +20,19 @@ import { resolveSlashAccessKey } from '../utils/messageAdapter.js'; import { isCollectorManagedComponent } from '../utils/collectorComponents.js'; import { ResponseCoordinator } from '../utils/responseCoordinator.js'; import { enforceDefaultCommandPermissions } from '../utils/permissionGuard.js'; +import type { ErrorContext, ErrorLike } from '../utils/errorHandler.js'; +import type { InteractionExtras, NexoInteraction } from '../utils/interactionHelper.js'; +import type { TraceContext } from '../utils/logger.js'; +import type { ReactionRoleMessage } from '../services/reactionRoleService.js'; +import type { BotEvent, GuildConfig } from '../types/index.js'; -const COMMAND_ERROR_SUBTYPES = { +/** Error context plus the identifiers withTraceContext folds into the trace fields. */ +type TraceableContext = ErrorContext & { commandName?: string; guildId?: string | null; userId?: string | null }; + +/** Any interaction viewed with the optional identifiers read in the unhandled-error path. */ +type UnhandledInteractionView = Interaction & { commandName?: string; customId?: string }; + +const COMMAND_ERROR_SUBTYPES: Record = { warn: 'warn_failed', kick: 'kick_failed', ban: 'ban_failed', @@ -36,7 +48,7 @@ const COMMAND_ERROR_SUBTYPES = { greroll: 'giveaway_failed', }; -function withTraceContext(context = {}, traceContext = {}) { +function withTraceContext(context: TraceableContext = {}, traceContext: Partial = {}): ErrorContext { return { traceId: traceContext.traceId, guildId: context.guildId || traceContext.guildId, @@ -50,13 +62,13 @@ export default { name: Events.InteractionCreate, async execute(interaction, client) { const interactionTraceContext = createInteractionTraceContext(interaction); - interaction.traceContext = interactionTraceContext; - interaction.traceId = interactionTraceContext.traceId; + (interaction as Interaction & InteractionExtras).traceContext = interactionTraceContext; + (interaction as Interaction & InteractionExtras).traceId = interactionTraceContext.traceId; return runWithTraceContext(interactionTraceContext, async () => { try { - InteractionHelper.patchInteractionResponses(interaction); - ResponseCoordinator.attach(interaction); + InteractionHelper.patchInteractionResponses(interaction as NexoInteraction); + ResponseCoordinator.attach(interaction as NexoInteraction); if (interaction.isChatInputCommand()) { try { @@ -138,9 +150,9 @@ export default { ); } - let guildConfig = null; + let guildConfig: GuildConfig | null = null; if (interaction.guild) { - guildConfig = await getGuildConfig(client, interaction.guild.id, interactionTraceContext); + guildConfig = await getGuildConfig(client, interaction.guild.id, { ...interactionTraceContext }); const accessKey = resolveSlashAccessKey(interaction); if (!(await isCommandEnabled(client, interaction.guild.id, accessKey, command.category))) { throw createError( @@ -165,7 +177,7 @@ export default { await handleInteractionError(interaction, error, withTraceContext({ type: 'command', commandName: interaction.commandName, - subtype: COMMAND_ERROR_SUBTYPES[interaction.commandName] || error?.context?.subtype, + subtype: COMMAND_ERROR_SUBTYPES[interaction.commandName] || (error as ErrorLike | null | undefined)?.context?.subtype, }, interactionTraceContext)); } } else if (interaction.isAutocomplete()) { @@ -175,7 +187,7 @@ export default { await autocompleteCommand.autocomplete(interaction, client); } catch (error) { logger.error('Error handling command autocomplete:', { - error: error.message, + error: (error as ErrorLike).message, guildId: interaction.guildId, commandName: interaction.commandName, }); @@ -189,7 +201,7 @@ export default { if (interaction.commandName === 'apply' && focusedOption.name === 'application') { try { const { getApplicationRoles } = await import('../utils/database.js'); - const roles = await getApplicationRoles(client, interaction.guildId); + const roles = await getApplicationRoles(client, interaction.guildId!); const roleName = interaction.options.getString('application', false); const filtered = roles.filter(role => @@ -205,7 +217,7 @@ export default { ); } catch (error) { logger.error('Error handling autocomplete:', { - error: error.message, + error: (error as ErrorLike).message, guildId: interaction.guildId, commandName: interaction.commandName }); @@ -214,7 +226,7 @@ export default { } else if (interaction.commandName === 'app-admin' && focusedOption.name === 'application') { try { const { getApplicationRoles } = await import('../utils/database.js'); - const roles = await getApplicationRoles(client, interaction.guildId); + const roles = await getApplicationRoles(client, interaction.guildId!); const appName = interaction.options.getString('application', false); const filtered = roles.filter(role => @@ -229,7 +241,7 @@ export default { ); } catch (error) { logger.error('Error handling app-admin autocomplete:', { - error: error.message, + error: (error as ErrorLike).message, guildId: interaction.guildId, commandName: interaction.commandName }); @@ -238,8 +250,8 @@ export default { } else if (interaction.commandName === 'reactroles' && focusedOption.name === 'panel') { try { const { getAllReactionRoleMessages, deleteReactionRoleMessage } = await import('../services/reactionRoleService.js'); - const guildId = interaction.guildId; - const guild = interaction.guild; + const guildId = interaction.guildId!; + const guild = interaction.guild!; let panels = await getAllReactionRoleMessages(client, guildId); @@ -248,13 +260,13 @@ export default { return; } - const validPanels = []; + const validPanels: ReactionRoleMessage[] = []; for (const panel of panels) { if (!panel.messageId || !panel.channelId) { continue; } - const channel = guild.channels.cache.get(panel.channelId); + const channel = guild.channels.cache.get(panel.channelId) as GuildTextBasedChannel | undefined; if (!channel) { await deleteReactionRoleMessage(client, guildId, panel.messageId).catch(() => {}); continue; @@ -276,7 +288,7 @@ export default { const choices = await Promise.all( validPanels.slice(0, 25).map(async panel => { try { - const channel = guild.channels.cache.get(panel.channelId); + const channel = guild.channels.cache.get(panel.channelId) as GuildTextBasedChannel | undefined; if (!channel) return null; const msg = await channel.messages.fetch(panel.messageId).catch(() => null); @@ -299,7 +311,7 @@ export default { await interaction.respond(validChoices); } catch (error) { logger.error('Error handling reactroles autocomplete:', { - error: error.message, + error: (error as ErrorLike).message, guildId: interaction.guildId, commandName: interaction.commandName }); @@ -451,10 +463,10 @@ export default { }); try { - await handleInteractionError(interaction, error, withTraceContext({ + await handleInteractionError(interaction as NexoInteraction, error, withTraceContext({ type: 'interaction', - commandName: interaction.commandName, - customId: interaction.customId, + commandName: (interaction as UnhandledInteractionView).commandName, + customId: (interaction as UnhandledInteractionView).customId, source: 'interactionCreate.unhandled' }, interactionTraceContext)); } catch (replyError) { @@ -468,4 +480,4 @@ export default { } }); } -}; \ No newline at end of file +} satisfies BotEvent; \ No newline at end of file diff --git a/src/events/messageCreate.js b/src/events/messageCreate.ts similarity index 91% rename from src/events/messageCreate.js rename to src/events/messageCreate.ts index fb1a165f31..fb51979f03 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.ts @@ -1,4 +1,5 @@ import { Events } from 'discord.js'; +import type { Message } from 'discord.js'; import { logger } from '../utils/logger.js'; import { getLevelingConfig, getUserLevelData } from '../services/leveling/leveling.js'; import { addXp } from '../services/leveling/xpSystem.js'; @@ -18,6 +19,7 @@ import { isValidCountingMessage, recordCorrectCount, } from '../services/countingGameService.js'; +import type { BotEvent, NexoClient } from '../types/index.js'; const MESSAGE_XP_RATE_LIMIT_ATTEMPTS = 12; const MESSAGE_XP_RATE_LIMIT_WINDOW_MS = 10000; @@ -30,21 +32,21 @@ export default { logger.debug(`Message received from ${message.author.tag}: ${message.content}`); - const countingProcessed = await handleCountingGame(message, client); + const countingProcessed = await handleCountingGame(message as Message, client); if (countingProcessed) { return; } - await handlePrefixCommand(message, client); + await handlePrefixCommand(message as Message, client); - await handleLeveling(message, client); + await handleLeveling(message as Message, client); } catch (error) { logger.error('Error in messageCreate event:', error); } } -}; +} satisfies BotEvent; -async function handlePrefixCommand(message, client) { +async function handlePrefixCommand(message: Message, client: NexoClient) { try { const guildConfig = await getGuildConfig(client, message.guild.id); const prefix = guildConfig?.prefix || getCommandPrefix(); @@ -108,7 +110,8 @@ async function handlePrefixCommand(message, client) { return; } - if (!(await isCommandEnabled(client, message.guild.id, resolvePrefixAccessKey(command.data, args), command.category))) { + // Loaded commands always have a name, so the access key is never null here. + if (!(await isCommandEnabled(client, message.guild.id, resolvePrefixAccessKey(command.data, args)!, command.category))) { const embed = createEmbed({ title: 'Command Disabled', description: 'This command has been disabled for this server.', @@ -118,7 +121,7 @@ async function handlePrefixCommand(message, client) { return; } - const mockInteractionForProtection = { + const mockInteractionForProtection: Parameters[0] = { guildId: message.guild.id, user: message.author, }; @@ -146,7 +149,7 @@ async function handlePrefixCommand(message, client) { } } -async function handleCountingGame(message, client) { +async function handleCountingGame(message: Message, client: NexoClient) { try { const config = await getCountingGameConfig(client, message.guild.id); if (!config.enabled || !config.channelId || message.channel.id !== config.channelId) { @@ -182,7 +185,7 @@ async function handleCountingGame(message, client) { } } -async function handleLeveling(message, client) { +async function handleLeveling(message: Message, client: NexoClient) { try { const rateLimitKey = `xp-event:${message.guild.id}:${message.author.id}`; const canProcess = await checkRateLimit(rateLimitKey, MESSAGE_XP_RATE_LIMIT_ATTEMPTS, MESSAGE_XP_RATE_LIMIT_WINDOW_MS); @@ -240,7 +243,7 @@ async function handleLeveling(message, client) { finalXP = Math.floor(finalXP * levelingConfig.xpMultiplier); } - const result = await addXp(client, message.guild, message.member, finalXP); + const result = await addXp(client, message.guild, message.member!, finalXP); if (result?.leveledUp) { logger.info( diff --git a/src/events/messageDelete.js b/src/events/messageDelete.ts similarity index 88% rename from src/events/messageDelete.js rename to src/events/messageDelete.ts index 54d7a9c1db..15cd0efa52 100644 --- a/src/events/messageDelete.js +++ b/src/events/messageDelete.ts @@ -1,8 +1,10 @@ import { Events } from 'discord.js'; +import type { GuildTextBasedChannel } 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/logging/logEmbeds.js'; +import type { BotEvent } from '../types/index.js'; const MAX_LOGGED_MESSAGE_CONTENT_LENGTH = 1024; @@ -28,7 +30,7 @@ export default { data: { title: 'Reaction Role Removed', lines: [ - formatLogLine('Channel', message.channel ? `${message.channel.name} ${message.channel.toString()}` : 'Unknown'), + formatLogLine('Channel', message.channel ? `${(message.channel as GuildTextBasedChannel).name} ${message.channel.toString()}` : 'Unknown'), formatLogLine('Message ID', `\`${message.id}\``), formatLogLine('Cleanup', 'Database entry removed automatically'), ], @@ -46,7 +48,7 @@ export default { if (message.author?.bot) return; const metaLines = [ - formatLogLine('Channel', message.channel ? `${message.channel.name} ${message.channel.toString()}` : 'Unknown'), + formatLogLine('Channel', message.channel ? `${(message.channel as GuildTextBasedChannel).name} ${message.channel.toString()}` : 'Unknown'), formatLogLine('Message ID', `\`${message.id}\``), formatLogLine('Message author', message.author ? message.author.toString() : 'Unknown'), formatLogLine('Message created', ``), @@ -81,4 +83,4 @@ export default { logger.error('Error in messageDelete event:', error); } } -}; +} satisfies BotEvent; diff --git a/src/events/messageUpdate.js b/src/events/messageUpdate.ts similarity index 86% rename from src/events/messageUpdate.js rename to src/events/messageUpdate.ts index 18761fd553..ac0732dcf5 100644 --- a/src/events/messageUpdate.js +++ b/src/events/messageUpdate.ts @@ -1,7 +1,9 @@ import { Events } from 'discord.js'; +import type { GuildTextBasedChannel } from 'discord.js'; import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; import { logger } from '../utils/logger.js'; import { formatLogLine } from '../utils/logging/logEmbeds.js'; +import type { BotEvent } from '../types/index.js'; const MAX_LOGGED_EDIT_CONTENT_LENGTH = 512; @@ -16,7 +18,7 @@ export default { if (oldMessage.content === newMessage.content) return; const metaLines = [ - formatLogLine('Channel', newMessage.channel ? `${newMessage.channel.name} ${newMessage.channel.toString()}` : 'Unknown'), + formatLogLine('Channel', newMessage.channel ? `${(newMessage.channel as GuildTextBasedChannel).name} ${newMessage.channel.toString()}` : 'Unknown'), formatLogLine('Message ID', `\`${newMessage.id}\``), formatLogLine('Message author', newMessage.author ? newMessage.author.toString() : 'Unknown'), formatLogLine('Message created', ``), @@ -52,4 +54,4 @@ export default { logger.error('Error in messageUpdate event:', error); } } -}; +} satisfies BotEvent; diff --git a/src/events/ready.js b/src/events/ready.ts similarity index 97% rename from src/events/ready.js rename to src/events/ready.ts index 2be8e5e424..048e8f9cf7 100644 --- a/src/events/ready.js +++ b/src/events/ready.ts @@ -5,6 +5,7 @@ import { reconcileReactionRoleMessages } from "../services/reactionRoleService.j import { reconcileTicketPanels, reconcileVerificationPanels, reconcileReactionRolePanelHealth } from "../services/panelHealthService.js"; import { reconcileLevelRoles } from "../services/leveling/levelRoleSyncService.js"; import { initRiffyAfterReady } from "../services/music/riffySetup.js"; +import type { BotEvent } from '../types/index.js'; export default { name: Events.ClientReady, @@ -50,4 +51,4 @@ export default { logger.error("Error in ready event:", error); } }, -}; \ No newline at end of file +} satisfies BotEvent; \ No newline at end of file diff --git a/src/events/roleCreate.js b/src/events/roleCreate.ts similarity index 88% rename from src/events/roleCreate.js rename to src/events/roleCreate.ts index 1625974db1..16bcfacc0e 100644 --- a/src/events/roleCreate.js +++ b/src/events/roleCreate.ts @@ -2,6 +2,7 @@ import { Events } from 'discord.js'; import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; import { logger } from '../utils/logger.js'; import { buildRoleAuditLines } from '../utils/logging/logEmbeds.js'; +import type { BotEvent } from '../types/index.js'; export default { name: Events.GuildRoleCreate, @@ -28,4 +29,4 @@ export default { logger.error('Error in roleCreate event:', error); } } -}; \ No newline at end of file +} satisfies BotEvent; \ No newline at end of file diff --git a/src/events/roleDelete.js b/src/events/roleDelete.ts similarity index 89% rename from src/events/roleDelete.js rename to src/events/roleDelete.ts index 1e16b23dc8..dba791068e 100644 --- a/src/events/roleDelete.js +++ b/src/events/roleDelete.ts @@ -2,6 +2,7 @@ import { Events } from 'discord.js'; import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; import { logger } from '../utils/logger.js'; import { buildRoleAuditLines } from '../utils/logging/logEmbeds.js'; +import type { BotEvent } from '../types/index.js'; export default { name: Events.GuildRoleDelete, @@ -28,4 +29,4 @@ export default { logger.error('Error in roleDelete event:', error); } } -}; \ No newline at end of file +} satisfies BotEvent; \ No newline at end of file diff --git a/src/events/userUpdate.js b/src/events/userUpdate.ts similarity index 95% rename from src/events/userUpdate.js rename to src/events/userUpdate.ts index 256c8ec446..deb44ba993 100644 --- a/src/events/userUpdate.js +++ b/src/events/userUpdate.ts @@ -1,6 +1,7 @@ import { Events } from 'discord.js'; import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; import { logger } from '../utils/logger.js'; +import type { BotEvent } from '../types/index.js'; export default { name: Events.UserUpdate, @@ -71,4 +72,4 @@ export default { logger.error('Error in userUpdate event:', error); } } -}; \ No newline at end of file +} satisfies BotEvent; \ No newline at end of file diff --git a/src/events/voiceStateUpdate.js b/src/events/voiceStateUpdate.ts similarity index 86% rename from src/events/voiceStateUpdate.js rename to src/events/voiceStateUpdate.ts index c76c1e123f..7a153897c7 100644 --- a/src/events/voiceStateUpdate.js +++ b/src/events/voiceStateUpdate.ts @@ -1,4 +1,5 @@ import { ChannelType, PermissionFlagsBits } from 'discord.js'; +import type { GuildMember, VoiceBasedChannel, VoiceState } from 'discord.js'; import { getJoinToCreateConfig, registerTemporaryChannel, @@ -9,8 +10,13 @@ import { import { sanitizeInput } from '../utils/validation.js'; import { logger } from '../utils/logger.js'; import { handleMusicVoiceState } from '../services/music/musicVoiceState.js'; +import type { JoinToCreateChannelOptions, JoinToCreateConfig } from '../utils/database.js'; +import type { BotEvent, NexoClient } from '../types/index.js'; -const channelCreationCooldown = new Map(); +/** A voice state the handlers below only receive while the member is in a channel. */ +type ActiveVoiceState = VoiceState & { channel: VoiceBasedChannel; member: GuildMember }; + +const channelCreationCooldown = new Map(); const VOICE_CREATE_COOLDOWN_MS = 2000; const DEFAULT_VOICE_BITRATE = 64000; const MAX_VOICE_BITRATE = 384000; @@ -22,10 +28,10 @@ const MAX_TRACKED_COOLDOWNS = 10000; export default { name: 'voiceStateUpdate', async execute(oldState, newState, client) { - if (newState.member.user.bot) return; + if (newState.member!.user.bot) return; const guildId = newState.guild.id; - const userId = newState.member.id; + const userId = newState.member!.id; const cooldownKey = `${guildId}-${userId}`; cleanupCooldownEntries(); @@ -52,8 +58,8 @@ export default { logger.error(`Error in voiceStateUpdate for guild ${guildId}:`, error); } - async function handleVoiceJoin(client, state, config) { - const { channel, member } = state; + async function handleVoiceJoin(client: NexoClient, state: VoiceState, config: JoinToCreateConfig) { + const { channel, member } = state as ActiveVoiceState; if (!config.triggerChannels.includes(channel.id)) { return; @@ -61,7 +67,7 @@ export default { const now = Date.now(); if (channelCreationCooldown.has(cooldownKey)) { - const lastCreation = channelCreationCooldown.get(cooldownKey); + const lastCreation = channelCreationCooldown.get(cooldownKey)!; if (now - lastCreation < VOICE_CREATE_COOLDOWN_MS) { logger.warn(`User ${member.id} is on cooldown for channel creation`); return; @@ -79,7 +85,7 @@ if (now - lastCreation < VOICE_CREATE_COOLDOWN_MS) { const tempChannel = state.guild.channels.cache.get(existingTempChannel); if (tempChannel) { try { - await member.voice.setChannel(tempChannel); + await member.voice.setChannel(tempChannel as VoiceBasedChannel); return; } catch (error) { logger.warn(`Failed to move user ${member.id} to existing channel ${existingTempChannel}:`, error); @@ -97,8 +103,8 @@ if (now - lastCreation < VOICE_CREATE_COOLDOWN_MS) { await createTemporaryChannel(client, state, config); } - async function handleVoiceLeave(client, state, config) { - const { channel, member } = state; + async function handleVoiceLeave(client: NexoClient, state: VoiceState, config: JoinToCreateConfig) { + const { channel, member } = state as ActiveVoiceState; const tempChannelInfo = await getTemporaryChannelInfo(client, state.guild.id, channel.id); @@ -116,14 +122,14 @@ if (now - lastCreation < VOICE_CREATE_COOLDOWN_MS) { } } - async function handleVoiceMove(client, oldState, newState, config) { + async function handleVoiceMove(client: NexoClient, oldState: VoiceState, newState: VoiceState, config: JoinToCreateConfig) { if (oldState.channel) { const tempChannelInfo = await getTemporaryChannelInfo(client, oldState.guild.id, oldState.channel.id); if (tempChannelInfo) { if (oldState.channel.members.size === 0) { await deleteTemporaryChannel(client, oldState.channel, oldState.guild.id); - } else if (tempChannelInfo.ownerId === oldState.member.id) { + } else if (tempChannelInfo.ownerId === oldState.member!.id) { const nextMember = oldState.channel.members.first(); if (nextMember) { await transferChannelOwnership(client, oldState.channel, oldState.guild.id, nextMember.id); @@ -132,14 +138,14 @@ if (now - lastCreation < VOICE_CREATE_COOLDOWN_MS) { } } - if (config.triggerChannels.includes(newState.channel.id) && - !config.triggerChannels.includes(oldState.channel?.id)) { + if (config.triggerChannels.includes(newState.channel!.id) && + !(config.triggerChannels as (string | undefined)[]).includes(oldState.channel?.id)) { await handleVoiceJoin(client, newState, config); } } - async function createTemporaryChannel(client, state, config) { - const { channel: triggerChannel, member, guild } = state; + async function createTemporaryChannel(client: NexoClient, state: VoiceState, config: JoinToCreateConfig) { + const { channel: triggerChannel, member, guild } = state as ActiveVoiceState; try { const me = guild.members.me; @@ -156,7 +162,7 @@ if (now - lastCreation < VOICE_CREATE_COOLDOWN_MS) { return; } - const channelOptions = config.channelOptions?.[triggerChannel.id] || {}; + const channelOptions: Partial = config.channelOptions?.[triggerChannel.id] || {}; const nameTemplate = channelOptions.nameTemplate || config.channelNameTemplate || "{username}'s Room"; let userLimit = channelOptions.userLimit ?? config.userLimit ?? 0; @@ -171,7 +177,7 @@ if (now - lastCreation < VOICE_CREATE_COOLDOWN_MS) { c.name.startsWith(triggerChannel.name) ).size; - let finalName; + let finalName: string; if ( nameTemplate.includes('{username}') || @@ -239,7 +245,7 @@ userLimit: userLimit === 0 ? undefined : userLimit, } } - async function deleteTemporaryChannel(client, channel, guildId) { + async function deleteTemporaryChannel(client: NexoClient, channel: VoiceBasedChannel, guildId: string) { try { await unregisterTemporaryChannel(client, guildId, channel.id); @@ -252,7 +258,7 @@ userLimit: userLimit === 0 ? undefined : userLimit, } } - async function transferChannelOwnership(client, channel, guildId, newOwnerId) { + async function transferChannelOwnership(client: NexoClient, channel: VoiceBasedChannel, guildId: string, newOwnerId: string) { try { const config = await getJoinToCreateConfig(client, guildId); const tempChannelInfo = config.temporaryChannels[channel.id]; @@ -264,7 +270,7 @@ userLimit: userLimit === 0 ? undefined : userLimit, const newOwner = await channel.guild.members.fetch(newOwnerId); if (newOwner) { - const channelOptions = config.channelOptions?.[tempChannelInfo.triggerChannelId] || {}; + const channelOptions: Partial = config.channelOptions?.[tempChannelInfo.triggerChannelId] || {}; const nameTemplate = channelOptions.nameTemplate || config.channelNameTemplate; const newChannelName = sanitizeVoiceChannelName(formatChannelName(nameTemplate, { @@ -291,9 +297,9 @@ userLimit: userLimit === 0 ? undefined : userLimit, }); } } -}; +} satisfies BotEvent<'voiceStateUpdate'>; -function sanitizeVoiceChannelName(inputName) { +function sanitizeVoiceChannelName(inputName: unknown) { const safeName = sanitizeInput(String(inputName || ''), MAX_CHANNEL_NAME_LENGTH) .replace(/[\r\n\t]/g, ' ') .replace(/\s+/g, ' ') @@ -302,7 +308,7 @@ function sanitizeVoiceChannelName(inputName) { return safeName || FALLBACK_CHANNEL_NAME; } -function clampVoiceBitrate(value) { +function clampVoiceBitrate(value: unknown) { const parsed = Number(value); if (!Number.isFinite(parsed)) { return DEFAULT_VOICE_BITRATE; diff --git a/src/handlers/calculateModals.js b/src/handlers/calculateModals.ts similarity index 84% rename from src/handlers/calculateModals.js rename to src/handlers/calculateModals.ts index d83d013261..14a10b6343 100644 --- a/src/handlers/calculateModals.js +++ b/src/handlers/calculateModals.ts @@ -1,16 +1,19 @@ +import type { ModalSubmitInteraction, TextBasedChannel } from 'discord.js'; 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) { +import type { ModalHandler, NexoClient } from '../types/index.js'; +function evaluate(expression: string) { return evaluateMathExpression(expression); } -async function calculateModalHandler(interaction, client, args) { +async function calculateModalHandler(interaction: ModalSubmitInteraction, client: NexoClient, args: string[]): Promise { try { const operation = args[0]; - const operandInput = interaction.fields.first(); + // JS called interaction.fields.first(), which does not exist on ModalSubmitFields (always threw); the field Collection is .fields. + const operandInput = interaction.fields.fields.first(); const contextKey = operandInput?.customId?.split(':')[1]; if (!contextKey) { @@ -28,7 +31,7 @@ async function calculateModalHandler(interaction, client, args) { const operand = interaction.fields.getTextInputValue(operandInput.customId); - if (!operand || isNaN(operand)) { + if (!operand || isNaN(Number(operand))) { return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please provide a valid number.' }); } @@ -64,14 +67,14 @@ async function calculateModalHandler(interaction, client, args) { try { if (context.messageId && context.channelId) { - const channel = await client.channels.fetch(context.channelId); + const channel = await client.channels.fetch(context.channelId) as TextBasedChannel; const message = await channel.messages.fetch(context.messageId); await message.edit({ embeds: [updatedEmbed], }); } } catch (editError) { - logger.warn('Could not edit original message:', editError.message); + logger.warn('Could not edit original message:', editError instanceof Error ? editError.message : String(editError)); } calculationContexts.delete(contextKey); @@ -100,4 +103,4 @@ async function calculateModalHandler(interaction, client, args) { export default { execute: calculateModalHandler -}; \ No newline at end of file +} satisfies Pick; \ No newline at end of file diff --git a/src/handlers/countdownButtons.js b/src/handlers/countdownButtons.ts similarity index 85% rename from src/handlers/countdownButtons.js rename to src/handlers/countdownButtons.ts index d8c2b2a893..3ef4c4e734 100644 --- a/src/handlers/countdownButtons.js +++ b/src/handlers/countdownButtons.ts @@ -1,10 +1,13 @@ import { ActionRowBuilder, ButtonBuilder, ButtonStyle, PermissionFlagsBits } from 'discord.js'; +import type { ButtonInteraction, GuildMember } from 'discord.js'; +import type { CountdownData } from '../commands/Tools/countdown.js'; +import type { NexoClient } from '../types/index.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( +function createControlButtons(countdownId: string, isPaused = false): ActionRowBuilder { + return new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`countdown_pause:${countdownId}`) .setLabel(isPaused ? "▶️ Resume" : "⏸️ Pause") @@ -16,7 +19,7 @@ function createControlButtons(countdownId, isPaused = false) { ); } -function formatTime(seconds) { +function formatTime(seconds: number): string { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; @@ -30,7 +33,7 @@ function formatTime(seconds) { .join(":"); } -function startCountdown(countdownId, countdownData, activeCountdowns) { +function startCountdown(countdownId: string, countdownData: CountdownData, activeCountdowns: Map): void { if (countdownData.interval) { clearInterval(countdownData.interval); countdownData.interval = null; @@ -70,7 +73,7 @@ function startCountdown(countdownId, countdownData, activeCountdowns) { } if (remaining <= 0) { - clearInterval(countdownData.interval); + clearInterval(countdownData.interval!); const finishedEmbed = successEmbed( `⏱️ ${countdownData.title} (Finished!)`, @@ -91,15 +94,15 @@ function startCountdown(countdownId, countdownData, activeCountdowns) { }, 100); } -function cleanupCountdown(countdownId, activeCountdowns) { +function cleanupCountdown(countdownId: string, activeCountdowns: Map): void { const countdownData = activeCountdowns.get(countdownId); if (countdownData) { - clearInterval(countdownData.interval); + clearInterval(countdownData.interval!); activeCountdowns.delete(countdownId); } } -async function countdownButtonHandler(interaction, client, args) { +async function countdownButtonHandler(interaction: ButtonInteraction, client: NexoClient, args: string[]): Promise { try { const { activeCountdowns } = await import('../commands/Tools/countdown.js'); const action = args[0]; @@ -113,7 +116,7 @@ async function countdownButtonHandler(interaction, client, args) { }); } - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageMessages)) { + if (!(interaction.member as GuildMember).permissions.has(PermissionFlagsBits.ManageMessages)) { return await interaction.reply({ content: 'You need the "Manage Messages" permission to control countdowns.', flags: ["Ephemeral"], @@ -138,7 +141,7 @@ async function countdownButtonHandler(interaction, client, args) { flags: ["Ephemeral"], }); } else { - clearInterval(countdownData.interval); + clearInterval(countdownData.interval!); countdownData.isPaused = true; countdownData.remainingTime = countdownData.endTime - Date.now(); @@ -156,7 +159,7 @@ async function countdownButtonHandler(interaction, client, args) { break; case "cancel": - clearInterval(countdownData.interval); + clearInterval(countdownData.interval!); const embed = successEmbed( `⏱️ ${countdownData.title} (Cancelled)`, diff --git a/src/handlers/counterButtons.js b/src/handlers/counterButtons.ts similarity index 95% rename from src/handlers/counterButtons.js rename to src/handlers/counterButtons.ts index fedd0b9370..132b7015ae 100644 --- a/src/handlers/counterButtons.js +++ b/src/handlers/counterButtons.ts @@ -1,4 +1,5 @@ import { MessageFlags } from 'discord.js'; +import type { ButtonHandler } from '../types/index.js'; import { createEmbed, successEmbed } from '../utils/embeds.js'; import { performDeletionByCounterId } from '../commands/ServerStats/modules/serverstats_delete.js'; import { logger } from '../utils/logger.js'; @@ -50,7 +51,7 @@ export const counterDeleteActionHandler = { return; } - const { message } = await performDeletionByCounterId(client, interaction.guild, counterId); + const { message } = await performDeletionByCounterId(client, interaction.guild!, counterId); await interaction.editReply({ embeds: [successEmbed(message)], @@ -64,6 +65,6 @@ export const counterDeleteActionHandler = { }); } } -}; +} satisfies ButtonHandler; export default counterDeleteActionHandler; \ No newline at end of file diff --git a/src/handlers/giveawayButtons.js b/src/handlers/giveawayButtons.ts similarity index 94% rename from src/handlers/giveawayButtons.js rename to src/handlers/giveawayButtons.ts index e97d84aece..862b4f3f25 100644 --- a/src/handlers/giveawayButtons.js +++ b/src/handlers/giveawayButtons.ts @@ -1,4 +1,6 @@ import { MessageFlags, PermissionFlagsBits } from 'discord.js'; +import type { ButtonInteraction, GuildMember } from 'discord.js'; +import type { NexoClient } from '../types/index.js'; import { successEmbed } from '../utils/embeds.js'; import { logger } from '../utils/logger.js'; import { NexoBotError, ErrorTypes, handleInteractionError, replyUserError } from '../utils/errorHandler.js'; @@ -19,7 +21,7 @@ import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; export const giveawayJoinHandler = { customId: 'giveaway_join', - async execute(interaction, client) { + async execute(interaction: ButtonInteraction, client: NexoClient): Promise { try { if (isUserRateLimited(interaction.user.id, interaction.message.id)) { @@ -30,7 +32,7 @@ export const giveawayJoinHandler = { const lockKey = `giveaway:${interaction.message.id}`; await Mutex.runExclusive(lockKey, async () => { - const guildGiveaways = await getGuildGiveaways(client, interaction.guildId); + const guildGiveaways = await getGuildGiveaways(client, interaction.guildId!); const giveaway = guildGiveaways.find(g => g.messageId === interaction.message.id); if (!giveaway) { @@ -59,7 +61,7 @@ export const giveawayJoinHandler = { participants.push(userId); giveaway.participants = participants; - await saveGiveaway(client, interaction.guildId, giveaway); + await saveGiveaway(client, interaction.guildId!, giveaway); logger.debug(`User ${interaction.user.tag} joined giveaway ${interaction.message.id}`); @@ -94,7 +96,7 @@ export const giveawayJoinHandler = { export const giveawayEndHandler = { customId: 'giveaway_end', - async execute(interaction, client) { + async execute(interaction: ButtonInteraction, client: NexoClient): Promise { try { if (!interaction.inGuild()) { @@ -106,7 +108,7 @@ export const giveawayEndHandler = { ); } - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { + if (!(interaction.member as GuildMember).permissions.has(PermissionFlagsBits.ManageGuild)) { return replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the \'Manage Server\' permission to end a giveaway.' }); } @@ -132,7 +134,7 @@ export const giveawayEndHandler = { } const participants = giveaway.participants || []; - const winners = selectWinners(participants, giveaway.winnerCount); + const winners = selectWinners(participants, giveaway.winnerCount!); giveaway.ended = true; giveaway.isEnded = true; @@ -210,7 +212,7 @@ export const giveawayEndHandler = { export const giveawayRerollHandler = { customId: 'giveaway_reroll', - async execute(interaction, client) { + async execute(interaction: ButtonInteraction, client: NexoClient): Promise { try { if (!interaction.inGuild()) { @@ -222,7 +224,7 @@ export const giveawayRerollHandler = { ); } - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { + if (!(interaction.member as GuildMember).permissions.has(PermissionFlagsBits.ManageGuild)) { return replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the \'Manage Server\' permission to reroll a giveaway.' }); } @@ -258,7 +260,7 @@ export const giveawayRerollHandler = { ); } - const newWinners = selectWinners(participants, giveaway.winnerCount); + const newWinners = selectWinners(participants, giveaway.winnerCount!); giveaway.winnerIds = newWinners; giveaway.rerolledAt = new Date().toISOString(); @@ -332,7 +334,7 @@ export const giveawayRerollHandler = { export const giveawayViewHandler = { customId: 'giveaway_view', - async execute(interaction, client) { + async execute(interaction: ButtonInteraction, client: NexoClient): Promise { try { if (!interaction.inGuild()) { throw new NexoBotError( diff --git a/src/handlers/help/helpButtons.js b/src/handlers/help/helpButtons.ts similarity index 80% rename from src/handlers/help/helpButtons.js rename to src/handlers/help/helpButtons.ts index 9cf5cca946..e5cfce83a5 100644 --- a/src/handlers/help/helpButtons.js +++ b/src/handlers/help/helpButtons.ts @@ -3,6 +3,8 @@ import { createAllCommandsMenu } from './helpSelectMenus.js'; import { createInitialHelpMenu } from '../../commands/Core/help.js'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js'; import { logger } from '../../utils/logger.js'; +import type { TopLevelComponent } from 'discord.js'; +import type { ButtonHandler } from '../../types/index.js'; const BACK_BUTTON_ID = "help-back-to-main"; const PAGINATION_PREFIX = "help-page"; @@ -22,10 +24,11 @@ export const helpBackButton = { components, }); } catch (error) { - if (error?.code === 40060 || error?.code === 10062) { + const code = typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined; + if (code === 40060 || code === 10062) { logger.warn('Help back button interaction already acknowledged or expired.', { event: 'interaction.help.button.unavailable', - errorCode: String(error.code), + errorCode: String(code), customId: interaction.customId, interactionId: interaction.id, }); @@ -35,7 +38,7 @@ export const helpBackButton = { throw error; } }, -}; +} satisfies ButtonHandler; export const helpBugReportButton = { name: BUG_REPORT_BUTTON_ID, @@ -45,7 +48,7 @@ export const helpBugReportButton = { .setStyle(ButtonStyle.Link) .setURL('https://github.com/Robertwlt03/NexoBot/issues'); - const bugRow = new ActionRowBuilder().addComponents(githubButton); + const bugRow = new ActionRowBuilder().addComponents(githubButton); const bugReportEmbed = createEmbed({ title: '🐛 Bug Report', @@ -70,13 +73,13 @@ export const helpBugReportButton = { flags: MessageFlags.Ephemeral }); }, -}; +} satisfies ButtonHandler; -function getPaginationInfo(components) { +function getPaginationInfo(components: readonly TopLevelComponent[] | undefined): { currentPage: number; totalPages: number } { for (const row of components || []) { - for (const component of row.components || []) { - if (component.customId === `${PAGINATION_PREFIX}_page`) { - const label = component.label || ''; + for (const component of ('components' in row && row.components) || []) { + if ('customId' in component && component.customId === `${PAGINATION_PREFIX}_page`) { + const label = ('label' in component && component.label) || ''; const match = label.match(/Page\s+(\d+)\s+of\s+(\d+)/i); if (match) { return { @@ -123,10 +126,11 @@ export const helpPaginationButton = { const { embeds, components } = await createAllCommandsMenu(nextPage, client); await interaction.editReply({ embeds, components }); } catch (error) { - if (error?.code === 40060 || error?.code === 10062) { + const code = typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined; + if (code === 40060 || code === 10062) { logger.warn('Help pagination interaction already acknowledged or expired.', { event: 'interaction.help.pagination.unavailable', - errorCode: String(error.code), + errorCode: String(code), customId: interaction.customId, interactionId: interaction.id, }); @@ -136,4 +140,4 @@ export const helpPaginationButton = { throw error; } }, -}; \ No newline at end of file +} satisfies ButtonHandler; \ No newline at end of file diff --git a/src/handlers/help/helpSelectMenus.js b/src/handlers/help/helpSelectMenus.ts similarity index 80% rename from src/handlers/help/helpSelectMenus.js rename to src/handlers/help/helpSelectMenus.ts index bf4b8ab7db..384041ee90 100644 --- a/src/handlers/help/helpSelectMenus.js +++ b/src/handlers/help/helpSelectMenus.ts @@ -5,6 +5,8 @@ import fs from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; import { Collection, ActionRowBuilder, MessageFlags } from 'discord.js'; +import type { ApplicationCommand, ButtonBuilder, EmbedBuilder } from 'discord.js'; +import type { NexoClient, SelectMenuHandler } from '../../types/index.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; @@ -19,7 +21,36 @@ const FOOTER_TEXT = "Made with ❤️"; const SUBCOMMAND_TYPE = 1; const SUBCOMMAND_GROUP_TYPE = 2; -const CATEGORY_ICONS = { +interface HelpEntry { + baseName: string; + displayName: string; + description: string; + category: string; +} + +interface HelpOptionData { + type?: number; + name?: string; + description?: string; + options?: (HelpOptionData | null | undefined)[]; +} + +interface HelpCommandData { + name?: string; + description?: string; + options: (HelpOptionData | null | undefined)[]; +} + +interface HelpMenuPayload { + embeds: EmbedBuilder[]; + components: ActionRowBuilder[]; +} + +function hasToJSON(value: unknown): value is { toJSON(): unknown } { + return typeof value === 'object' && value !== null && 'toJSON' in value && typeof value.toJSON === 'function'; +} + +const CATEGORY_ICONS: Record = { Core: "ℹ️", Moderation: "🛡️", Economy: "💰", @@ -41,14 +72,14 @@ const CATEGORY_ICONS = { Config: "⚙️", }; -function formatCategoryName(rawCategory) { +function formatCategoryName(rawCategory: string): string { return rawCategory .replace(/_/g, ' ') .replace(/([a-z])([A-Z])/g, '$1 $2') .replace(/\b\w/g, (char) => char.toUpperCase()); } -function buildHelpEntries(command, category) { +function buildHelpEntries(command: unknown, category: string): HelpEntry[] { const commandData = normalizeCommandData(command); if (!commandData?.name) { return []; @@ -58,7 +89,7 @@ function buildHelpEntries(command, category) { const baseDescription = commandData.description || "No description"; const options = commandData.options || []; - const entries = []; + const entries: HelpEntry[] = []; for (const option of options) { if (!option) continue; @@ -100,13 +131,14 @@ function buildHelpEntries(command, category) { return entries; } -function normalizeCommandData(command) { - const rawData = command?.data; +function normalizeCommandData(command: unknown): HelpCommandData | null { + const rawData = typeof command === 'object' && command !== null && 'data' in command ? command.data : undefined; if (!rawData) { return null; } - const jsonData = typeof rawData.toJSON === 'function' ? rawData.toJSON() : rawData; + // Command data (builder JSON or a plain object) is loosely shaped; fields are checked below as in the JS. + const jsonData = (hasToJSON(rawData) ? rawData.toJSON() : rawData) as { name?: string; description?: string; options?: unknown } | null; if (!jsonData?.name) { return null; } @@ -114,18 +146,18 @@ function normalizeCommandData(command) { return { ...jsonData, options: Array.isArray(jsonData.options) - ? jsonData.options.map((option) => - typeof option?.toJSON === 'function' ? option.toJSON() : option, - ) + ? jsonData.options.map((option: unknown) => + hasToJSON(option) ? option.toJSON() : option, + ) as (HelpOptionData | null | undefined)[] : [], }; } -async function createCategoryCommandsMenu(category, client) { +async function createCategoryCommandsMenu(category: string, client: NexoClient): Promise { const categoryName = formatCategoryName(category); const icon = CATEGORY_ICONS[categoryName] || "🔍"; - const categoryCommands = []; + const categoryCommands: HelpEntry[] = []; try { const categoryPath = path.join(__dirname, "../../commands", category); @@ -135,7 +167,7 @@ async function createCategoryCommandsMenu(category, client) { for (const file of commandFiles) { const filePath = path.join(categoryPath, file); - const commandModule = await import(`file://${filePath}`); + const commandModule: { default?: unknown } = await import(`file://${filePath}`); const command = commandModule.default; const commandData = normalizeCommandData(command); @@ -158,7 +190,7 @@ async function createCategoryCommandsMenu(category, client) { categoryCommands.sort((a, b) => a.displayName.localeCompare(b.displayName)); - let registeredCommands = new Collection(); + let registeredCommands = new Collection(); try { if (client?.application?.commands?.fetch) { const commands = await client.application.commands.fetch(); @@ -196,7 +228,7 @@ async function createCategoryCommandsMenu(category, client) { inline: false, }); } else { - const chunks = []; + const chunks: string[] = []; let currentChunk = ""; const lines = commandMentions.split("\n"); @@ -231,7 +263,7 @@ async function createCategoryCommandsMenu(category, client) { false, ); - const buttonRow = new ActionRowBuilder().addComponents(backButton); + const buttonRow = new ActionRowBuilder().addComponents(backButton); return { embeds: [embed], @@ -239,9 +271,12 @@ async function createCategoryCommandsMenu(category, client) { }; } -export async function createAllCommandsMenu(page = 1, client) { +export async function createAllCommandsMenu( + page = 1, + client: NexoClient, +): Promise { const commandsPerPage = 45; - const allCommands = []; + const allCommands: HelpEntry[] = []; const commandsPath = path.join(__dirname, "../../commands"); const categoryDirs = ( @@ -264,7 +299,7 @@ export async function createAllCommandsMenu(page = 1, client) { for (const file of commandFiles) { const filePath = path.join(categoryPath, file); - const commandModule = await import(`file://${filePath}`); + const commandModule: { default?: unknown } = await import(`file://${filePath}`); const command = commandModule.default; const commandData = normalizeCommandData(command); @@ -290,7 +325,7 @@ export async function createAllCommandsMenu(page = 1, client) { allCommands.sort((a, b) => a.displayName.localeCompare(b.displayName)); - let registeredCommands = new Collection(); + let registeredCommands = new Collection(); try { if (client?.application?.commands?.fetch) { const commands = await client.application.commands.fetch(); @@ -342,7 +377,7 @@ export async function createAllCommandsMenu(page = 1, client) { } } - const components = []; + const components: ActionRowBuilder[] = []; if (totalPages > 1) { const paginationRow = getPaginationRow( @@ -361,7 +396,7 @@ export async function createAllCommandsMenu(page = 1, client) { false, ); - const buttonRow = new ActionRowBuilder().addComponents(backButton); + const buttonRow = new ActionRowBuilder().addComponents(backButton); components.push(buttonRow); return { @@ -396,10 +431,11 @@ export const helpCategorySelectMenu = { }); } } catch (error) { - if (error?.code === 40060 || error?.code === 10062) { + const code = typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined; + if (code === 40060 || code === 10062) { logger.warn('Help category select interaction already acknowledged or expired.', { event: 'interaction.help.select.unavailable', - errorCode: String(error.code), + errorCode: String(code), customId: interaction.customId, interactionId: interaction.id, }); @@ -413,4 +449,4 @@ export const helpCategorySelectMenu = { }); } }, -}; \ No newline at end of file +} satisfies SelectMenuHandler; \ No newline at end of file diff --git a/src/handlers/interactionHandlers/reactionRolesSelectMenu.js b/src/handlers/interactionHandlers/reactionRolesSelectMenu.ts similarity index 94% rename from src/handlers/interactionHandlers/reactionRolesSelectMenu.js rename to src/handlers/interactionHandlers/reactionRolesSelectMenu.ts index dcac75d6d3..7bab6873cf 100644 --- a/src/handlers/interactionHandlers/reactionRolesSelectMenu.js +++ b/src/handlers/interactionHandlers/reactionRolesSelectMenu.ts @@ -1,4 +1,6 @@ import { EmbedBuilder, MessageFlags } from 'discord.js'; +import type { GuildMember, StringSelectMenuInteraction } from 'discord.js'; +import type { NexoClient } from '../../types/index.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError, createError, ErrorTypes } from '../../utils/errorHandler.js'; @@ -6,7 +8,7 @@ import { getColor } from '../../config/bot.js'; import { logEvent, EVENT_TYPES } from '../../services/loggingService.js'; import { getReactionRoleMessage } from '../../services/reactionRoleService.js'; -export async function handleReactionRolesSelectMenu(interaction, client) { +export async function handleReactionRolesSelectMenu(interaction: StringSelectMenuInteraction, client: NexoClient): Promise { try { const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; @@ -35,7 +37,7 @@ export async function handleReactionRolesSelectMenu(interaction, client) { }); } - const member = interaction.member; + const member = interaction.member as GuildMember; const selectedRoleIds = interaction.values; const me = interaction.guild.members.me ?? await interaction.guild.members.fetchMe().catch(() => null); @@ -64,9 +66,9 @@ export async function handleReactionRolesSelectMenu(interaction, client) { ? reactionRoleData.roles : (typeof reactionRoleData.roles === 'object' ? Object.values(reactionRoleData.roles) : []); - const addedRoles = []; - const removedRoles = []; - const skippedRoles = []; + const addedRoles: string[] = []; + const removedRoles: string[] = []; + const skippedRoles: string[] = []; for (const roleId of selectedRoleIds) { if (!availableRoleIds.includes(roleId)) { diff --git a/src/handlers/loaders/commandLoader.js b/src/handlers/loaders/commandLoader.ts similarity index 78% rename from src/handlers/loaders/commandLoader.js rename to src/handlers/loaders/commandLoader.ts index ddc85fd7e1..9d78e93688 100644 --- a/src/handlers/loaders/commandLoader.js +++ b/src/handlers/loaders/commandLoader.ts @@ -2,26 +2,54 @@ import fs from 'fs/promises'; import { isLoadableModuleFile } from '../../utils/helpers.js'; import path from 'path'; import { fileURLToPath, pathToFileURL } from 'url'; -import { Collection } from 'discord.js'; +import { ApplicationCommandOptionType, Collection } from 'discord.js'; +import type { RESTPostAPIChatInputApplicationCommandsJSONBody } from 'discord.js'; import { logger } from '../../utils/logger.js'; import botConfig from '../../config/bot.js'; +import type { Command, NexoClient } from '../../types/index.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const MAX_COMMANDS = 100; const COMMAND_COUNT_WARN_THRESHOLD = 90; -function getSubcommandInfo(commandData) { - const subcommands = []; +type CommandJSON = RESTPostAPIChatInputApplicationCommandsJSONBody; + +interface ValidatableChoice { + name?: string; + value?: string | number; +} + +interface ValidatableOption { + name?: string; + description?: string; + choices?: ValidatableChoice[]; + options?: ValidatableOption[]; +} + +interface ValidatableCommand { + name?: string; + description?: string; + options?: ValidatableOption[]; +} + +function isCommand(value: unknown): value is Command { + return typeof value === 'object' && value !== null + && 'data' in value && Boolean(value.data) + && 'execute' in value && Boolean(value.execute); +} + +function getSubcommandInfo(commandData: CommandJSON): string[] { + const subcommands: string[] = []; if (commandData.options) { for (const option of commandData.options) { -if (option.type === 1) { +if (option.type === ApplicationCommandOptionType.Subcommand) { subcommands.push(option.name); -} else if (option.type === 2) { +} else if (option.type === ApplicationCommandOptionType.SubcommandGroup) { if (option.options) { for (const subOption of option.options) { -if (subOption.type === 1) { +if (subOption.type === ApplicationCommandOptionType.Subcommand) { subcommands.push(`${option.name}/${subOption.name}`); } } @@ -33,7 +61,7 @@ if (subOption.type === 1) { return subcommands; } -async function getAllFiles(directory, fileList = []) { +async function getAllFiles(directory: string, fileList: string[] = []): Promise { const files = await fs.readdir(directory, { withFileTypes: true }); for (const file of files) { @@ -52,14 +80,14 @@ async function getAllFiles(directory, fileList = []) { return fileList; } -export async function loadCommands(client) { +export async function loadCommands(client: NexoClient): Promise> { client.commands = new Collection(); const commandsPath = path.join(__dirname, '../../commands'); const commandFiles = await getAllFiles(commandsPath); logger.info(`Found ${commandFiles.length} command files to load`); - const uniqueCommandNames = new Set(); + const uniqueCommandNames = new Set(); for (const filePath of commandFiles) { try { @@ -69,10 +97,10 @@ export async function loadCommands(client) { const commandDir = path.dirname(filePath); const category = path.basename(commandDir); - const commandModule = await import(`file://${filePath}`); - const command = commandModule.default || commandModule; + const commandModule: { default?: unknown } = await import(`file://${filePath}`); + const command: unknown = commandModule.default || commandModule; - if (!command.data || !command.execute) { + if (!isCommand(command)) { logger.warn(`Command at ${filePath} is missing required "data" or "execute" property.`); continue; } @@ -110,7 +138,7 @@ export async function loadCommands(client) { return total + getSubcommandInfo(cmd.data.toJSON()).length; }, 0); - const uniqueCommands = new Set(); + const uniqueCommands = new Set(); for (const [name, command] of client.commands.entries()) { if (command.data && command.data.name) { uniqueCommands.add(command.data.name); @@ -121,10 +149,10 @@ export async function loadCommands(client) { return client.commands; } -function collectCommandPayloads(client) { - const commands = []; +function collectCommandPayloads(client: NexoClient): { commands: CommandJSON[]; totalSubcommands: number } { + const commands: CommandJSON[] = []; let totalSubcommands = 0; - const registeredNames = new Set(); + const registeredNames = new Set(); for (const command of client.commands.values()) { if (!command.data || typeof command.data.toJSON !== 'function') { @@ -153,8 +181,8 @@ function collectCommandPayloads(client) { return { commands, totalSubcommands }; } -function validateCommands(commands) { - const validationErrors = []; +function validateCommands(commands: ValidatableCommand[]): void { + const validationErrors: string[] = []; for (const cmd of commands) { if (cmd.name && cmd.name.length > 32) { @@ -181,7 +209,7 @@ function validateCommands(commands) { if (choice.name && choice.name.length > 110) { validationErrors.push(`Command ${cmd.name} option ${option.name} choice ${choice.name} has name longer than 110 chars: "${choice.name}" (${choice.name.length} chars)`); } - if (choice.value && choice.value.length > 100) { + if (typeof choice.value === 'string' && choice.value.length > 100) { validationErrors.push(`Command ${cmd.name} option ${option.name} choice ${choice.name} has value longer than 100 chars: "${choice.value}" (${choice.value.length} chars)`); } } @@ -207,7 +235,7 @@ function validateCommands(commands) { if (choice.name && choice.name.length > 110) { validationErrors.push(`Command ${cmd.name} subcommand ${option.name} option ${subOption.name} choice ${choice.name} has name longer than 110 chars: "${choice.name}" (${choice.name.length} chars)`); } - if (choice.value && choice.value.length > 100) { + if (typeof choice.value === 'string' && choice.value.length > 100) { validationErrors.push(`Command ${cmd.name} subcommand ${option.name} option ${subOption.name} choice ${choice.name} has value longer than 100 chars: "${choice.value}" (${choice.value.length} chars)`); } } @@ -222,7 +250,7 @@ function validateCommands(commands) { } } -function prepareCommandsForRegistration(commands) { +function prepareCommandsForRegistration(commands: CommandJSON[]): CommandJSON[] { if (commands.length >= COMMAND_COUNT_WARN_THRESHOLD) { logger.warn(`Command count (${commands.length}) is near Discord's ${MAX_COMMANDS} global command limit`); } @@ -237,7 +265,12 @@ function prepareCommandsForRegistration(commands) { return truncated; } -async function registerGlobalCommands(client, clientId, commands, totalSubcommands) { +async function registerGlobalCommands( + client: NexoClient, + clientId: string | null, + commands: CommandJSON[], + totalSubcommands: number, +): Promise { if (!clientId) { throw new Error('CLIENT_ID is required for slash command registration'); } @@ -264,7 +297,7 @@ 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'); } -export async function registerCommands(client, options = {}) { +export async function registerCommands(client: NexoClient, options: { clientId?: string | null } = {}): Promise { const { clientId = null } = options; try { @@ -276,7 +309,7 @@ export async function registerCommands(client, options = {}) { } } -export async function reloadCommand(client, commandName) { +export async function reloadCommand(client: NexoClient, commandName: string): Promise<{ success: boolean; message: string }> { const command = client.commands.get(commandName); if (!command) { @@ -284,11 +317,12 @@ export async function reloadCommand(client, commandName) { } try { - const commandPath = path.resolve(command.filePath); + const commandPath = path.resolve(command.filePath!); const moduleUrl = pathToFileURL(commandPath); moduleUrl.searchParams.set('t', Date.now().toString()); - const newCommand = (await import(moduleUrl.href)).default; + // The reloaded file is the same command module that was validated on initial load. + const { default: newCommand }: { default: Command } = await import(moduleUrl.href); client.commands.set(commandName, newCommand); @@ -296,6 +330,6 @@ export async function reloadCommand(client, commandName) { return { success: true, message: `Successfully reloaded command "${commandName}"` }; } catch (error) { logger.error(`Error reloading command "${commandName}":`, error); - return { success: false, message: `Error reloading command: ${error.message}` }; + return { success: false, message: `Error reloading command: ${error instanceof Error ? error.message : String(error)}` }; } } \ No newline at end of file diff --git a/src/handlers/loaders/events.js b/src/handlers/loaders/events.ts similarity index 60% rename from src/handlers/loaders/events.js rename to src/handlers/loaders/events.ts index 8fd16ad7b1..aac6dbdb44 100644 --- a/src/handlers/loaders/events.js +++ b/src/handlers/loaders/events.ts @@ -4,11 +4,18 @@ import { join } from 'path'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; import { logger } from '../../utils/logger.js'; +import type { BotEvent, NexoClient } from '../../types/index.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -export default async function loadEvents(client) { +function isBotEvent(value: unknown): value is BotEvent { + return typeof value === 'object' && value !== null + && 'name' in value && Boolean(value.name) + && 'execute' in value && typeof value.execute === 'function'; +} + +export default async function loadEvents(client: NexoClient): Promise { const eventsPath = join(__dirname, '../../events'); const eventFiles = await readdir(eventsPath).then(files => files.filter(isLoadableModuleFile)); @@ -17,16 +24,19 @@ export default async function loadEvents(client) { for (const file of eventFiles) { const filePath = join(eventsPath, file); try { - const { default: event } = await import(`file://${filePath}`); + const { default: event }: { default?: unknown } = await import(`file://${filePath}`); - if (!event?.name || typeof event.execute !== 'function') { + if (!isBotEvent(event)) { logger.warn(`Event ${file} is missing required "name" or "execute" properties.`); continue; } - const safeExecute = async (...args) => { + // Events are registered generically: discord.js passes the args of ClientEvents[event.name], + // which is exactly what this event's execute expects, but TS cannot correlate the two here. + const execute = event.execute as (...args: unknown[]) => unknown; + const safeExecute = async (...args: unknown[]) => { try { - await event.execute(...args, client); + await execute(...args, client); } catch (error) { logger.error(`Error executing event ${event.name}:`, error); } diff --git a/src/handlers/loaders/interactions.js b/src/handlers/loaders/interactions.ts similarity index 64% rename from src/handlers/loaders/interactions.js rename to src/handlers/loaders/interactions.ts index 43c6d274fd..b74a09f379 100644 --- a/src/handlers/loaders/interactions.js +++ b/src/handlers/loaders/interactions.ts @@ -4,13 +4,23 @@ import { join } from 'path'; import { fileURLToPath, pathToFileURL } from 'url'; import { dirname } from 'path'; import { logger } from '../../utils/logger.js'; +import type { Collection } from 'discord.js'; +import type { ButtonHandler, ModalHandler, NexoClient, SelectMenuHandler } from '../../types/index.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -const interactionTypes = ['buttons', 'selectMenus', 'modals']; +const interactionTypes = ['buttons', 'selectMenus', 'modals'] as const; -async function getAllInteractionFiles(directory, fileList = []) { +type InteractionHandler = ButtonHandler | SelectMenuHandler | ModalHandler; + +function isInteractionHandler(value: unknown): value is InteractionHandler { + return typeof value === 'object' && value !== null + && 'name' in value && Boolean(value.name) + && 'execute' in value && Boolean(value.execute); +} + +async function getAllInteractionFiles(directory: string, fileList: string[] = []): Promise { const entries = await readdir(directory, { withFileTypes: true }); for (const entry of entries) { @@ -26,7 +36,7 @@ async function getAllInteractionFiles(directory, fileList = []) { return fileList; } -export default async (client) => { +export default async (client: NexoClient): Promise => { try { const interactionsPath = join(__dirname, '../../interactions'); @@ -42,17 +52,18 @@ export default async (client) => { const fileName = relativePath.split('/').pop(); try { - const module = await import(pathToFileURL(filePath).href); + const module: { default?: unknown } = await import(pathToFileURL(filePath).href); const moduleExport = module.default; - const interactions = Array.isArray(moduleExport) ? moduleExport : [moduleExport]; + const interactions: unknown[] = Array.isArray(moduleExport) ? moduleExport : [moduleExport]; for (const interaction of interactions) { - if (!interaction?.name || !interaction?.execute) { + if (!isInteractionHandler(interaction)) { logger.warn(`Interaction ${relativePath} in ${type} is missing required properties.`); continue; } - client[type].set(interaction.name, interaction); + const registry: Collection = client[type]; + registry.set(interaction.name, interaction); loadedCount += 1; logger.info(`Loaded ${type.slice(0, -1)}: ${interaction.name} (${fileName})`); } @@ -63,7 +74,7 @@ export default async (client) => { logger.info(`Loaded ${loadedCount} ${type}`); } catch (error) { - if (error.code !== 'ENOENT') { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { logger.error(`Error loading ${type}:`, error); } else { logger.debug(`No ${type} directory found, skipping...`); diff --git a/src/handlers/loggingButtons.js b/src/handlers/loggingButtons.ts similarity index 80% rename from src/handlers/loggingButtons.js rename to src/handlers/loggingButtons.ts index 0f5144c205..6d79620d29 100644 --- a/src/handlers/loggingButtons.js +++ b/src/handlers/loggingButtons.ts @@ -9,6 +9,13 @@ import { StringSelectMenuOptionBuilder, UserSelectMenuBuilder, } from 'discord.js'; +import type { + ButtonInteraction, + GuildMember, + ModalSubmitInteraction, + SelectMenuModalData, + StringSelectMenuInteraction, +} from 'discord.js'; import { toggleEventLogging, getLoggingStatus, @@ -33,7 +40,7 @@ import { const LOGGING_CATEGORIES = [...new Set(Object.values(EVENT_TYPES).map((eventType) => eventType.split('.')[0]))]; -const DESTINATION_LABELS = { +const DESTINATION_LABELS: Record = { audit: 'Audit Log', applications: 'Applications', reports: 'Reports', @@ -48,9 +55,9 @@ export default { 'log_dash_remove_filter', ], - async execute(interaction) { + async execute(interaction: ButtonInteraction): Promise { try { - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { + if (!(interaction.member as GuildMember).permissions.has(PermissionFlagsBits.ManageGuild)) { return interaction.reply({ content: '❌ You need **Manage Server** permissions to use this.', ephemeral: true, @@ -86,7 +93,7 @@ export default { }, }; -async function handleRefresh(interaction) { +async function handleRefresh(interaction: ButtonInteraction) { if (isCategoriesView(interaction)) { const { embed, components } = await buildLoggingCategoriesView(interaction, interaction.client); return interaction.update({ embeds: [embed], components, content: null }); @@ -101,30 +108,30 @@ async function handleRefresh(interaction) { await interaction.update({ embeds: [embed], components, content: null }); } -async function handleBackToMain(interaction) { +async function handleBackToMain(interaction: ButtonInteraction) { const { embed, components } = await buildLoggingDashboardView(interaction, interaction.client); await interaction.update({ embeds: [embed], components, content: null }); } -async function handleToggle(interaction) { +async function handleToggle(interaction: ButtonInteraction) { const eventType = interaction.customId.replace('log_dash_toggle:', ''); if (!eventType) { return interaction.reply({ content: '❌ Invalid event type.', ephemeral: true }); } - const status = await getLoggingStatus(interaction.client, interaction.guildId); + const status = await getLoggingStatus(interaction.client, interaction.guildId!); const onCategoriesView = isCategoriesView(interaction); if (eventType === 'audit_enabled') { - await setLoggingEnabled(interaction.client, interaction.guildId, !Boolean(status.enabled)); + await setLoggingEnabled(interaction.client, interaction.guildId!, !Boolean(status.enabled)); } else if (eventType === 'all') { const newState = !Object.values(status.enabledEvents).every((v) => v !== false); const allTypes = Object.values(EVENT_TYPES); const categoryTypes = LOGGING_CATEGORIES.map((c) => `${c}.*`); - await toggleEventLogging(interaction.client, interaction.guildId, [...allTypes, ...categoryTypes], newState); + await toggleEventLogging(interaction.client, interaction.guildId!, [...allTypes, ...categoryTypes], newState); } else { const currentState = status.enabledEvents[eventType] !== false; - await toggleEventLogging(interaction.client, interaction.guildId, eventType, !currentState); + await toggleEventLogging(interaction.client, interaction.guildId!, eventType, !currentState); } if (onCategoriesView || (eventType !== 'audit_enabled' && eventType.includes('.*'))) { @@ -136,7 +143,7 @@ async function handleToggle(interaction) { await interaction.update({ embeds: [embed], components, content: null }); } -async function handleAddFilterModal(interaction) { +async function handleAddFilterModal(interaction: ButtonInteraction) { const filterType = interaction.customId.replace('log_dash_add_filter:', ''); if (filterType !== 'user' && filterType !== 'channel') { return interaction.reply({ content: '❌ Invalid filter type.', ephemeral: true }); @@ -144,7 +151,7 @@ async function handleAddFilterModal(interaction) { const modalCustomId = `log_dash_filter_modal:add:${filterType}`; - let modal; + let modal: ModalBuilder; if (filterType === 'user') { const userSelect = new UserSelectMenuBuilder() .setCustomId('ignore_user') @@ -185,14 +192,14 @@ async function handleAddFilterModal(interaction) { try { const modalSubmission = await interaction.awaitModalSubmit({ time: 5 * 60 * 1000, - filter: (i) => i.user.id === interaction.user.id && i.customId === modalCustomId, + filter: (i: ModalSubmitInteraction) => i.user.id === interaction.user.id && i.customId === modalCustomId, }); let id; if (filterType === 'user') { - id = modalSubmission.fields.getField('ignore_user')?.values?.[0]; + id = (modalSubmission.fields.getField('ignore_user') as SelectMenuModalData)?.values?.[0]; } else { - id = modalSubmission.fields.getField('ignore_channel')?.values?.[0]; + id = (modalSubmission.fields.getField('ignore_channel') as SelectMenuModalData)?.values?.[0]; } if (!id) { @@ -202,7 +209,7 @@ async function handleAddFilterModal(interaction) { }); } - await updateIgnoreList(interaction.client, interaction.guildId, { action: 'add', type: filterType, id }); + await updateIgnoreList(interaction.client, interaction.guildId!, { action: 'add', type: filterType, id }); await modalSubmission.reply({ embeds: [successEmbed('Filter Added', `${filterType === 'user' ? 'User' : 'Channel'} \`${id}\` will be ignored in audit logs.`)], @@ -213,17 +220,17 @@ async function handleAddFilterModal(interaction) { await refreshDashboardMessage(interaction, interaction.client); } } catch (error) { - if (error.code === 'INTERACTION_TIMEOUT') { + if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'INTERACTION_TIMEOUT') { return; } logger.error('Error in add filter modal:', error); } } -async function handleRemoveFilterModal(interaction) { - const config = await getGuildConfig(interaction.client, interaction.guildId); +async function handleRemoveFilterModal(interaction: ButtonInteraction) { + const config = await getGuildConfig(interaction.client, interaction.guildId!); const ignore = getIgnoreList(config); - const options = []; + const options: StringSelectMenuOptionBuilder[] = []; for (const userId of ignore.users || []) { options.push( @@ -274,10 +281,10 @@ async function handleRemoveFilterModal(interaction) { try { const modalSubmission = await interaction.awaitModalSubmit({ time: 5 * 60 * 1000, - filter: (i) => i.user.id === interaction.user.id && i.customId === modalCustomId, + filter: (i: ModalSubmitInteraction) => i.user.id === interaction.user.id && i.customId === modalCustomId, }); - const entry = modalSubmission.fields.getField('filter_entry')?.values?.[0]; + const entry = (modalSubmission.fields.getField('filter_entry') as SelectMenuModalData)?.values?.[0]; if (!entry) { return replyUserError(modalSubmission, { type: ErrorTypes.VALIDATION, @@ -286,7 +293,7 @@ async function handleRemoveFilterModal(interaction) { } const [type, id] = entry.split(':'); - await updateIgnoreList(interaction.client, interaction.guildId, { action: 'remove', type, id }); + await updateIgnoreList(interaction.client, interaction.guildId!, { action: 'remove', type, id }); await modalSubmission.reply({ embeds: [successEmbed('Filter Removed', `Removed ${type} \`${id}\` from the ignore list.`)], @@ -297,14 +304,14 @@ async function handleRemoveFilterModal(interaction) { await refreshDashboardMessage(interaction, interaction.client); } } catch (error) { - if (error.code === 'INTERACTION_TIMEOUT') { + if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'INTERACTION_TIMEOUT') { return; } logger.error('Error in remove filter modal:', error); } } -async function showChannelModal(interaction, destination) { +async function showChannelModal(interaction: StringSelectMenuInteraction, destination: string) { const label = DESTINATION_LABELS[destination] || destination; const modalCustomId = `log_dash_channel_modal:${destination}`; @@ -331,12 +338,12 @@ async function showChannelModal(interaction, destination) { try { const modalSubmission = await interaction.awaitModalSubmit({ time: 5 * 60 * 1000, - filter: (i) => i.user.id === interaction.user.id && i.customId === modalCustomId, + filter: (i: ModalSubmitInteraction) => i.user.id === interaction.user.id && i.customId === modalCustomId, }); - const channelId = modalSubmission.fields.getField('log_channel').values[0]; - const channel = interaction.guild.channels.cache.get(channelId) - ?? await interaction.guild.channels.fetch(channelId).catch(() => null); + const channelId = (modalSubmission.fields.getField('log_channel') as SelectMenuModalData).values[0]; + const channel = interaction.guild!.channels.cache.get(channelId) + ?? await interaction.guild!.channels.fetch(channelId).catch(() => null); if (!channel) { return modalSubmission.reply({ @@ -345,7 +352,7 @@ async function showChannelModal(interaction, destination) { }); } - const botPerms = channel.permissionsFor(interaction.guild.members.me); + const botPerms = channel.permissionsFor(interaction.guild!.members.me!); if (!botPerms?.has(['ViewChannel', 'SendMessages', 'EmbedLinks'])) { return modalSubmission.reply({ content: '❌ I need View Channel, Send Messages, and Embed Links in that channel.', @@ -353,7 +360,7 @@ async function showChannelModal(interaction, destination) { }); } - await setLogChannel(interaction.client, interaction.guildId, destination, channel.id); + await setLogChannel(interaction.client, interaction.guildId!, destination, channel.id); await modalSubmission.reply({ embeds: [successEmbed('Channel Updated', `**${label}** logs will be sent to ${channel}.`)], @@ -362,7 +369,7 @@ async function showChannelModal(interaction, destination) { await refreshDashboardMessage(interaction, interaction.client); } catch (error) { - if (error.code === 'INTERACTION_TIMEOUT') { + if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'INTERACTION_TIMEOUT') { return; } await handleInteractionError(interaction, error, { @@ -373,8 +380,8 @@ async function showChannelModal(interaction, destination) { } } -export async function handleLoggingMenuSelect(interaction) { - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { +export async function handleLoggingMenuSelect(interaction: StringSelectMenuInteraction): Promise { + if (!(interaction.member as GuildMember).permissions.has(PermissionFlagsBits.ManageGuild)) { return interaction.reply({ content: '❌ You need **Manage Server** permissions to use this.', ephemeral: true, @@ -390,7 +397,7 @@ export async function handleLoggingMenuSelect(interaction) { if (value.startsWith('clear:')) { const destination = value.replace('clear:', ''); - await setLogChannel(interaction.client, interaction.guildId, destination, null); + await setLogChannel(interaction.client, interaction.guildId!, destination, null); const { embed, components } = await buildLoggingDashboardView(interaction, interaction.client); return interaction.update({ embeds: [embed], diff --git a/src/handlers/musicButtons.js b/src/handlers/musicButtons.ts similarity index 85% rename from src/handlers/musicButtons.js rename to src/handlers/musicButtons.ts index 13b8acd9c6..f23908828a 100644 --- a/src/handlers/musicButtons.js +++ b/src/handlers/musicButtons.ts @@ -1,4 +1,6 @@ import { MessageFlags } from 'discord.js'; +import type { ButtonInteraction, GuildMember } from 'discord.js'; +import type { NexoClient } from '../types/index.js'; import { logger } from '../utils/logger.js'; import { handleInteractionError } from '../utils/errorHandler.js'; import { getGuildMusicData } from '../services/music/playerStore.js'; @@ -15,20 +17,20 @@ import { refreshPlayerMessage } from '../services/music/playerHandler.js'; import { MUSIC_BUTTON_IDS } from '../services/music/musicEmbeds.js'; import { replyUserError, ErrorTypes } from '../utils/errorHandler.js'; -async function handleMusicButton(interaction, client) { - const player = getPlayer(client, interaction.guild.id); - const guildData = getGuildMusicData(interaction.guild.id); +async function handleMusicButton(interaction: ButtonInteraction, client: NexoClient): Promise { + const player = getPlayer(client, interaction.guild!.id); + const guildData = getGuildMusicData(interaction.guild!.id); const customId = interaction.customId; if (customId === MUSIC_BUTTON_IDS.QUEUE) { if (!player?.current) { return replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Nothing is playing right now.' }); } - if (!canControlMusic(interaction.member, player)) { + if (!canControlMusic(interaction.member as GuildMember, player)) { return replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: VOICE_CHANNEL_DENIAL }); } guildData.queuePages.set(interaction.user.id, 0); - const payload = buildQueueReply(client, interaction.guild.id, 0); + const payload = buildQueueReply(client, interaction.guild!.id, 0); return interaction.reply({ embeds: payload.embeds, components: payload.components, @@ -47,12 +49,12 @@ async function handleMusicButton(interaction, client) { if (!player?.current) { return replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Nothing is playing right now.' }); } - if (!canControlMusic(interaction.member, player)) { + if (!canControlMusic(interaction.member as GuildMember, player)) { return replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: VOICE_CHANNEL_DENIAL }); } await interaction.deferUpdate(); - const payload = buildQueueReply(client, interaction.guild.id, guildData.queuePages.get(interaction.user.id) || 0); + const payload = buildQueueReply(client, interaction.guild!.id, guildData.queuePages.get(interaction.user.id) || 0); const totalPages = payload.totalPages; let page = payload.page; @@ -74,7 +76,7 @@ async function handleMusicButton(interaction, client) { } guildData.queuePages.set(interaction.user.id, page); - const updated = buildQueueReply(client, interaction.guild.id, page); + const updated = buildQueueReply(client, interaction.guild!.id, page); return interaction.editReply({ embeds: updated.embeds, components: updated.components, @@ -85,7 +87,7 @@ async function handleMusicButton(interaction, client) { return replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'No music is playing. Use `/play` first.' }); } - if (!canControlMusic(interaction.member, player)) { + if (!canControlMusic(interaction.member as GuildMember, player)) { return replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: VOICE_CHANNEL_DENIAL }); } @@ -94,10 +96,10 @@ async function handleMusicButton(interaction, client) { try { switch (customId) { case MUSIC_BUTTON_IDS.PAUSE: - await applyPause(client, interaction.guild.id); + await applyPause(client, interaction.guild!.id); break; case MUSIC_BUTTON_IDS.RESUME: - await applyResume(client, interaction.guild.id); + 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 @@ -108,17 +110,17 @@ async function handleMusicButton(interaction, client) { player.stop(); break; case MUSIC_BUTTON_IDS.STOP: - await destroyPlayerSession(client, interaction.guild.id, player, guildData); + await destroyPlayerSession(client, interaction.guild!.id, player, guildData); break; case MUSIC_BUTTON_IDS.SHUFFLE: if (player.queue.length > 0) { player.queue.shuffle(); guildData.shuffle = true; - await refreshPlayerMessage(client, interaction.guild.id); + await refreshPlayerMessage(client, interaction.guild!.id); } break; case MUSIC_BUTTON_IDS.LOOP: { - const guildDataLoop = getGuildMusicData(interaction.guild.id); + const guildDataLoop = getGuildMusicData(interaction.guild!.id); const next = guildDataLoop.loop === 'none' ? 'track' : guildDataLoop.loop === 'track' ? 'queue' : 'none'; await setLoopMode(client, interaction, next); break; @@ -126,12 +128,12 @@ async function handleMusicButton(interaction, client) { case MUSIC_BUTTON_IDS.VOL_DOWN: guildData.volume = Math.max(0, guildData.volume - 10); player.setVolume(guildData.volume); - await refreshPlayerMessage(client, interaction.guild.id); + await refreshPlayerMessage(client, interaction.guild!.id); break; case MUSIC_BUTTON_IDS.VOL_UP: guildData.volume = Math.min(100, guildData.volume + 10); player.setVolume(guildData.volume); - await refreshPlayerMessage(client, interaction.guild.id); + await refreshPlayerMessage(client, interaction.guild!.id); break; default: break; @@ -146,7 +148,7 @@ async function handleMusicButton(interaction, client) { } export const musicButtonHandler = { - async execute(interaction, client) { + async execute(interaction: ButtonInteraction, client: NexoClient): Promise { try { if (!client.riffy) { return replyUserError(interaction, { type: ErrorTypes.CONFIGURATION, message: 'Music is unavailable — Lavalink is not configured.' }); diff --git a/src/handlers/ticketButtons.js b/src/handlers/ticketButtons.ts similarity index 85% rename from src/handlers/ticketButtons.js rename to src/handlers/ticketButtons.ts index 76909a6d1c..d3f9ffc091 100644 --- a/src/handlers/ticketButtons.js +++ b/src/handlers/ticketButtons.ts @@ -8,8 +8,18 @@ import { InteractionHelper } from '../utils/interactionHelper.js'; import { checkRateLimit } from '../utils/rateLimiter.js'; import { replyUserError, ErrorTypes, handleInteractionError, createError } from '../utils/errorHandler.js'; import { getTicketPermissionContext } from '../utils/ticket/ticketPermissions.js'; - -function escapeHtml(text) { +import type { + ButtonInteraction, + GuildMember, + ModalSubmitInteraction, + TextChannel, +} from 'discord.js'; +import type { ButtonHandler, ModalHandler, NexoClient } from '../types/index.js'; + +type TicketInteraction = ButtonInteraction | ModalSubmitInteraction; +type TicketPermissionContext = Awaited>; + +function escapeHtml(text: unknown): string { if (!text) return ''; return String(text) .replace(/&/g, '&') @@ -19,7 +29,7 @@ function escapeHtml(text) { .replace(/'/g, '''); } -async function ensureGuildContext(interaction) { +async function ensureGuildContext(interaction: TicketInteraction): Promise { if (interaction.inGuild()) { return true; } @@ -31,18 +41,25 @@ async function ensureGuildContext(interaction) { return false; } -async function assertTicketPermission(interaction, client, actionLabel, options = {}, timeoutMs = 2500) { +async function assertTicketPermission( + interaction: TicketInteraction, + client: NexoClient, + actionLabel: string, + options: { allowTicketCreator?: boolean } = {}, + timeoutMs = 2500, +): Promise { const { allowTicketCreator = false } = options; - let context; + let context: TicketPermissionContext; try { const contextPromise = getTicketPermissionContext({ client, interaction }); - const timeoutPromise = new Promise((_, reject) => + const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), timeoutMs) ); context = await Promise.race([contextPromise, timeoutPromise]); } catch (error) { - if (error.message === 'Timeout') { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage === 'Timeout') { throw createError( 'Ticket permission timeout', ErrorTypes.RATE_LIMIT, @@ -52,7 +69,7 @@ async function assertTicketPermission(interaction, client, actionLabel, options throw createError( 'Ticket permission check failed', ErrorTypes.UNKNOWN, - `Failed to check permissions: ${error.message}` + `Failed to check permissions: ${errorMessage}` ); } @@ -79,9 +96,15 @@ async function assertTicketPermission(interaction, client, actionLabel, options return context; } -async function ensureTicketPermission(interaction, client, actionLabel, options = {}) { +async function ensureTicketPermission( + interaction: TicketInteraction, + client: NexoClient, + actionLabel: string, + options: { allowTicketCreator?: boolean } = {}, +): Promise { const { allowTicketCreator = false } = options; + // See assertTicketPermission: only guildId, channelId, member and user are read. const context = await getTicketPermissionContext({ client, interaction }); if (!context.ticketData) { @@ -115,11 +138,11 @@ const createTicketHandler = { return; } - const config = await getGuildConfig(client, interaction.guildId); + const config = await getGuildConfig(client, interaction.guildId!); const maxTicketsPerUser = config.maxTicketsPerUser || 3; const { getUserTicketCount } = await import('../services/ticket.js'); - const currentTicketCount = await getUserTicketCount(interaction.guildId, interaction.user.id); + const currentTicketCount = await getUserTicketCount(interaction.guildId!, interaction.user.id); if (currentTicketCount >= maxTicketsPerUser) { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `You have reached the maximum number of open tickets (${maxTicketsPerUser}).\n\nPlease close your existing tickets before creating a new one.\n\n**Current Tickets:** ${currentTicketCount}/${maxTicketsPerUser}` }); @@ -137,7 +160,7 @@ const createTicketHandler = { .setRequired(true) .setMaxLength(1000); - const actionRow = new ActionRowBuilder().addComponents(reasonInput); + const actionRow = new ActionRowBuilder().addComponents(reasonInput); modal.addComponents(actionRow); await interaction.showModal(modal); @@ -148,7 +171,7 @@ const createTicketHandler = { } } } -}; +} satisfies ButtonHandler; const createTicketModalHandler = { name: 'create_ticket_modal', @@ -160,12 +183,12 @@ const createTicketModalHandler = { if (!deferSuccess) return; const reason = interaction.fields.getTextInputValue('reason'); - const config = await getGuildConfig(client, interaction.guildId); + const config = await getGuildConfig(client, interaction.guildId!); const categoryId = config.ticketCategoryId || null; const { channel } = await createTicket( - interaction.guild, - interaction.member, + interaction.guild!, + interaction.member as GuildMember, categoryId, reason ); @@ -179,7 +202,7 @@ const createTicketModalHandler = { await handleInteractionError(interaction, error, { type: 'button', handler: 'ticket', customId: interaction.customId }); } } -}; +} satisfies ModalHandler; const closeTicketHandler = { name: 'ticket_close', @@ -201,7 +224,7 @@ const closeTicketHandler = { .setRequired(false) .setMaxLength(1000); - const actionRow = new ActionRowBuilder().addComponents(reasonInput); + const actionRow = new ActionRowBuilder().addComponents(reasonInput); modal.addComponents(actionRow); await interaction.showModal(modal); @@ -213,7 +236,7 @@ const closeTicketHandler = { } } } -}; +} satisfies ButtonHandler; const closeTicketModalHandler = { name: 'ticket_close_modal', @@ -229,7 +252,7 @@ const closeTicketModalHandler = { const providedReason = interaction.fields.getTextInputValue('reason')?.trim(); const reason = providedReason || 'Closed via ticket button without a specific reason.'; - await closeTicket(interaction.channel, interaction.user, reason); + await closeTicket(interaction.channel as TextChannel, 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); @@ -240,7 +263,7 @@ const closeTicketModalHandler = { } } } -}; +} satisfies ModalHandler; const claimTicketHandler = { name: 'ticket_claim', @@ -253,7 +276,7 @@ const claimTicketHandler = { const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; - await claimTicket(interaction.channel, interaction.user); + await claimTicket(interaction.channel as TextChannel, interaction.user); await interaction.editReply({ embeds: [successEmbed('Ticket Claimed', 'You have claimed this ticket.')] }); } catch (error) { logger.error('Error claiming ticket:', error); @@ -264,7 +287,7 @@ const claimTicketHandler = { } } } -}; +} satisfies ButtonHandler; const priorityTicketHandler = { name: 'ticket_priority', @@ -283,7 +306,7 @@ const priorityTicketHandler = { return; } - await updateTicketPriority(interaction.channel, priority, interaction.user); + await updateTicketPriority(interaction.channel as TextChannel, 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); @@ -294,7 +317,7 @@ const priorityTicketHandler = { } } } -}; +} satisfies ButtonHandler; const pinTicketHandler = { name: 'ticket_pin', @@ -307,7 +330,7 @@ const pinTicketHandler = { const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; - const channel = interaction.channel; + const channel = interaction.channel as TextChannel; const category = channel.parent; if (!category) { @@ -331,11 +354,11 @@ const pinTicketHandler = { description: 'This ticket has been unpinned and moved back to normal position.', color: 0x95A5A6 })], - flags: MessageFlags.Ephemeral + // JS also passed flags: MessageFlags.Ephemeral here; editReply cannot set it (Discord ignores it on edits). }); logger.info('Ticket unpinned', { - guildId: interaction.guildId, + guildId: interaction.guildId!, channelId: channel.id, channelName: newName, userId: interaction.user.id @@ -354,11 +377,11 @@ const pinTicketHandler = { description: 'This ticket has been pinned to the top of the category.', color: 0x3498db })], - flags: MessageFlags.Ephemeral + // JS also passed flags: MessageFlags.Ephemeral here; editReply cannot set it (Discord ignores it on edits). }); logger.info('Ticket pinned', { - guildId: interaction.guildId, + guildId: interaction.guildId!, channelId: channel.id, channelName: pinnedName, userId: interaction.user.id @@ -367,7 +390,7 @@ const pinTicketHandler = { await logTicketEvent({ client: interaction.client, - guildId: interaction.guildId, + guildId: interaction.guildId!, event: { type: hasPingEmoji ? 'unpin' : 'pin', ticketId: channel.id, @@ -390,7 +413,7 @@ const pinTicketHandler = { } } } -}; +} satisfies ButtonHandler; const unclaimTicketHandler = { name: 'ticket_unclaim', @@ -404,7 +427,7 @@ const unclaimTicketHandler = { if (!deferSuccess) return; const { unclaimTicket } = await import('../services/ticket.js'); - await unclaimTicket(interaction.channel, interaction.member); + await unclaimTicket(interaction.channel as TextChannel, interaction.member as GuildMember); await interaction.editReply({ embeds: [successEmbed('Ticket Unclaimed', 'This ticket has been unclaimed.')] }); } catch (error) { logger.error('Error unclaiming ticket:', error); @@ -415,7 +438,7 @@ const unclaimTicketHandler = { } } } -}; +} satisfies ButtonHandler; const reopenTicketHandler = { name: 'ticket_reopen', @@ -429,7 +452,7 @@ const reopenTicketHandler = { if (!deferSuccess) return; const { reopenTicket } = await import('../services/ticket.js'); - const { movedToOpenCategory, openCategoryMoveFailed } = await reopenTicket(interaction.channel, interaction.member); + const { movedToOpenCategory, openCategoryMoveFailed } = await reopenTicket(interaction.channel as TextChannel, interaction.member as GuildMember); let reopenMessage = 'This ticket has been reopened.'; if (openCategoryMoveFailed) { reopenMessage += ' Note: Could not move the channel back to the open tickets category.'; @@ -444,7 +467,7 @@ const reopenTicketHandler = { } } } -}; +} satisfies ButtonHandler; const deleteTicketHandler = { name: 'ticket_delete', @@ -458,7 +481,7 @@ const deleteTicketHandler = { if (!deferSuccess) return; const { deleteTicket } = await import('../services/ticket.js'); - await deleteTicket(interaction.channel, interaction.member); + await deleteTicket(interaction.channel as TextChannel, interaction.member as GuildMember); await interaction.editReply({ embeds: [successEmbed('Ticket Deleted', 'This ticket will be deleted shortly.')] }); } catch (error) { logger.error('Error deleting ticket:', error); @@ -469,7 +492,7 @@ const deleteTicketHandler = { } } } -}; +} satisfies ButtonHandler; export default createTicketHandler; export { diff --git a/src/handlers/todoButtons.js b/src/handlers/todoButtons.ts similarity index 88% rename from src/handlers/todoButtons.js rename to src/handlers/todoButtons.ts index 3b86997a11..8ee43f67ee 100644 --- a/src/handlers/todoButtons.js +++ b/src/handlers/todoButtons.ts @@ -5,7 +5,20 @@ import { checkRateLimit } from '../utils/rateLimiter.js'; import { logger } from '../utils/logger.js'; import { replyUserError, ErrorTypes } from '../utils/errorHandler.js'; -function buildSharedTodoViewPayload(listData, listId, guild) { +import type { Guild, ModalSubmitInteraction } from 'discord.js'; +import type { SharedTodoList, SharedTodoTask } from '../commands/Utility/todo.js'; +import type { ButtonHandler, ModalHandler } from '../types/index.js'; + +/** Shared task as written by the complete modal (adds completedAt). */ +interface StoredSharedTodoTask extends SharedTodoTask { + completedAt?: string; +} + +interface StoredSharedTodoList extends SharedTodoList { + tasks: StoredSharedTodoTask[]; +} + +function buildSharedTodoViewPayload(listData: StoredSharedTodoList, listId: string, guild: Guild | null) { const memberList = (listData.members || []).map(memberId => { const member = guild?.members?.cache?.get(memberId); return member ? member.user.username : `<@${memberId}>`; @@ -28,7 +41,7 @@ function buildSharedTodoViewPayload(listData, listId, guild) { ) ], components: [ - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`shared_todo_add_${listId}`) .setLabel('Add Task') @@ -65,7 +78,7 @@ function buildSharedTodoViewPayload(listData, listId, guild) { ) ], components: [ - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`shared_todo_add_${listId}`) .setLabel('Add Task') @@ -83,13 +96,13 @@ function buildSharedTodoViewPayload(listData, listId, guild) { }; } -async function refreshSharedTodoMessage(interaction, listId, messageId) { +async function refreshSharedTodoMessage(interaction: ModalSubmitInteraction, listId: string, messageId: string | null): Promise { if (!messageId || !interaction.channel) { return; } const listKey = `shared_todo_${listId}`; - const listData = await getFromDb(listKey, null); + const listData = await getFromDb(listKey, null); if (!listData) { return; } @@ -108,7 +121,7 @@ async function refreshSharedTodoMessage(interaction, listId, messageId) { messageId, guildId: interaction.guildId, channelId: interaction.channelId, - error: error.message + error: error instanceof Error ? error.message : String(error) }); } } @@ -135,12 +148,12 @@ const sharedTodoAddHandler = { .setRequired(true) .setMaxLength(200); - const actionRow = new ActionRowBuilder().addComponents(taskInput); + const actionRow = new ActionRowBuilder().addComponents(taskInput); modal.addComponents(actionRow); await interaction.showModal(modal); } -}; +} satisfies ButtonHandler; const sharedTodoCompleteHandler = { name: 'shared_todo_complete', @@ -164,12 +177,12 @@ const sharedTodoCompleteHandler = { .setRequired(true) .setPlaceholder('e.g., 1, 2, 3'); - const actionRow = new ActionRowBuilder().addComponents(taskIdInput); + const actionRow = new ActionRowBuilder().addComponents(taskIdInput); modal.addComponents(actionRow); await interaction.showModal(modal); } -}; +} satisfies ButtonHandler; const sharedTodoRemoveHandler = { name: 'shared_todo_remove', @@ -193,12 +206,12 @@ const sharedTodoRemoveHandler = { .setRequired(true) .setPlaceholder('e.g., 1, 2, 3'); - const actionRow = new ActionRowBuilder().addComponents(taskIdInput); + const actionRow = new ActionRowBuilder().addComponents(taskIdInput); modal.addComponents(actionRow); await interaction.showModal(modal); } -}; +} satisfies ButtonHandler; const sharedTodoAddModalHandler = { name: 'shared_todo_add_modal', @@ -223,7 +236,7 @@ const sharedTodoAddModalHandler = { } const listKey = `shared_todo_${listId}`; - let listData = await getFromDb(listKey, null); + let listData = await getFromDb(listKey, null); if (!listData) { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Shared list not found.' }); @@ -259,7 +272,7 @@ const sharedTodoAddModalHandler = { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while adding the task.' }); } } -}; +} satisfies ModalHandler; const sharedTodoCompleteModalHandler = { name: 'shared_todo_complete_modal', @@ -284,7 +297,7 @@ const sharedTodoCompleteModalHandler = { } const listKey = `shared_todo_${listId}`; - let listData = await getFromDb(listKey, null); + let listData = await getFromDb(listKey, null); if (!listData) { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Shared list not found.' }); @@ -324,7 +337,7 @@ const sharedTodoCompleteModalHandler = { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while completing the task.' }); } } -}; +} satisfies ModalHandler; const sharedTodoRemoveModalHandler = { name: 'shared_todo_remove_modal', @@ -349,7 +362,7 @@ const sharedTodoRemoveModalHandler = { } const listKey = `shared_todo_${listId}`; - const listData = await getFromDb(listKey, null); + const listData = await getFromDb(listKey, null); if (!listData) { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Shared list not found.' }); @@ -382,7 +395,7 @@ const sharedTodoRemoveModalHandler = { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while removing the task.' }); } } -}; +} satisfies ModalHandler; export default sharedTodoAddHandler; export { sharedTodoCompleteHandler, sharedTodoRemoveHandler, sharedTodoAddModalHandler, sharedTodoCompleteModalHandler, sharedTodoRemoveModalHandler }; \ No newline at end of file diff --git a/src/handlers/verificationButtons.js b/src/handlers/verificationButtons.ts similarity index 87% rename from src/handlers/verificationButtons.js rename to src/handlers/verificationButtons.ts index 663621ec8f..40becb07d1 100644 --- a/src/handlers/verificationButtons.js +++ b/src/handlers/verificationButtons.ts @@ -1,11 +1,13 @@ import { MessageFlags } from 'discord.js'; +import type { ButtonInteraction } from 'discord.js'; +import type { NexoClient } from '../types/index.js'; import { successEmbed } from '../utils/embeds.js'; import { verifyUser } from '../services/verificationService.js'; import { handleInteractionError, replyUserError, ErrorTypes } from '../utils/errorHandler.js'; import { logger } from '../utils/logger.js'; import { InteractionHelper } from '../utils/interactionHelper.js'; -export async function handleVerificationButton(interaction, client) { +export async function handleVerificationButton(interaction: ButtonInteraction, client: NexoClient): Promise { try { await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); @@ -46,7 +48,7 @@ export async function handleVerificationButton(interaction, client) { } catch (error) { logger.error('Error in verification button handler', { - error: error.message, + error: error instanceof Error ? error.message : String(error), guildId: interaction.guild?.id, userId: interaction.user.id }); diff --git a/src/handlers/warningHandlers.js b/src/handlers/warningHandlers.ts similarity index 90% rename from src/handlers/warningHandlers.js rename to src/handlers/warningHandlers.ts index 26b5970bb2..08b481f724 100644 --- a/src/handlers/warningHandlers.js +++ b/src/handlers/warningHandlers.ts @@ -1,4 +1,6 @@ import { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, MessageFlags } from 'discord.js'; +import type { ModalSubmitInteraction } from 'discord.js'; +import type { ButtonHandler, ModalHandler, NexoClient } from '../types/index.js'; import { successEmbed } from '../utils/embeds.js'; import { WarningService } from '../services/moderation/warningService.js'; import { InteractionHelper } from '../utils/interactionHelper.js'; @@ -27,7 +29,7 @@ const warningDeleteSpecificHandler = { .setStyle(TextInputStyle.Short) .setMaxLength(10); - const actionRow = new ActionRowBuilder().addComponents(warningNumberInput); + const actionRow = new ActionRowBuilder().addComponents(warningNumberInput); modal.addComponents(actionRow); await interaction.showModal(modal); @@ -36,7 +38,7 @@ const warningDeleteSpecificHandler = { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Failed to open delete warning modal.' }); } } -}; +} satisfies ButtonHandler; const warningClearAllHandler = { name: 'warning_clear_all', @@ -55,7 +57,7 @@ const warningClearAllHandler = { .setCustomId(`warning_clear_confirm_modal:${targetUserId}:${interaction.user.id}`) .setTitle('Clear All Warnings') .addComponents( - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('delete_confirmation') .setLabel(`Type "DELETE" to clear all warnings`) @@ -73,9 +75,9 @@ const warningClearAllHandler = { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Failed to open confirmation modal.' }); } } -}; +} satisfies ButtonHandler; -async function warningDeleteModalHandler(interaction, client) { +async function warningDeleteModalHandler(interaction: ModalSubmitInteraction, client: NexoClient): Promise { try { const [, targetUserId, originalModeratorId] = interaction.customId.split(':'); @@ -93,7 +95,7 @@ async function warningDeleteModalHandler(interaction, client) { const deferSuccess = await InteractionHelper.safeDefer(interaction); if (!deferSuccess) return; - const guildId = interaction.guildId; + const guildId = interaction.guildId!; const warnings = await WarningService.getWarnings(guildId, targetUserId); if (warningNumber > warnings.length) { @@ -121,7 +123,7 @@ async function warningDeleteModalHandler(interaction, client) { } } -async function warningClearConfirmModalHandler(interaction, client) { +async function warningClearConfirmModalHandler(interaction: ModalSubmitInteraction, client: NexoClient): Promise { try { const [, targetUserId, originalModeratorId] = interaction.customId.split(':'); @@ -137,7 +139,7 @@ async function warningClearConfirmModalHandler(interaction, client) { await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - const guildId = interaction.guildId; + const guildId = interaction.guildId!; const { count } = await WarningService.clearWarnings(guildId, targetUserId); const targetUser = await client.users.fetch(targetUserId).catch(() => null); @@ -168,4 +170,4 @@ export { export default { name: 'warning_delete_modal', execute: warningDeleteModalHandler -}; +} satisfies ModalHandler; diff --git a/src/handlers/wipedataButtons.js b/src/handlers/wipedataButtons.ts similarity index 94% rename from src/handlers/wipedataButtons.js rename to src/handlers/wipedataButtons.ts index 57c2d7ce17..54fc6a0bb0 100644 --- a/src/handlers/wipedataButtons.js +++ b/src/handlers/wipedataButtons.ts @@ -1,6 +1,7 @@ import { createEmbed, successEmbed } from '../utils/embeds.js'; import { InteractionHelper } from '../utils/interactionHelper.js'; import { MessageFlags } from 'discord.js'; +import type { ButtonHandler } from '../types/index.js'; import { logger } from '../utils/logger.js'; import { replyUserError, ErrorTypes } from '../utils/errorHandler.js'; @@ -21,7 +22,7 @@ const wipedataConfirmHandler = { if (!deferSuccess) return; const userId = interaction.user.id; - const guildId = interaction.guildId; + const guildId = interaction.guildId!; const dataKeyPatterns = [ getEconomyKey(guildId, userId), @@ -54,11 +55,11 @@ const wipedataConfirmHandler = { ]; let deletedCount = 0; - const deleteErrors = []; + const deleteErrors: string[] = []; for (const key of dataKeyPatterns) { try { - const exists = await client.db.exists(key); + const exists = await client.db.exists!(key); if (exists) { await client.db.delete(key); deletedCount++; @@ -81,7 +82,7 @@ const wipedataConfirmHandler = { `user:${guildId}:` ]; - const discoveredKeys = new Set(); + const discoveredKeys = new Set(); for (const prefix of searchPrefixes) { try { @@ -135,7 +136,7 @@ const wipedataConfirmHandler = { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while wiping your data. Please try again later or contact support.' }); } } -}; +} satisfies ButtonHandler; const wipedataCancelHandler = { name: 'wipedata_no', @@ -161,6 +162,6 @@ const wipedataCancelHandler = { } } } -}; +} satisfies ButtonHandler; export { wipedataConfirmHandler, wipedataCancelHandler }; \ No newline at end of file diff --git a/src/interactions/buttons/countdown/countdown.js b/src/interactions/buttons/countdown/countdown.ts similarity index 72% rename from src/interactions/buttons/countdown/countdown.js rename to src/interactions/buttons/countdown/countdown.ts index 78da057e53..631cc7b224 100644 --- a/src/interactions/buttons/countdown/countdown.js +++ b/src/interactions/buttons/countdown/countdown.ts @@ -1,4 +1,5 @@ import countdownButtonHandler from '../../../handlers/countdownButtons.js'; +import type { ButtonHandler } from '../../../types/index.js'; export default [ { @@ -9,4 +10,4 @@ export default [ name: 'countdown_cancel', execute: countdownButtonHandler, }, -]; \ No newline at end of file +] satisfies ButtonHandler[]; diff --git a/src/interactions/buttons/counter/counterDelete.js b/src/interactions/buttons/counter/counterDelete.ts similarity index 100% rename from src/interactions/buttons/counter/counterDelete.js rename to src/interactions/buttons/counter/counterDelete.ts diff --git a/src/interactions/buttons/giveaway/giveaway.js b/src/interactions/buttons/giveaway/giveaway.ts similarity index 66% rename from src/interactions/buttons/giveaway/giveaway.js rename to src/interactions/buttons/giveaway/giveaway.ts index a614a5cd85..bf84d51272 100644 --- a/src/interactions/buttons/giveaway/giveaway.js +++ b/src/interactions/buttons/giveaway/giveaway.ts @@ -4,8 +4,9 @@ import { giveawayRerollHandler, giveawayViewHandler, } from '../../../handlers/giveawayButtons.js'; +import type { ButtonHandler } from '../../../types/index.js'; -function fromCustomId(handler) { +function fromCustomId(handler: { customId: string; execute: ButtonHandler['execute'] }): ButtonHandler { return { name: handler.customId, execute: handler.execute, @@ -17,4 +18,4 @@ export default [ fromCustomId(giveawayEndHandler), fromCustomId(giveawayRerollHandler), fromCustomId(giveawayViewHandler), -]; \ No newline at end of file +] satisfies ButtonHandler[]; diff --git a/src/interactions/buttons/help/help.js b/src/interactions/buttons/help/help.ts similarity index 79% rename from src/interactions/buttons/help/help.js rename to src/interactions/buttons/help/help.ts index 61ab55ee21..ba3b3e15e1 100644 --- a/src/interactions/buttons/help/help.js +++ b/src/interactions/buttons/help/help.ts @@ -3,6 +3,7 @@ import { helpBugReportButton, helpPaginationButton, } from '../../../handlers/help/helpButtons.js'; +import type { ButtonHandler } from '../../../types/index.js'; const paginationIds = [ 'help-page_first', @@ -16,4 +17,4 @@ const paginationInteractions = paginationIds.map((name) => ({ execute: helpPaginationButton.execute, })); -export default [helpBackButton, helpBugReportButton, ...paginationInteractions]; \ No newline at end of file +export default [helpBackButton, helpBugReportButton, ...paginationInteractions] satisfies ButtonHandler[]; diff --git a/src/interactions/buttons/logging/logging.js b/src/interactions/buttons/logging/logging.ts similarity index 84% rename from src/interactions/buttons/logging/logging.js rename to src/interactions/buttons/logging/logging.ts index d18d028c5a..1f5aaed231 100644 --- a/src/interactions/buttons/logging/logging.js +++ b/src/interactions/buttons/logging/logging.ts @@ -1,4 +1,5 @@ import loggingButtonsHandler from '../../../handlers/loggingButtons.js'; +import type { ButtonHandler } from '../../../types/index.js'; export default [ { @@ -21,4 +22,4 @@ export default [ name: 'log_dash_remove_filter', execute: loggingButtonsHandler.execute, }, -]; +] satisfies ButtonHandler[]; diff --git a/src/interactions/buttons/music/music.js b/src/interactions/buttons/music/music.ts similarity index 87% rename from src/interactions/buttons/music/music.js rename to src/interactions/buttons/music/music.ts index 3a69f9f3fb..4fdbf14d28 100644 --- a/src/interactions/buttons/music/music.js +++ b/src/interactions/buttons/music/music.ts @@ -1,5 +1,6 @@ import musicButtonHandler from '../../../handlers/musicButtons.js'; import { MUSIC_BUTTON_IDS } from '../../../services/music/musicEmbeds.js'; +import type { ButtonHandler } from '../../../types/index.js'; const buttonIds = [ MUSIC_BUTTON_IDS.PAUSE, @@ -20,4 +21,4 @@ const buttonIds = [ export default buttonIds.map((name) => ({ name, execute: musicButtonHandler.execute, -})); +})) satisfies ButtonHandler[]; diff --git a/src/interactions/buttons/ticket/ticket.js b/src/interactions/buttons/ticket/ticket.ts similarity index 82% rename from src/interactions/buttons/ticket/ticket.js rename to src/interactions/buttons/ticket/ticket.ts index ce1f57b6b4..b96f8c3005 100644 --- a/src/interactions/buttons/ticket/ticket.js +++ b/src/interactions/buttons/ticket/ticket.ts @@ -7,6 +7,7 @@ import createTicketHandler, { reopenTicketHandler, deleteTicketHandler, } from '../../../handlers/ticketButtons.js'; +import type { ButtonHandler } from '../../../types/index.js'; export default [ createTicketHandler, @@ -17,4 +18,4 @@ export default [ unclaimTicketHandler, reopenTicketHandler, deleteTicketHandler, -]; \ No newline at end of file +] satisfies ButtonHandler[]; diff --git a/src/interactions/buttons/ticket/ticketFeedback.js b/src/interactions/buttons/ticket/ticketFeedback.ts similarity index 90% rename from src/interactions/buttons/ticket/ticketFeedback.js rename to src/interactions/buttons/ticket/ticketFeedback.ts index 38579f2817..0ef7ef4cfa 100644 --- a/src/interactions/buttons/ticket/ticketFeedback.js +++ b/src/interactions/buttons/ticket/ticketFeedback.ts @@ -4,8 +4,9 @@ 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'; +import type { ButtonHandler } from '../../../types/index.js'; -const STAR_LABELS = { +const STAR_LABELS: Record = { '1': '⭐ 1 — Poor', '2': '⭐ 2 — Below Average', '3': '⭐ 3 — Average', @@ -36,7 +37,7 @@ const feedbackHandler = { try { await interaction.deferUpdate(); } catch (err) { - logger.warn('ticketFeedback: interaction expired before deferUpdate', { guildId, channelId, error: err.message }); + logger.warn('ticketFeedback: interaction expired before deferUpdate', { guildId, channelId, error: err instanceof Error ? err.message : String(err) }); return; } @@ -44,7 +45,7 @@ const feedbackHandler = { try { ticketData = await getTicketData(guildId, channelId); } catch (err) { - logger.warn('ticketFeedback: failed to load ticket data', { guildId, channelId, error: err.message }); + logger.warn('ticketFeedback: failed to load ticket data', { guildId, channelId, error: err instanceof Error ? err.message : String(err) }); } if (!ticketData) { @@ -96,7 +97,7 @@ const feedbackHandler = { }; await saveTicketData(guildId, channelId, ticketData); } catch (err) { - logger.error('ticketFeedback: failed to save feedback', { guildId, channelId, rating, error: err.message }); + logger.error('ticketFeedback: failed to save feedback', { guildId, channelId, rating, error: err instanceof Error ? err.message : String(err) }); } try { @@ -109,7 +110,7 @@ const feedbackHandler = { rating, }); } catch (err) { - logger.warn('ticketFeedback: failed to send log', { guildId, channelId, error: err.message }); + logger.warn('ticketFeedback: failed to send log', { guildId, channelId, error: err instanceof Error ? err.message : String(err) }); } await InteractionHelper.safeEditReply(interaction, { @@ -131,7 +132,7 @@ const feedbackHandler = { rating, }); }, -}; +} satisfies ButtonHandler; const commentHandler = { name: 'ticket_feedback_comment', @@ -164,11 +165,11 @@ const commentHandler = { .setRequired(true) .setMaxLength(1000); - modal.addComponents(new ActionRowBuilder().addComponents(commentInput)); + modal.addComponents(new ActionRowBuilder().addComponents(commentInput)); await interaction.showModal(modal); }, -}; +} satisfies ButtonHandler; const declineHandler = { name: 'ticket_feedback_decline', @@ -184,6 +185,6 @@ const declineHandler = { components: [], }); }, -}; +} satisfies ButtonHandler; export default [feedbackHandler, commentHandler, declineHandler]; \ No newline at end of file diff --git a/src/interactions/buttons/todo/todoShared.js b/src/interactions/buttons/todo/todoShared.ts similarity index 70% rename from src/interactions/buttons/todo/todoShared.js rename to src/interactions/buttons/todo/todoShared.ts index b683f6bf4b..1f7740eb0f 100644 --- a/src/interactions/buttons/todo/todoShared.js +++ b/src/interactions/buttons/todo/todoShared.ts @@ -2,9 +2,10 @@ import todoAddHandler, { sharedTodoCompleteHandler, sharedTodoRemoveHandler, } from '../../../handlers/todoButtons.js'; +import type { ButtonHandler } from '../../../types/index.js'; export default [ todoAddHandler, sharedTodoCompleteHandler, sharedTodoRemoveHandler, -]; \ No newline at end of file +] satisfies ButtonHandler[]; diff --git a/src/interactions/buttons/verification/verification.js b/src/interactions/buttons/verification/verification.ts similarity index 68% rename from src/interactions/buttons/verification/verification.js rename to src/interactions/buttons/verification/verification.ts index f6b115c91e..0c53a1cf99 100644 --- a/src/interactions/buttons/verification/verification.js +++ b/src/interactions/buttons/verification/verification.ts @@ -1,6 +1,7 @@ import verificationButtonHandler from '../../../handlers/verificationButtons.js'; +import type { ButtonHandler } from '../../../types/index.js'; export default { name: verificationButtonHandler.customId, execute: verificationButtonHandler.execute, -}; \ No newline at end of file +} satisfies ButtonHandler; diff --git a/src/interactions/buttons/warnings/warnings.js b/src/interactions/buttons/warnings/warnings.ts similarity index 67% rename from src/interactions/buttons/warnings/warnings.js rename to src/interactions/buttons/warnings/warnings.ts index a7b40f49a0..6aa106866e 100644 --- a/src/interactions/buttons/warnings/warnings.js +++ b/src/interactions/buttons/warnings/warnings.ts @@ -2,8 +2,9 @@ import { warningDeleteSpecificHandler, warningClearAllHandler } from '../../../handlers/warningHandlers.js'; +import type { ButtonHandler } from '../../../types/index.js'; export default [ warningDeleteSpecificHandler, warningClearAllHandler -]; \ No newline at end of file +] satisfies ButtonHandler[]; diff --git a/src/interactions/buttons/wipedata/wipedata.js b/src/interactions/buttons/wipedata/wipedata.js deleted file mode 100644 index d54701c7de..0000000000 --- a/src/interactions/buttons/wipedata/wipedata.js +++ /dev/null @@ -1,6 +0,0 @@ -import { - wipedataConfirmHandler, - wipedataCancelHandler, -} from '../../../handlers/wipedataButtons.js'; - -export default [wipedataConfirmHandler, wipedataCancelHandler]; \ No newline at end of file diff --git a/src/interactions/buttons/wipedata/wipedata.ts b/src/interactions/buttons/wipedata/wipedata.ts new file mode 100644 index 0000000000..6df8cd4955 --- /dev/null +++ b/src/interactions/buttons/wipedata/wipedata.ts @@ -0,0 +1,7 @@ +import { + wipedataConfirmHandler, + wipedataCancelHandler, +} from '../../../handlers/wipedataButtons.js'; +import type { ButtonHandler } from '../../../types/index.js'; + +export default [wipedataConfirmHandler, wipedataCancelHandler] satisfies ButtonHandler[]; diff --git a/src/interactions/modals/calculate/calculate.js b/src/interactions/modals/calculate/calculate.ts similarity index 73% rename from src/interactions/modals/calculate/calculate.js rename to src/interactions/modals/calculate/calculate.ts index 66193613b3..07ba71b33f 100644 --- a/src/interactions/modals/calculate/calculate.js +++ b/src/interactions/modals/calculate/calculate.ts @@ -1,4 +1,5 @@ import calculateModalHandler from '../../../handlers/calculateModals.js'; +import type { ModalHandler } from '../../../types/index.js'; const execute = typeof calculateModalHandler === 'function' ? calculateModalHandler @@ -7,4 +8,4 @@ const execute = typeof calculateModalHandler === 'function' export default { name: 'calc_modal', execute, -}; \ No newline at end of file +} satisfies ModalHandler; diff --git a/src/interactions/modals/config/config_modal.js b/src/interactions/modals/config/config_modal.ts similarity index 76% rename from src/interactions/modals/config/config_modal.js rename to src/interactions/modals/config/config_modal.ts index 04043dbf59..cb2012dd89 100644 --- a/src/interactions/modals/config/config_modal.js +++ b/src/interactions/modals/config/config_modal.ts @@ -2,10 +2,12 @@ import { successEmbed } from '../../../utils/embeds.js'; import ConfigService from '../../../services/config/configService.js'; import { MessageFlags } from 'discord.js'; +import type { Guild, ModalSubmitInteraction, SelectMenuModalData } from 'discord.js'; +import type { ModalHandler } from '../../../types/index.js'; import { logger } from '../../../utils/logger.js'; import { ErrorTypes, replyUserError } from '../../../utils/errorHandler.js'; -function extractId(value) { +function extractId(value: unknown): string | null { if (!value || typeof value !== 'string') return null; const channelMention = value.match(/^<#(\d+)>$/); @@ -20,7 +22,7 @@ function extractId(value) { return null; } -function parseValue(key, rawValue) { +function parseValue(key: string, rawValue: string): string | boolean | null { const value = rawValue.trim(); if (['modRole', 'adminRole', 'autoRole', 'logChannelId'].includes(key)) { @@ -54,9 +56,9 @@ function parseValue(key, rawValue) { return value; } -function resolveModalValue(key, interaction) { +function resolveModalValue(key: string, interaction: ModalSubmitInteraction): string | boolean | null { if (key === 'logChannelId') { - const channelId = interaction.fields.getField('log_channel')?.values?.[0]; + const channelId = (interaction.fields.getField('log_channel') as SelectMenuModalData)?.values?.[0]; if (!channelId) { throw new Error('Please select a log channel.'); } @@ -64,7 +66,7 @@ function resolveModalValue(key, interaction) { } if (key === 'modRole') { - const roleId = interaction.fields.getField('mod_role')?.values?.[0]; + const roleId = (interaction.fields.getField('mod_role') as SelectMenuModalData)?.values?.[0]; if (!roleId) { throw new Error('Please select a moderator role.'); } @@ -75,14 +77,14 @@ function resolveModalValue(key, interaction) { return parseValue(key, rawValue); } -function buildSuccessMessage(key, value, guild) { +function buildSuccessMessage(key: string, value: string | boolean | null, guild: Guild | null): string { if (key === 'logChannelId') { - const channel = guild?.channels?.cache?.get(value); + const channel = guild?.channels?.cache?.get(String(value)); return `Log channel set to ${channel ?? `<#${value}>`}.`; } if (key === 'modRole') { - const role = guild?.roles?.cache?.get(value); + const role = guild?.roles?.cache?.get(String(value)); return `Moderator role set to ${role ?? `<@&${value}>`}.`; } @@ -104,7 +106,7 @@ export default { }); } catch (error) { logger.error('Config modal handler error:', error); - await replyUserError(interaction, { type: ErrorTypes.CONFIGURATION, message: error.message || 'Please try again.' }); + await replyUserError(interaction, { type: ErrorTypes.CONFIGURATION, message: (error instanceof Error && error.message) || 'Please try again.' }); } }, -}; +} satisfies ModalHandler; diff --git a/src/interactions/modals/ticket/ticket.js b/src/interactions/modals/ticket/ticket.ts similarity index 65% rename from src/interactions/modals/ticket/ticket.js rename to src/interactions/modals/ticket/ticket.ts index d79511e3e2..2c8f569322 100644 --- a/src/interactions/modals/ticket/ticket.js +++ b/src/interactions/modals/ticket/ticket.ts @@ -2,5 +2,6 @@ import { createTicketModalHandler, closeTicketModalHandler, } from '../../../handlers/ticketButtons.js'; +import type { ModalHandler } from '../../../types/index.js'; -export default [createTicketModalHandler, closeTicketModalHandler]; \ No newline at end of file +export default [createTicketModalHandler, closeTicketModalHandler] satisfies ModalHandler[]; diff --git a/src/interactions/modals/ticket/ticketFeedbackComment.js b/src/interactions/modals/ticket/ticketFeedbackComment.ts similarity index 88% rename from src/interactions/modals/ticket/ticketFeedbackComment.js rename to src/interactions/modals/ticket/ticketFeedbackComment.ts index 42123eadf2..695746fc5f 100644 --- a/src/interactions/modals/ticket/ticketFeedbackComment.js +++ b/src/interactions/modals/ticket/ticketFeedbackComment.ts @@ -1,11 +1,13 @@ import { EmbedBuilder, MessageFlags } from 'discord.js'; +import type { ColorResolvable } from 'discord.js'; +import type { ModalHandler } from '../../../types/index.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) { +function buildEmbed(title: string, description: string, color: ColorResolvable): EmbedBuilder { return new EmbedBuilder() .setTitle(title) .setDescription(description) @@ -52,7 +54,7 @@ export default { try { ticketData = await getTicketData(guildId, channelId); } catch (err) { - logger.warn('ticketFeedbackComment: failed to load ticket data', { guildId, channelId, error: err.message }); + logger.warn('ticketFeedbackComment: failed to load ticket data', { guildId, channelId, error: err instanceof Error ? err.message : String(err) }); } if (!ticketData) { @@ -86,7 +88,7 @@ export default { try { await saveTicketData(guildId, channelId, ticketData); } catch (err) { - logger.error('ticketFeedbackComment: failed to save feedback', { guildId, channelId, error: err.message }); + logger.error('ticketFeedbackComment: failed to save feedback', { guildId, channelId, error: err instanceof Error ? err.message : String(err) }); } try { @@ -100,7 +102,7 @@ export default { comment, }); } catch (err) { - logger.warn('ticketFeedbackComment: failed to send log', { guildId, channelId, error: err.message }); + logger.warn('ticketFeedbackComment: failed to send log', { guildId, channelId, error: err instanceof Error ? err.message : String(err) }); } await InteractionHelper.safeEditReply(interaction, { @@ -117,4 +119,4 @@ export default { userId: interaction.user.id, }); }, -}; +} satisfies ModalHandler; diff --git a/src/interactions/modals/todo/todoShared.js b/src/interactions/modals/todo/todoShared.ts similarity index 74% rename from src/interactions/modals/todo/todoShared.js rename to src/interactions/modals/todo/todoShared.ts index 9624f3bad2..8db935a25b 100644 --- a/src/interactions/modals/todo/todoShared.js +++ b/src/interactions/modals/todo/todoShared.ts @@ -3,9 +3,10 @@ import { sharedTodoCompleteModalHandler, sharedTodoRemoveModalHandler, } from '../../../handlers/todoButtons.js'; +import type { ModalHandler } from '../../../types/index.js'; export default [ sharedTodoAddModalHandler, sharedTodoCompleteModalHandler, sharedTodoRemoveModalHandler, -]; \ No newline at end of file +] satisfies ModalHandler[]; diff --git a/src/interactions/modals/warnings/warningDelete.js b/src/interactions/modals/warnings/warningDelete.js deleted file mode 100644 index 24b2c6b0cc..0000000000 --- a/src/interactions/modals/warnings/warningDelete.js +++ /dev/null @@ -1,20 +0,0 @@ -import { warningDeleteModalHandler, warningClearConfirmModalHandler } from '../../../handlers/warningHandlers.js'; - -const deleteExecute = typeof warningDeleteModalHandler === 'function' - ? warningDeleteModalHandler - : warningDeleteModalHandler.execute; - -const clearExecute = typeof warningClearConfirmModalHandler === 'function' - ? warningClearConfirmModalHandler - : warningClearConfirmModalHandler.execute; - -export default [ - { - name: 'warning_delete_modal', - execute: deleteExecute - }, - { - name: 'warning_clear_confirm_modal', - execute: clearExecute - } -]; \ No newline at end of file diff --git a/src/interactions/modals/warnings/warningDelete.ts b/src/interactions/modals/warnings/warningDelete.ts new file mode 100644 index 0000000000..59317c7c0d --- /dev/null +++ b/src/interactions/modals/warnings/warningDelete.ts @@ -0,0 +1,18 @@ +import { warningDeleteModalHandler, warningClearConfirmModalHandler } from '../../../handlers/warningHandlers.js'; +import type { ModalHandler } from '../../../types/index.js'; + +// Both handlers are plain functions (the JS also checked for an { execute } object, which never applied). +const deleteExecute = warningDeleteModalHandler; + +const clearExecute = warningClearConfirmModalHandler; + +export default [ + { + name: 'warning_delete_modal', + execute: deleteExecute + }, + { + name: 'warning_clear_confirm_modal', + execute: clearExecute + } +] satisfies ModalHandler[]; \ No newline at end of file diff --git a/src/interactions/selectMenus/help/helpCategory.js b/src/interactions/selectMenus/help/helpCategory.ts similarity index 100% rename from src/interactions/selectMenus/help/helpCategory.js rename to src/interactions/selectMenus/help/helpCategory.ts diff --git a/src/interactions/selectMenus/logging/logging.js b/src/interactions/selectMenus/logging/logging.ts similarity index 61% rename from src/interactions/selectMenus/logging/logging.js rename to src/interactions/selectMenus/logging/logging.ts index 8b151db65b..4a01b51d99 100644 --- a/src/interactions/selectMenus/logging/logging.js +++ b/src/interactions/selectMenus/logging/logging.ts @@ -1,6 +1,7 @@ import { handleLoggingMenuSelect } from '../../../handlers/loggingButtons.js'; +import type { SelectMenuHandler } from '../../../types/index.js'; export default { name: 'log_dash_menu', execute: handleLoggingMenuSelect, -}; +} satisfies SelectMenuHandler; diff --git a/src/interactions/selectMenus/reactionRoles/reaction_roles.js b/src/interactions/selectMenus/reactionRoles/reaction_roles.js deleted file mode 100644 index 25e9e1a803..0000000000 --- a/src/interactions/selectMenus/reactionRoles/reaction_roles.js +++ /dev/null @@ -1,10 +0,0 @@ -import { handleReactionRolesSelectMenu } from '../../../handlers/interactionHandlers/reactionRolesSelectMenu.js'; - -export async function execute(interaction, client) { - return handleReactionRolesSelectMenu(interaction, client); -} - -export default { - name: 'reaction_roles', - execute -}; \ No newline at end of file diff --git a/src/interactions/selectMenus/reactionRoles/reaction_roles.ts b/src/interactions/selectMenus/reactionRoles/reaction_roles.ts new file mode 100644 index 0000000000..982a3e57ed --- /dev/null +++ b/src/interactions/selectMenus/reactionRoles/reaction_roles.ts @@ -0,0 +1,12 @@ +import type { StringSelectMenuInteraction } from 'discord.js'; +import { handleReactionRolesSelectMenu } from '../../../handlers/interactionHandlers/reactionRolesSelectMenu.js'; +import type { NexoClient, SelectMenuHandler } from '../../../types/index.js'; + +export async function execute(interaction: StringSelectMenuInteraction, client: NexoClient): Promise { + return handleReactionRolesSelectMenu(interaction, client); +} + +export default { + name: 'reaction_roles', + execute +} satisfies SelectMenuHandler; \ No newline at end of file diff --git a/src/interactions/selectMenus/ticket/ticketFeedback.js b/src/interactions/selectMenus/ticket/ticketFeedback.ts similarity index 91% rename from src/interactions/selectMenus/ticket/ticketFeedback.js rename to src/interactions/selectMenus/ticket/ticketFeedback.ts index c801f31a3f..2a2a4c2f11 100644 --- a/src/interactions/selectMenus/ticket/ticketFeedback.js +++ b/src/interactions/selectMenus/ticket/ticketFeedback.ts @@ -3,8 +3,9 @@ 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 type { SelectMenuHandler } from '../../../types/index.js'; -const STAR_LABELS = { +const STAR_LABELS: Record = { '1': '⭐ 1 — Poor', '2': '⭐ 2 — Below Average', '3': '⭐ 3 — Average', @@ -36,7 +37,7 @@ export default { try { ticketData = await getTicketData(guildId, channelId); } catch (err) { - logger.warn('ticketFeedback: failed to load ticket data', { guildId, channelId, error: err.message }); + logger.warn('ticketFeedback: failed to load ticket data', { guildId, channelId, error: err instanceof Error ? err.message : String(err) }); } if (!ticketData) { @@ -88,7 +89,7 @@ export default { }; await saveTicketData(guildId, channelId, ticketData); } catch (err) { - logger.error('ticketFeedback: failed to save feedback', { guildId, channelId, rating, error: err.message }); + logger.error('ticketFeedback: failed to save feedback', { guildId, channelId, rating, error: err instanceof Error ? err.message : String(err) }); } try { @@ -101,7 +102,7 @@ export default { rating, }); } catch (err) { - logger.warn('ticketFeedback: failed to send log', { guildId, channelId, error: err.message }); + logger.warn('ticketFeedback: failed to send log', { guildId, channelId, error: err instanceof Error ? err.message : String(err) }); } const thankYouEmbed = new EmbedBuilder() @@ -123,4 +124,4 @@ export default { rating, }); }, -}; \ No newline at end of file +} satisfies SelectMenuHandler; \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 1c0dd07a3c..7f2a08cc91 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -81,6 +81,7 @@ export interface Command { /** A subcommand implementation called by a router command. */ export interface SubcommandModule { + prefixOnly?: boolean; execute( interaction: ChatInputCommandInteraction, guildConfig: GuildConfig | null, diff --git a/src/utils/database/guildConfigStorage.ts b/src/utils/database/guildConfigStorage.ts index 3f058d533e..3948b143e8 100644 --- a/src/utils/database/guildConfigStorage.ts +++ b/src/utils/database/guildConfigStorage.ts @@ -7,9 +7,9 @@ import { getGuildConfigKey } from './keys.js'; /** Optional tracing context passed through to logs and errors. */ export interface GuildConfigContext { - traceId?: string; - userId?: string; - command?: string; + traceId?: string | null; + userId?: string | null; + command?: string | null; [key: string]: unknown; } diff --git a/src/utils/ticket/ticketPermissions.ts b/src/utils/ticket/ticketPermissions.ts index 82923d73e1..027bddacbd 100644 --- a/src/utils/ticket/ticketPermissions.ts +++ b/src/utils/ticket/ticketPermissions.ts @@ -1,17 +1,17 @@ // ticketPermissions.js import { PermissionFlagsBits } from 'discord.js'; -import type { ChatInputCommandInteraction, GuildMember } from 'discord.js'; +import type { ButtonInteraction, ChatInputCommandInteraction, GuildMember, ModalSubmitInteraction } from 'discord.js'; import type { NexoClient } from '../../types/index.js'; import { getGuildConfig } from '../../services/config/guildConfig.js'; import { getTicketData } from '../database.js'; export async function getTicketPermissionContext({ client, interaction }: { client: NexoClient; - interaction: ChatInputCommandInteraction; + interaction: ChatInputCommandInteraction | ButtonInteraction | ModalSubmitInteraction; }) { const guildId = interaction.guildId!; - const channelId = interaction.channelId; + const channelId = interaction.channelId!; const [config, ticketData] = await Promise.all([ getGuildConfig(client, guildId), diff --git a/tsconfig.json b/tsconfig.json index 235ab3889d..7d35ac2f3e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,8 +11,6 @@ "resolveJsonModule": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "allowJs": true, - "checkJs": false, "strict": true }, "include": ["src"] From 6c9d4f81b68902f807e1993e1490d7333e26af05 Mon Sep 17 00:00:00 2001 From: robertwalter Date: Sat, 17 Oct 2026 16:48:38 +0200 Subject: [PATCH 07/20] feat(commands): consolidate 99 top-level commands into 36 Old command implementations move to /modules and are wrapped by router commands (createRouterCommand) built from their own builders, so options and behaviour stay unchanged. A single legacy map (config/commands/legacyCommandMap.ts) drives: - access checks: stored disabledCommands keys with old names and old categories still apply (no data migration) - prefix routing and aliases (!bal -> /eco balance) - per-old-command cooldown buckets, abuse protection and error subtypes - help hints and docs/COMMAND_MIGRATION.md Prefix parser now supports commands that mix subcommands and groups and prefers exact subcommand names over aliases. Routes keep stricter permissions and guild-only flags of the old commands. Co-Authored-By: Claude Opus 5.5 --- docs/COMMAND_MIGRATION.md | 102 +++++++ package.json | 3 +- src/commands/Core/bot.ts | 17 ++ src/commands/Core/commands.ts | 8 +- src/commands/Core/{ => modules}/ping.ts | 10 +- src/commands/Core/{ => modules}/stats.ts | 8 +- src/commands/Core/{ => modules}/support.ts | 8 +- src/commands/Core/{ => modules}/uptime.ts | 8 +- src/commands/Economy/eco.ts | 43 +++ src/commands/Economy/economy.ts | 48 +--- src/commands/Economy/{ => modules}/balance.ts | 12 +- src/commands/Economy/{ => modules}/beg.ts | 14 +- src/commands/Economy/{ => modules}/buy.ts | 14 +- src/commands/Economy/{ => modules}/crime.ts | 12 +- src/commands/Economy/{ => modules}/daily.ts | 18 +- src/commands/Economy/{ => modules}/deposit.ts | 10 +- src/commands/Economy/modules/economy.ts | 33 +++ .../Economy/{ => modules}/eleaderboard.ts | 14 +- src/commands/Economy/{ => modules}/fish.ts | 12 +- src/commands/Economy/{ => modules}/gamble.ts | 12 +- .../Economy/{ => modules}/inventory.ts | 14 +- src/commands/Economy/{ => modules}/mine.ts | 12 +- src/commands/Economy/{ => modules}/pay.ts | 14 +- src/commands/Economy/{ => modules}/rob.ts | 12 +- .../Economy/{ => modules}/shop-config.ts | 4 +- src/commands/Economy/{ => modules}/shop.ts | 4 +- src/commands/Economy/modules/shop_browse.ts | 4 +- src/commands/Economy/{ => modules}/slut.ts | 14 +- .../Economy/{ => modules}/withdraw.ts | 10 +- src/commands/Economy/{ => modules}/work.ts | 14 +- src/commands/Fun/fun.ts | 15 + src/commands/Fun/{ => modules}/fight.ts | 8 +- src/commands/Fun/{ => modules}/flip.ts | 10 +- src/commands/Fun/{ => modules}/roll.ts | 10 +- src/commands/Giveaway/giveaway.ts | 19 ++ .../Giveaway/{ => modules}/gcreate.ts | 20 +- .../Giveaway/{ => modules}/gdelete.ts | 14 +- src/commands/Giveaway/{ => modules}/gend.ts | 16 +- .../Giveaway/{ => modules}/greroll.ts | 20 +- src/commands/Leveling/level.ts | 171 ++--------- .../Leveling/{ => modules}/leaderboard.ts | 10 +- src/commands/Leveling/modules/level.ts | 152 ++++++++++ .../Leveling/{ => modules}/leveladd.ts | 14 +- .../Leveling/{ => modules}/levelremove.ts | 14 +- .../Leveling/{ => modules}/levelset.ts | 14 +- src/commands/Leveling/modules/rank.ts | 100 +++++++ src/commands/Leveling/rank.ts | 109 +------ src/commands/Moderation/ban.ts | 83 ++---- src/commands/Moderation/channel.ts | 15 + src/commands/Moderation/kick.ts | 94 +------ src/commands/Moderation/mod.ts | 21 ++ src/commands/Moderation/modules/ban.ts | 68 +++++ src/commands/Moderation/{ => modules}/dm.ts | 14 +- src/commands/Moderation/modules/kick.ts | 79 ++++++ src/commands/Moderation/{ => modules}/lock.ts | 14 +- .../Moderation/{ => modules}/massban.ts | 14 +- .../Moderation/{ => modules}/masskick.ts | 14 +- .../Moderation/{ => modules}/purge.ts | 14 +- src/commands/Moderation/{ => modules}/say.ts | 14 +- .../Moderation/{ => modules}/timeout.ts | 12 +- .../Moderation/{ => modules}/unban.ts | 12 +- .../Moderation/{ => modules}/unlock.ts | 14 +- .../Moderation/{ => modules}/untimeout.ts | 12 +- .../Moderation/{ => modules}/usernotes.ts | 14 +- src/commands/Moderation/{ => modules}/warn.ts | 16 +- .../Moderation/{ => modules}/warnings.ts | 14 +- src/commands/Moderation/msg.ts | 19 ++ src/commands/Music/{ => modules}/join.ts | 8 +- src/commands/Music/modules/music.ts | 190 +++++++++++++ .../Music/{ => modules}/nowplaying.ts | 8 +- src/commands/Music/{ => modules}/play.ts | 6 +- src/commands/Music/{ => modules}/queue.ts | 6 +- src/commands/Music/music.ts | 207 ++------------ src/commands/Ticket/{ => modules}/claim.ts | 16 +- src/commands/Ticket/{ => modules}/close.ts | 16 +- src/commands/Ticket/{ => modules}/priority.ts | 16 +- src/commands/Ticket/tickets.ts | 15 + .../Tools/{ => modules}/baseconvert.ts | 14 +- src/commands/Tools/{ => modules}/calculate.ts | 12 +- src/commands/Tools/{ => modules}/countdown.ts | 10 +- .../Tools/{ => modules}/embedbuilder.ts | 12 +- .../Tools/{ => modules}/generatepassword.ts | 12 +- src/commands/Tools/{ => modules}/hexcolor.ts | 12 +- src/commands/Tools/{ => modules}/poll.ts | 10 +- .../Tools/{ => modules}/randomuser.ts | 12 +- src/commands/Tools/{ => modules}/shorten.ts | 12 +- src/commands/Tools/{ => modules}/time.ts | 10 +- src/commands/Tools/{ => modules}/unixtime.ts | 10 +- src/commands/Tools/tools.ts | 33 +++ src/commands/Utility/info.ts | 15 + src/commands/Utility/{ => modules}/avatar.ts | 8 +- .../Utility/{ => modules}/firstmsg.ts | 8 +- .../Utility/{ => modules}/serverinfo.ts | 8 +- .../Utility/{ => modules}/userinfo.ts | 8 +- src/commands/Utility/{ => modules}/weather.ts | 10 +- src/commands/Verification/autoverify.ts | 1 - .../modules/autoVerifyDashboard.ts | 2 +- .../Verification/modules/verification.ts | 252 +++++++++++++++++ .../modules/verification_dashboard.ts | 2 +- src/commands/Verification/verification.ts | 265 +----------------- .../Welcome/{ => modules}/autorole.ts | 16 +- src/commands/Welcome/{ => modules}/goodbye.ts | 22 +- src/commands/Welcome/{ => modules}/greet.ts | 14 +- .../Welcome/modules/greet_dashboard.ts | 2 +- src/commands/Welcome/modules/welcome.ts | 126 +++++++++ src/commands/Welcome/welcome.ts | 145 ++-------- src/config/commands/commandAliases.ts | 1 - src/config/commands/legacyCommandMap.ts | 226 +++++++++++++++ src/config/commands/prefixRestrictions.ts | 27 +- src/config/shop/items.ts | 6 +- src/events/interactionCreate.ts | 28 +- src/events/messageCreate.ts | 25 +- src/handlers/calculateModals.ts | 2 +- src/handlers/countdownButtons.ts | 4 +- src/handlers/help/helpSelectMenus.ts | 14 +- src/handlers/musicButtons.ts | 2 +- src/services/commandAccessService.ts | 62 ++-- src/services/music/musicActions.ts | 2 +- src/types/index.ts | 11 + src/utils/commandRouter.ts | 203 ++++++++++++++ src/utils/prefixParser.ts | 98 ++++--- tests/commandConsolidation.test.ts | 111 ++++++++ 122 files changed, 2604 insertions(+), 1550 deletions(-) create mode 100644 docs/COMMAND_MIGRATION.md create mode 100644 src/commands/Core/bot.ts rename src/commands/Core/{ => modules}/ping.ts (92%) rename src/commands/Core/{ => modules}/stats.ts (86%) rename src/commands/Core/{ => modules}/support.ts (85%) rename src/commands/Core/{ => modules}/uptime.ts (85%) create mode 100644 src/commands/Economy/eco.ts rename src/commands/Economy/{ => modules}/balance.ts (91%) rename src/commands/Economy/{ => modules}/beg.ts (89%) rename src/commands/Economy/{ => modules}/buy.ts (94%) rename src/commands/Economy/{ => modules}/crime.ts (93%) rename src/commands/Economy/{ => modules}/daily.ts (88%) rename src/commands/Economy/{ => modules}/deposit.ts (95%) create mode 100644 src/commands/Economy/modules/economy.ts rename src/commands/Economy/{ => modules}/eleaderboard.ts (88%) rename src/commands/Economy/{ => modules}/fish.ts (93%) rename src/commands/Economy/{ => modules}/gamble.ts (94%) rename src/commands/Economy/{ => modules}/inventory.ts (87%) rename src/commands/Economy/{ => modules}/mine.ts (91%) rename src/commands/Economy/{ => modules}/pay.ts (93%) rename src/commands/Economy/{ => modules}/rob.ts (95%) rename src/commands/Economy/{ => modules}/shop-config.ts (89%) rename src/commands/Economy/{ => modules}/shop.ts (76%) rename src/commands/Economy/{ => modules}/slut.ts (94%) rename src/commands/Economy/{ => modules}/withdraw.ts (92%) rename src/commands/Economy/{ => modules}/work.ts (92%) create mode 100644 src/commands/Fun/fun.ts rename src/commands/Fun/{ => modules}/fight.ts (91%) rename src/commands/Fun/{ => modules}/flip.ts (71%) rename src/commands/Fun/{ => modules}/roll.ts (88%) create mode 100644 src/commands/Giveaway/giveaway.ts rename src/commands/Giveaway/{ => modules}/gcreate.ts (91%) rename src/commands/Giveaway/{ => modules}/gdelete.ts (93%) rename src/commands/Giveaway/{ => modules}/gend.ts (93%) rename src/commands/Giveaway/{ => modules}/greroll.ts (94%) rename src/commands/Leveling/{ => modules}/leaderboard.ts (89%) create mode 100644 src/commands/Leveling/modules/level.ts rename src/commands/Leveling/{ => modules}/leveladd.ts (83%) rename src/commands/Leveling/{ => modules}/levelremove.ts (87%) rename src/commands/Leveling/{ => modules}/levelset.ts (83%) create mode 100644 src/commands/Leveling/modules/rank.ts create mode 100644 src/commands/Moderation/channel.ts create mode 100644 src/commands/Moderation/mod.ts create mode 100644 src/commands/Moderation/modules/ban.ts rename src/commands/Moderation/{ => modules}/dm.ts (92%) create mode 100644 src/commands/Moderation/modules/kick.ts rename src/commands/Moderation/{ => modules}/lock.ts (85%) rename src/commands/Moderation/{ => modules}/massban.ts (94%) rename src/commands/Moderation/{ => modules}/masskick.ts (94%) rename src/commands/Moderation/{ => modules}/purge.ts (86%) rename src/commands/Moderation/{ => modules}/say.ts (91%) rename src/commands/Moderation/{ => modules}/timeout.ts (90%) rename src/commands/Moderation/{ => modules}/unban.ts (85%) rename src/commands/Moderation/{ => modules}/unlock.ts (88%) rename src/commands/Moderation/{ => modules}/untimeout.ts (83%) rename src/commands/Moderation/{ => modules}/usernotes.ts (96%) rename src/commands/Moderation/{ => modules}/warn.ts (86%) rename src/commands/Moderation/{ => modules}/warnings.ts (89%) create mode 100644 src/commands/Moderation/msg.ts rename src/commands/Music/{ => modules}/join.ts (64%) create mode 100644 src/commands/Music/modules/music.ts rename src/commands/Music/{ => modules}/nowplaying.ts (63%) rename src/commands/Music/{ => modules}/play.ts (78%) rename src/commands/Music/{ => modules}/queue.ts (80%) rename src/commands/Ticket/{ => modules}/claim.ts (77%) rename src/commands/Ticket/{ => modules}/close.ts (81%) rename src/commands/Ticket/{ => modules}/priority.ts (82%) create mode 100644 src/commands/Ticket/tickets.ts rename src/commands/Tools/{ => modules}/baseconvert.ts (95%) rename src/commands/Tools/{ => modules}/calculate.ts (97%) rename src/commands/Tools/{ => modules}/countdown.ts (92%) rename src/commands/Tools/{ => modules}/embedbuilder.ts (99%) rename src/commands/Tools/{ => modules}/generatepassword.ts (94%) rename src/commands/Tools/{ => modules}/hexcolor.ts (93%) rename src/commands/Tools/{ => modules}/poll.ts (94%) rename src/commands/Tools/{ => modules}/randomuser.ts (96%) rename src/commands/Tools/{ => modules}/shorten.ts (92%) rename src/commands/Tools/{ => modules}/time.ts (89%) rename src/commands/Tools/{ => modules}/unixtime.ts (83%) create mode 100644 src/commands/Tools/tools.ts create mode 100644 src/commands/Utility/info.ts rename src/commands/Utility/{ => modules}/avatar.ts (80%) rename src/commands/Utility/{ => modules}/firstmsg.ts (89%) rename src/commands/Utility/{ => modules}/serverinfo.ts (88%) rename src/commands/Utility/{ => modules}/userinfo.ts (90%) rename src/commands/Utility/{ => modules}/weather.ts (95%) delete mode 100644 src/commands/Verification/autoverify.ts create mode 100644 src/commands/Verification/modules/verification.ts rename src/commands/Welcome/{ => modules}/autorole.ts (95%) rename src/commands/Welcome/{ => modules}/goodbye.ts (88%) rename src/commands/Welcome/{ => modules}/greet.ts (78%) create mode 100644 src/commands/Welcome/modules/welcome.ts create mode 100644 src/config/commands/legacyCommandMap.ts create mode 100644 src/utils/commandRouter.ts create mode 100644 tests/commandConsolidation.test.ts diff --git a/docs/COMMAND_MIGRATION.md b/docs/COMMAND_MIGRATION.md new file mode 100644 index 0000000000..9f1565ccd1 --- /dev/null +++ b/docs/COMMAND_MIGRATION.md @@ -0,0 +1,102 @@ +# Command-Migration: 99 → 36 Commands + +NexoBot fasst die früheren Einzel-Commands thematisch zusammen. Aus 99 Top-Level-Commands werden 36. Command-Namen innerhalb der Gruppen, Optionen und Buttons bleiben gleich. + +## Was gleich bleibt + +- **Gesperrte Commands:** Sperren aus `/commands` mit alten Namen gelten weiter. Ein gesperrtes `/balance` sperrt `/eco balance`, ein gesperrtes `/usernotes` sperrt `/msg notes`. Gesperrte Kategorien gelten auch für Commands, die in eine andere Kategorie gewandert sind (z. B. `/weather` aus Utility). +- **Prefix-Commands:** Alte Namen und Aliase funktionieren weiter: `!bal` → `/eco balance`, `!unban` → `/ban remove`, `!usernotes view @User` → `/msg notes view @User`. +- **Cooldowns:** Cooldowns und Missbrauchsschutz zählen weiter pro altem Command. +- **`/commands disable balance`** akzeptiert alte Namen und sperrt den neuen Subcommand. + +Hinweis: Bei Namen, die es alt und neu gibt (`/ban`, `/kick`, `/rank`, `/welcome`), sperrt ein gespeicherter Eintrag ohne Subcommand den ganzen neuen Command. Beispiel: Ein gesperrtes `/ban` sperrt auch `/ban remove` und `/ban mass`. + +## Unverändert + +`/help`, `/commands`, `/configwizard`, `/todo`, `/report`, `/wipedata`, `/apply`, `/app-admin`, `/count`, `/ticket`, `/cases`, `/verify`, `/birthday`, `/jointocreate`, `/logging`, `/reactroles`, `/search`, `/serverstats` + +Bestehende Subcommands bleiben außerdem direkt erhalten bei `/economy` (dashboard), `/music` (pause, resume, skip, …), `/level` (setup, dashboard) und `/verification` (setup, remove, dashboard). + +## Zuordnung + +`…` bedeutet: Die alten Subcommands bleiben unter der neuen Gruppe erhalten, z. B. `/usernotes add` → `/msg notes add`. + +| Alt | Neu | Alte Kategorie | +| --- | --- | --- | +| `/ban` | `/ban user` | Moderation | +| `/unban` | `/ban remove` | Moderation | +| `/massban` | `/ban mass` | Moderation | +| `/ping` | `/bot ping` | Core | +| `/stats` | `/bot stats` | Core | +| `/support` | `/bot support` | Core | +| `/uptime` | `/bot uptime` | Core | +| `/lock` | `/channel lock` | Moderation | +| `/unlock` | `/channel unlock` | Moderation | +| `/balance` | `/eco balance` | Economy | +| `/beg` | `/eco beg` | Economy | +| `/buy` | `/eco buy` | Economy | +| `/crime` | `/eco crime` | Economy | +| `/daily` | `/eco daily` | Economy | +| `/deposit` | `/eco deposit` | Economy | +| `/eleaderboard` | `/eco leaderboard` | Economy | +| `/fish` | `/eco fish` | Economy | +| `/gamble` | `/eco gamble` | Economy | +| `/inventory` | `/eco inventory` | Economy | +| `/mine` | `/eco mine` | Economy | +| `/pay` | `/eco pay` | Economy | +| `/rob` | `/eco rob` | Economy | +| `/shop` | `/eco shop` | Economy | +| `/slut` | `/eco slut` | Economy | +| `/withdraw` | `/eco withdraw` | Economy | +| `/work` | `/eco work` | Economy | +| `/shop-config …` | `/economy shop …` | Economy | +| `/fight` | `/fun fight` | Fun | +| `/flip` | `/fun flip` | Fun | +| `/roll` | `/fun roll` | Fun | +| `/gcreate` | `/giveaway create` | Giveaway | +| `/gdelete` | `/giveaway delete` | Giveaway | +| `/gend` | `/giveaway end` | Giveaway | +| `/greroll` | `/giveaway reroll` | Giveaway | +| `/avatar` | `/info avatar` | Utility | +| `/serverinfo` | `/info server` | Utility | +| `/userinfo` | `/info user` | Utility | +| `/kick` | `/kick user` | Moderation | +| `/masskick` | `/kick mass` | Moderation | +| `/leveladd` | `/level add` | Leveling | +| `/levelremove` | `/level remove` | Leveling | +| `/levelset` | `/level set` | Leveling | +| `/timeout` | `/mod timeout` | Moderation | +| `/untimeout` | `/mod untimeout` | Moderation | +| `/warn` | `/mod warn` | Moderation | +| `/warnings` | `/mod warnings` | Moderation | +| `/dm` | `/mod dm` | Moderation | +| `/purge` | `/msg purge` | Moderation | +| `/say` | `/msg say` | Moderation | +| `/embedbuilder` | `/msg embed` | Tools | +| `/usernotes …` | `/msg notes …` | Moderation | +| `/join` | `/music join` | Music | +| `/play` | `/music play` | Music | +| `/queue` | `/music queue` | Music | +| `/nowplaying` | `/music nowplaying` | Music | +| `/rank` | `/rank view` | Leveling | +| `/leaderboard` | `/rank leaderboard` | Leveling | +| `/claim` | `/tickets claim` | Ticket | +| `/close` | `/tickets close` | Ticket | +| `/priority` | `/tickets priority` | Ticket | +| `/baseconvert` | `/tools baseconvert` | Tools | +| `/calculate` | `/tools calculate` | Tools | +| `/countdown` | `/tools countdown` | Tools | +| `/generatepassword` | `/tools generatepassword` | Tools | +| `/hexcolor` | `/tools hexcolor` | Tools | +| `/poll` | `/tools poll` | Tools | +| `/randomuser` | `/tools randomuser` | Tools | +| `/shorten` | `/tools shorten` | Tools | +| `/time` | `/tools time` | Tools | +| `/unixtime` | `/tools unixtime` | Tools | +| `/weather` | `/tools weather` | Utility | +| `/firstmsg` | `/tools firstmsg` | Utility | +| `/autoverify …` | `/verification autoverify …` | Verification | +| `/autorole …` | `/welcome autorole …` | Welcome | +| `/goodbye …` | `/welcome goodbye …` | Welcome | +| `/greet …` | `/welcome greet …` | Welcome | +| `/welcome …` | `/welcome message …` | Welcome | diff --git a/package.json b/package.json index 00c1bda378..f8cb77364c 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "migrate:keys:dry": "node dist/scripts/migrate-keys.js --dry-run", "backup:db": "node dist/scripts/backup.js", "restore:db": "node dist/scripts/restore.js", - "backup:drill": "node dist/scripts/restore-drill.js" + "backup:drill": "node dist/scripts/restore-drill.js", + "test": "tsx --test tests/*.test.ts" }, "dependencies": { "@discordjs/rest": "^2.6.1", diff --git a/src/commands/Core/bot.ts b/src/commands/Core/bot.ts new file mode 100644 index 0000000000..a0a7c235f1 --- /dev/null +++ b/src/commands/Core/bot.ts @@ -0,0 +1,17 @@ +import { createRouterCommand } from '../../utils/commandRouter.js'; +import pingCommand from './modules/ping.js'; +import statsCommand from './modules/stats.js'; +import supportCommand from './modules/support.js'; +import uptimeCommand from './modules/uptime.js'; + +export default createRouterCommand({ + name: 'bot', + description: 'Bot status and information', + category: 'Core', + routes: [ + { legacyName: 'ping', module: pingCommand }, + { legacyName: 'stats', module: statsCommand }, + { legacyName: 'support', module: supportCommand }, + { legacyName: 'uptime', module: uptimeCommand }, + ], +}); diff --git a/src/commands/Core/commands.ts b/src/commands/Core/commands.ts index 1646a7e8d4..ddb9fdc048 100644 --- a/src/commands/Core/commands.ts +++ b/src/commands/Core/commands.ts @@ -252,15 +252,15 @@ export default { const commandName = target!.toLowerCase(); if (isDisable) { - await disableCommand(client, interaction.guildId!, commandName); + const result = await disableCommand(client, interaction.guildId!, commandName); return InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed('Command Disabled', `\`/${commandName}\` is now disabled in this server.`)], + embeds: [successEmbed('Command Disabled', `\`/${result.commandName}\` is now disabled in this server.`)], }); } - await enableCommand(client, interaction.guildId!, commandName); + const result = await enableCommand(client, interaction.guildId!, commandName); return InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed('Command Enabled', `\`/${commandName}\` is now enabled in this server.`)], + embeds: [successEmbed('Command Enabled', `\`/${result.commandName}\` is now enabled in this server.`)], }); }, } satisfies Command; diff --git a/src/commands/Core/ping.ts b/src/commands/Core/modules/ping.ts similarity index 92% rename from src/commands/Core/ping.ts rename to src/commands/Core/modules/ping.ts index 8852ad3b54..d4caa20859 100644 --- a/src/commands/Core/ping.ts +++ b/src/commands/Core/modules/ping.ts @@ -1,13 +1,13 @@ import { SlashCommandBuilder, MessageFlags } from 'discord.js'; import type { ChatInputCommandInteraction, GuildTextBasedChannel } from 'discord.js'; -import type { Command } from '../../types/index.js'; -import type { InteractionExtras } from '../../utils/interactionHelper.js'; +import type { Command } from '../../../types/index.js'; +import type { InteractionExtras } from '../../../utils/interactionHelper.js'; /** Prefix invocations arrive as a mock interaction carrying the message timestamp (see utils/messageAdapter). */ type PingInteraction = ChatInputCommandInteraction & InteractionExtras & { _commandStartTime?: number }; -import { createEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { createEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() diff --git a/src/commands/Core/stats.ts b/src/commands/Core/modules/stats.ts similarity index 86% rename from src/commands/Core/stats.ts rename to src/commands/Core/modules/stats.ts index 90dbf51089..37579cdb90 100644 --- a/src/commands/Core/stats.ts +++ b/src/commands/Core/modules/stats.ts @@ -1,9 +1,9 @@ import { SlashCommandBuilder, version, MessageFlags } from 'discord.js'; -import type { Command } from '../../types/index.js'; -import { createEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; +import type { Command } from '../../../types/index.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("stats") diff --git a/src/commands/Core/support.ts b/src/commands/Core/modules/support.ts similarity index 85% rename from src/commands/Core/support.ts rename to src/commands/Core/modules/support.ts index b4cec17c3d..b599582c0f 100644 --- a/src/commands/Core/support.ts +++ b/src/commands/Core/modules/support.ts @@ -1,9 +1,9 @@ import { SlashCommandBuilder, ButtonBuilder, ButtonStyle, ActionRowBuilder, MessageFlags } from 'discord.js'; -import type { Command } from '../../types/index.js'; -import { createEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; +import type { Command } from '../../../types/index.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'; const SUPPORT_URL = "https://github.com/Robertwlt03/NexoBot/issues"; export default { data: new SlashCommandBuilder() diff --git a/src/commands/Core/uptime.ts b/src/commands/Core/modules/uptime.ts similarity index 85% rename from src/commands/Core/uptime.ts rename to src/commands/Core/modules/uptime.ts index 98e1de6868..c884b200a9 100644 --- a/src/commands/Core/uptime.ts +++ b/src/commands/Core/modules/uptime.ts @@ -1,9 +1,9 @@ import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import type { Command } from '../../types/index.js'; -import { createEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; +import type { Command } from '../../../types/index.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("uptime") diff --git a/src/commands/Economy/eco.ts b/src/commands/Economy/eco.ts new file mode 100644 index 0000000000..e88b2b842f --- /dev/null +++ b/src/commands/Economy/eco.ts @@ -0,0 +1,43 @@ +import { createRouterCommand } from '../../utils/commandRouter.js'; +import balanceCommand from './modules/balance.js'; +import begCommand from './modules/beg.js'; +import buyCommand from './modules/buy.js'; +import crimeCommand from './modules/crime.js'; +import dailyCommand from './modules/daily.js'; +import depositCommand from './modules/deposit.js'; +import eleaderboardCommand from './modules/eleaderboard.js'; +import fishCommand from './modules/fish.js'; +import gambleCommand from './modules/gamble.js'; +import inventoryCommand from './modules/inventory.js'; +import mineCommand from './modules/mine.js'; +import payCommand from './modules/pay.js'; +import robCommand from './modules/rob.js'; +import shopCommand from './modules/shop.js'; +import slutCommand from './modules/slut.js'; +import withdrawCommand from './modules/withdraw.js'; +import workCommand from './modules/work.js'; + +export default createRouterCommand({ + name: 'eco', + description: 'Economy: balance, work, shop and more', + category: 'Economy', + routes: [ + { legacyName: 'balance', module: balanceCommand }, + { legacyName: 'beg', module: begCommand }, + { legacyName: 'buy', module: buyCommand }, + { legacyName: 'crime', module: crimeCommand }, + { legacyName: 'daily', module: dailyCommand }, + { legacyName: 'deposit', module: depositCommand }, + { legacyName: 'eleaderboard', module: eleaderboardCommand }, + { legacyName: 'fish', module: fishCommand }, + { legacyName: 'gamble', module: gambleCommand }, + { legacyName: 'inventory', module: inventoryCommand }, + { legacyName: 'mine', module: mineCommand }, + { legacyName: 'pay', module: payCommand }, + { legacyName: 'rob', module: robCommand }, + { legacyName: 'shop', module: shopCommand }, + { legacyName: 'slut', module: slutCommand }, + { legacyName: 'withdraw', module: withdrawCommand }, + { legacyName: 'work', module: workCommand }, + ], +}); diff --git a/src/commands/Economy/economy.ts b/src/commands/Economy/economy.ts index 63c32d30fa..2c67984c86 100644 --- a/src/commands/Economy/economy.ts +++ b/src/commands/Economy/economy.ts @@ -1,33 +1,15 @@ -import type { Command } from '../../types/index.js'; -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import economyDashboard from './modules/economy_dashboard.js'; - -export default { - slashOnly: true, - data: new SlashCommandBuilder() - .setName('economy') - .setDescription('Economy management commands') - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .setDMPermission(false) - .addSubcommand(subcommand => - subcommand - .setName('dashboard') - .setDescription('Open the economy management dashboard') - ), - category: 'Economy', - - async execute(interaction, config, client) { - const deferred = await InteractionHelper.safeDefer(interaction, { - flags: MessageFlags.Ephemeral, - }); - if (!deferred) return; - - const subcommand = interaction.options.getSubcommand(); - - if (subcommand === 'dashboard') { - await economyDashboard.execute(interaction, config, client); - } - } -} satisfies Command; +import { PermissionFlagsBits } from 'discord.js'; +import { createRouterCommand } from '../../utils/commandRouter.js'; +import economyCommand from './modules/economy.js'; +import shopConfigCommand from './modules/shop-config.js'; + +export default createRouterCommand({ + name: 'economy', + description: 'Configure the economy system', + category: 'Economy', + defaultMemberPermissions: PermissionFlagsBits.ManageGuild, + routes: [ + { legacyName: 'economy', module: economyCommand, spread: true }, + { legacyName: 'shop-config', module: shopConfigCommand }, + ], +}); diff --git a/src/commands/Economy/balance.ts b/src/commands/Economy/modules/balance.ts similarity index 91% rename from src/commands/Economy/balance.ts rename to src/commands/Economy/modules/balance.ts index 58d5adb00b..622e24bd73 100644 --- a/src/commands/Economy/balance.ts +++ b/src/commands/Economy/modules/balance.ts @@ -1,10 +1,10 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, getMaxBankCapacity } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; +import { getEconomyData, getMaxBankCapacity } from '../../../utils/economy.js'; +import { withErrorHandling, createError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { logger } from '../../../utils/logger.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() diff --git a/src/commands/Economy/beg.ts b/src/commands/Economy/modules/beg.ts similarity index 89% rename from src/commands/Economy/beg.ts rename to src/commands/Economy/modules/beg.ts index 9a032a0219..3dd8588b91 100644 --- a/src/commands/Economy/beg.ts +++ b/src/commands/Economy/modules/beg.ts @@ -1,11 +1,11 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; -import { successEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import type { EconomyData } from '../../utils/economy.js'; -import { botConfig } from '../../config/bot.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { successEmbed, warningEmbed } from '../../../utils/embeds.js'; +import { getEconomyData, setEconomyData } from '../../../utils/economy.js'; +import type { EconomyData } from '../../../utils/economy.js'; +import { botConfig } from '../../../config/bot.js'; +import { withErrorHandling, createError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; const COOLDOWN = 30 * 60 * 1000; const MIN_WIN = Number(botConfig?.economy?.begMin) || 50; diff --git a/src/commands/Economy/buy.ts b/src/commands/Economy/modules/buy.ts similarity index 94% rename from src/commands/Economy/buy.ts rename to src/commands/Economy/modules/buy.ts index 39726f5529..3c784391ca 100644 --- a/src/commands/Economy/buy.ts +++ b/src/commands/Economy/modules/buy.ts @@ -1,11 +1,11 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder, MessageFlags, type GuildMember } 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/config/guildConfig.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.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/config/guildConfig.js'; +import { withErrorHandling, createError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; const SHOP_ITEMS = shopItems; diff --git a/src/commands/Economy/crime.ts b/src/commands/Economy/modules/crime.ts similarity index 93% rename from src/commands/Economy/crime.ts rename to src/commands/Economy/modules/crime.ts index 1442a923b1..9102427f8a 100644 --- a/src/commands/Economy/crime.ts +++ b/src/commands/Economy/modules/crime.ts @@ -1,10 +1,10 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import type { EconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; +import { getEconomyData, setEconomyData } from '../../../utils/economy.js'; +import type { EconomyData } from '../../../utils/economy.js'; +import { withErrorHandling, createError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; const CRIME_COOLDOWN = 60 * 60 * 1000; const JAIL_TIME = 2 * 60 * 60 * 1000; diff --git a/src/commands/Economy/daily.ts b/src/commands/Economy/modules/daily.ts similarity index 88% rename from src/commands/Economy/daily.ts rename to src/commands/Economy/modules/daily.ts index d99d30bbb3..8a55270cf2 100644 --- a/src/commands/Economy/daily.ts +++ b/src/commands/Economy/modules/daily.ts @@ -1,13 +1,13 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder, type GuildMember } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.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'; +import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; +import { getEconomyData, setEconomyData } from '../../../utils/economy.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 = botConfig.economy?.dailyAmount ?? 100; diff --git a/src/commands/Economy/deposit.ts b/src/commands/Economy/modules/deposit.ts similarity index 95% rename from src/commands/Economy/deposit.ts rename to src/commands/Economy/modules/deposit.ts index 9b1f1574bf..b0eef2e886 100644 --- a/src/commands/Economy/deposit.ts +++ b/src/commands/Economy/modules/deposit.ts @@ -1,9 +1,9 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { successEmbed, buildUserErrorEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData, getMaxBankCapacity } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { successEmbed, buildUserErrorEmbed } from '../../../utils/embeds.js'; +import { getEconomyData, setEconomyData, getMaxBankCapacity } from '../../../utils/economy.js'; +import { withErrorHandling, createError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() diff --git a/src/commands/Economy/modules/economy.ts b/src/commands/Economy/modules/economy.ts new file mode 100644 index 0000000000..88a8abee7c --- /dev/null +++ b/src/commands/Economy/modules/economy.ts @@ -0,0 +1,33 @@ +import type { Command } from '../../../types/index.js'; +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; +import { logger } from '../../../utils/logger.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import economyDashboard from './economy_dashboard.js'; + +export default { + slashOnly: true, + data: new SlashCommandBuilder() + .setName('economy') + .setDescription('Economy management commands') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .setDMPermission(false) + .addSubcommand(subcommand => + subcommand + .setName('dashboard') + .setDescription('Open the economy management dashboard') + ), + category: 'Economy', + + async execute(interaction, config, client) { + const deferred = await InteractionHelper.safeDefer(interaction, { + flags: MessageFlags.Ephemeral, + }); + if (!deferred) return; + + const subcommand = interaction.options.getSubcommand(); + + if (subcommand === 'dashboard') { + await economyDashboard.execute(interaction, config, client); + } + } +} satisfies Command; diff --git a/src/commands/Economy/eleaderboard.ts b/src/commands/Economy/modules/eleaderboard.ts similarity index 88% rename from src/commands/Economy/eleaderboard.ts rename to src/commands/Economy/modules/eleaderboard.ts index a6ab3238ed..aaebc34805 100644 --- a/src/commands/Economy/eleaderboard.ts +++ b/src/commands/Economy/modules/eleaderboard.ts @@ -1,11 +1,11 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed } from '../../utils/embeds.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getEconomyPrefix } from '../../utils/database.js'; -import type { EconomyData } from '../../utils/economy.js'; +import { createEmbed } from '../../../utils/embeds.js'; +import { withErrorHandling, createError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { logger } from '../../../utils/logger.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { getEconomyPrefix } from '../../../utils/database.js'; +import type { EconomyData } from '../../../utils/economy.js'; export default { data: new SlashCommandBuilder() diff --git a/src/commands/Economy/fish.ts b/src/commands/Economy/modules/fish.ts similarity index 93% rename from src/commands/Economy/fish.ts rename to src/commands/Economy/modules/fish.ts index 2ef3bd930e..320ad6b960 100644 --- a/src/commands/Economy/fish.ts +++ b/src/commands/Economy/modules/fish.ts @@ -1,10 +1,10 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import type { EconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; +import { getEconomyData, setEconomyData } from '../../../utils/economy.js'; +import type { EconomyData } from '../../../utils/economy.js'; +import { withErrorHandling, createError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; const FISH_COOLDOWN = 45 * 60 * 1000; const BASE_MIN_REWARD = 300; diff --git a/src/commands/Economy/gamble.ts b/src/commands/Economy/modules/gamble.ts similarity index 94% rename from src/commands/Economy/gamble.ts rename to src/commands/Economy/modules/gamble.ts index fcd48e29a7..c19523a356 100644 --- a/src/commands/Economy/gamble.ts +++ b/src/commands/Economy/modules/gamble.ts @@ -1,10 +1,10 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import type { EconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; +import { getEconomyData, setEconomyData } from '../../../utils/economy.js'; +import type { EconomyData } from '../../../utils/economy.js'; +import { withErrorHandling, createError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; const BASE_WIN_CHANCE = 0.4; const CLOVER_WIN_BONUS = 0.1; diff --git a/src/commands/Economy/inventory.ts b/src/commands/Economy/modules/inventory.ts similarity index 87% rename from src/commands/Economy/inventory.ts rename to src/commands/Economy/modules/inventory.ts index 5dc2e50bf5..e755c2fee5 100644 --- a/src/commands/Economy/inventory.ts +++ b/src/commands/Economy/modules/inventory.ts @@ -1,11 +1,11 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { shopItems } from '../../config/shop/items.js'; -import { getEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; +import { shopItems } from '../../../config/shop/items.js'; +import { getEconomyData } from '../../../utils/economy.js'; +import { withErrorHandling, createError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { logger } from '../../../utils/logger.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; const SHOP_ITEMS = shopItems; diff --git a/src/commands/Economy/mine.ts b/src/commands/Economy/modules/mine.ts similarity index 91% rename from src/commands/Economy/mine.ts rename to src/commands/Economy/modules/mine.ts index 415e4a9ce4..372bf7067e 100644 --- a/src/commands/Economy/mine.ts +++ b/src/commands/Economy/modules/mine.ts @@ -1,10 +1,10 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import type { EconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; +import { getEconomyData, setEconomyData } from '../../../utils/economy.js'; +import type { EconomyData } from '../../../utils/economy.js'; +import { withErrorHandling, createError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; const MINE_COOLDOWN = 60 * 60 * 1000; const BASE_MIN_REWARD = 400; diff --git a/src/commands/Economy/pay.ts b/src/commands/Economy/modules/pay.ts similarity index 93% rename from src/commands/Economy/pay.ts rename to src/commands/Economy/modules/pay.ts index 97053e94f6..1027c6adbf 100644 --- a/src/commands/Economy/pay.ts +++ b/src/commands/Economy/modules/pay.ts @@ -1,11 +1,11 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, addMoney, removeMoney, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import EconomyService from '../../services/economyService.js'; +import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; +import { getEconomyData, addMoney, removeMoney, setEconomyData } from '../../../utils/economy.js'; +import { withErrorHandling, createError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { logger } from '../../../utils/logger.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import EconomyService from '../../../services/economyService.js'; export default { data: new SlashCommandBuilder() diff --git a/src/commands/Economy/rob.ts b/src/commands/Economy/modules/rob.ts similarity index 95% rename from src/commands/Economy/rob.ts rename to src/commands/Economy/modules/rob.ts index d959452586..d05983cdc9 100644 --- a/src/commands/Economy/rob.ts +++ b/src/commands/Economy/modules/rob.ts @@ -1,10 +1,10 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; -import { successEmbed, warningEmbed, buildUserErrorEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { BotConfig } from '../../config/bot.js'; +import { successEmbed, warningEmbed, buildUserErrorEmbed } from '../../../utils/embeds.js'; +import { getEconomyData, setEconomyData } from '../../../utils/economy.js'; +import { withErrorHandling, createError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { BotConfig } from '../../../config/bot.js'; const ROB_COOLDOWN = BotConfig.economy?.cooldowns?.rob ?? 4 * 60 * 60 * 1000; const BASE_ROB_SUCCESS_CHANCE = BotConfig.economy?.robSuccessRate ?? 0.4; diff --git a/src/commands/Economy/shop-config.ts b/src/commands/Economy/modules/shop-config.ts similarity index 89% rename from src/commands/Economy/shop-config.ts rename to src/commands/Economy/modules/shop-config.ts index a72dcb8666..c2f06a4995 100644 --- a/src/commands/Economy/shop-config.ts +++ b/src/commands/Economy/modules/shop-config.ts @@ -1,6 +1,6 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; -import shopConfigSetrole from './modules/shop_config_setrole.js'; +import shopConfigSetrole from './shop_config_setrole.js'; export default { slashOnly: true, diff --git a/src/commands/Economy/shop.ts b/src/commands/Economy/modules/shop.ts similarity index 76% rename from src/commands/Economy/shop.ts rename to src/commands/Economy/modules/shop.ts index 7abaec3124..ba5311cde9 100644 --- a/src/commands/Economy/shop.ts +++ b/src/commands/Economy/modules/shop.ts @@ -1,6 +1,6 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; -import shopBrowse from './modules/shop_browse.js'; +import shopBrowse from './shop_browse.js'; export default { slashOnly: true, diff --git a/src/commands/Economy/modules/shop_browse.ts b/src/commands/Economy/modules/shop_browse.ts index 7108ba7c31..0c350891ef 100644 --- a/src/commands/Economy/modules/shop_browse.ts +++ b/src/commands/Economy/modules/shop_browse.ts @@ -19,7 +19,7 @@ export default { const embed = new EmbedBuilder() .setTitle('Store') .setColor(getColor('primary')) - .setDescription('Use `/buy item_id: quantity:` to purchase an item.'); + .setDescription('Use `/eco buy item_id: quantity:` to purchase an item.'); pageItems.forEach(item => { embed.addFields({ name: `${item.name} (${item.id})`, @@ -62,7 +62,7 @@ export default { collector.on('collect', async (buttonInteraction) => { if (buttonInteraction.user.id !== interaction.user.id) { - await buttonInteraction.reply({ content: '❌ You cannot use these buttons. Run `/shop` to get your own shop view.', flags: 64 }); + await buttonInteraction.reply({ content: '❌ You cannot use these buttons. Run `/eco shop` to get your own shop view.', flags: 64 }); return; } const { customId } = buttonInteraction; diff --git a/src/commands/Economy/slut.ts b/src/commands/Economy/modules/slut.ts similarity index 94% rename from src/commands/Economy/slut.ts rename to src/commands/Economy/modules/slut.ts index cd9664a624..cffbddcb16 100644 --- a/src/commands/Economy/slut.ts +++ b/src/commands/Economy/modules/slut.ts @@ -1,11 +1,11 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import type { EconomyData } 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 { createEmbed } from '../../../utils/embeds.js'; +import { getEconomyData, setEconomyData } from '../../../utils/economy.js'; +import type { EconomyData } from '../../../utils/economy.js'; +import { withErrorHandling, createError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { logger } from '../../../utils/logger.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; const SLUT_COOLDOWN = 45 * 60 * 1000; diff --git a/src/commands/Economy/withdraw.ts b/src/commands/Economy/modules/withdraw.ts similarity index 92% rename from src/commands/Economy/withdraw.ts rename to src/commands/Economy/modules/withdraw.ts index 1ddac96e3b..fea4f3f042 100644 --- a/src/commands/Economy/withdraw.ts +++ b/src/commands/Economy/modules/withdraw.ts @@ -1,10 +1,10 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData, getMaxBankCapacity } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; +import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; +import { getEconomyData, setEconomyData, getMaxBankCapacity } from '../../../utils/economy.js'; +import { withErrorHandling, createError, ErrorTypes } from '../../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName('withdraw') diff --git a/src/commands/Economy/work.ts b/src/commands/Economy/modules/work.ts similarity index 92% rename from src/commands/Economy/work.ts rename to src/commands/Economy/modules/work.ts index 844e38072b..d4e7fc28d0 100644 --- a/src/commands/Economy/work.ts +++ b/src/commands/Economy/modules/work.ts @@ -1,11 +1,11 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { botConfig } from '../../config/bot.js'; +import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; +import { getEconomyData, setEconomyData } from '../../../utils/economy.js'; +import { withErrorHandling, createError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { logger } from '../../../utils/logger.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { botConfig } from '../../../config/bot.js'; const WORK_COOLDOWN = botConfig.economy?.cooldowns?.work ?? 30 * 60 * 1000; const MIN_WORK_AMOUNT = botConfig.economy?.workMin ?? 10; diff --git a/src/commands/Fun/fun.ts b/src/commands/Fun/fun.ts new file mode 100644 index 0000000000..d625aa8355 --- /dev/null +++ b/src/commands/Fun/fun.ts @@ -0,0 +1,15 @@ +import { createRouterCommand } from '../../utils/commandRouter.js'; +import fightCommand from './modules/fight.js'; +import flipCommand from './modules/flip.js'; +import rollCommand from './modules/roll.js'; + +export default createRouterCommand({ + name: 'fun', + description: 'Fun commands and mini games', + category: 'Fun', + routes: [ + { legacyName: 'fight', module: fightCommand }, + { legacyName: 'flip', module: flipCommand }, + { legacyName: 'roll', module: rollCommand }, + ], +}); diff --git a/src/commands/Fun/fight.ts b/src/commands/Fun/modules/fight.ts similarity index 91% rename from src/commands/Fun/fight.ts rename to src/commands/Fun/modules/fight.ts index eae6690033..0039d23ff4 100644 --- a/src/commands/Fun/fight.ts +++ b/src/commands/Fun/modules/fight.ts @@ -1,9 +1,9 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; -import { successEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; +import { successEmbed, warningEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; const rand = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min; const EMBED_DESCRIPTION_LIMIT = 4096; diff --git a/src/commands/Fun/flip.ts b/src/commands/Fun/modules/flip.ts similarity index 71% rename from src/commands/Fun/flip.ts rename to src/commands/Fun/modules/flip.ts index 6fc5530c84..53eff971b3 100644 --- a/src/commands/Fun/flip.ts +++ b/src/commands/Fun/modules/flip.ts @@ -1,10 +1,10 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { NexoBotError, ErrorTypes } from '../../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("flip") diff --git a/src/commands/Fun/roll.ts b/src/commands/Fun/modules/roll.ts similarity index 88% rename from src/commands/Fun/roll.ts rename to src/commands/Fun/modules/roll.ts index f755709a08..6ea2858935 100644 --- a/src/commands/Fun/roll.ts +++ b/src/commands/Fun/modules/roll.ts @@ -1,10 +1,10 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder } from 'discord.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { successEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { NexoBotError, ErrorTypes } from '../../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("roll") diff --git a/src/commands/Giveaway/giveaway.ts b/src/commands/Giveaway/giveaway.ts new file mode 100644 index 0000000000..5f18d6f178 --- /dev/null +++ b/src/commands/Giveaway/giveaway.ts @@ -0,0 +1,19 @@ +import { PermissionFlagsBits } from 'discord.js'; +import { createRouterCommand } from '../../utils/commandRouter.js'; +import gcreateCommand from './modules/gcreate.js'; +import gdeleteCommand from './modules/gdelete.js'; +import gendCommand from './modules/gend.js'; +import grerollCommand from './modules/greroll.js'; + +export default createRouterCommand({ + name: 'giveaway', + description: 'Create and manage giveaways', + category: 'Giveaway', + defaultMemberPermissions: PermissionFlagsBits.ManageGuild, + routes: [ + { legacyName: 'gcreate', module: gcreateCommand }, + { legacyName: 'gdelete', module: gdeleteCommand }, + { legacyName: 'gend', module: gendCommand }, + { legacyName: 'greroll', module: grerollCommand }, + ], +}); diff --git a/src/commands/Giveaway/gcreate.ts b/src/commands/Giveaway/modules/gcreate.ts similarity index 91% rename from src/commands/Giveaway/gcreate.ts rename to src/commands/Giveaway/modules/gcreate.ts index 09a3cc3e10..24c7114a07 100644 --- a/src/commands/Giveaway/gcreate.ts +++ b/src/commands/Giveaway/modules/gcreate.ts @@ -1,21 +1,21 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, MessageFlags, type GuildMember, type GuildBasedChannel } from 'discord.js'; -import { errorEmbed, successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { saveGiveaway } from '../../utils/giveaways.js'; -import type { Giveaway } from '../../utils/giveaways.js'; +import { errorEmbed, successEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { NexoBotError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { saveGiveaway } from '../../../utils/giveaways.js'; +import type { Giveaway } from '../../../utils/giveaways.js'; import { parseDuration, validatePrize, validateWinnerCount, createGiveawayEmbed, createGiveawayButtons -} from '../../services/giveawayService.js'; -import { logEvent, EVENT_TYPES } from '../../services/loggingService.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; +} from '../../../services/giveawayService.js'; +import { logEvent, EVENT_TYPES } from '../../../services/loggingService.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { botConfig } from '../../config/bot.js'; +import { botConfig } from '../../../config/bot.js'; const GIVEAWAY_MIN_WINNERS = botConfig.giveaways?.minimumWinners ?? 1; const GIVEAWAY_MAX_WINNERS = botConfig.giveaways?.maximumWinners ?? 10; diff --git a/src/commands/Giveaway/gdelete.ts b/src/commands/Giveaway/modules/gdelete.ts similarity index 93% rename from src/commands/Giveaway/gdelete.ts rename to src/commands/Giveaway/modules/gdelete.ts index cb6503f710..c104bbd5dd 100644 --- a/src/commands/Giveaway/gdelete.ts +++ b/src/commands/Giveaway/modules/gdelete.ts @@ -1,12 +1,12 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, type GuildMember, type Channel } from 'discord.js'; -import { errorEmbed, successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { getGuildGiveaways, deleteGiveaway } from '../../utils/giveaways.js'; -import { logEvent, EVENT_TYPES } from '../../services/loggingService.js'; +import { errorEmbed, successEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { NexoBotError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { getGuildGiveaways, deleteGiveaway } from '../../../utils/giveaways.js'; +import { logEvent, EVENT_TYPES } from '../../../services/loggingService.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("gdelete") diff --git a/src/commands/Giveaway/gend.ts b/src/commands/Giveaway/modules/gend.ts similarity index 93% rename from src/commands/Giveaway/gend.ts rename to src/commands/Giveaway/modules/gend.ts index 79c56fdb48..34a4d4cbb2 100644 --- a/src/commands/Giveaway/gend.ts +++ b/src/commands/Giveaway/modules/gend.ts @@ -1,16 +1,16 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, type GuildMember, type SendableChannels } from 'discord.js'; -import { errorEmbed, successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { getGuildGiveaways, saveGiveaway } from '../../utils/giveaways.js'; +import { errorEmbed, successEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { NexoBotError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { getGuildGiveaways, saveGiveaway } from '../../../utils/giveaways.js'; import { endGiveaway as endGiveawayService, createGiveawayEmbed, createGiveawayButtons -} from '../../services/giveawayService.js'; -import { logEvent, EVENT_TYPES } from '../../services/loggingService.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; +} from '../../../services/giveawayService.js'; +import { logEvent, EVENT_TYPES } from '../../../services/loggingService.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() diff --git a/src/commands/Giveaway/greroll.ts b/src/commands/Giveaway/modules/greroll.ts similarity index 94% rename from src/commands/Giveaway/greroll.ts rename to src/commands/Giveaway/modules/greroll.ts index ad332c9900..78d738a242 100644 --- a/src/commands/Giveaway/greroll.ts +++ b/src/commands/Giveaway/modules/greroll.ts @@ -1,17 +1,17 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, type GuildMember, type SendableChannels } from 'discord.js'; -import { errorEmbed, successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { getGuildGiveaways, saveGiveaway } from '../../utils/giveaways.js'; -import type { Giveaway } from '../../utils/giveaways.js'; +import { errorEmbed, successEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { NexoBotError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { getGuildGiveaways, saveGiveaway } from '../../../utils/giveaways.js'; +import type { Giveaway } from '../../../utils/giveaways.js'; import { selectWinners, createGiveawayEmbed, createGiveawayButtons -} from '../../services/giveawayService.js'; -import { logEvent, EVENT_TYPES } from '../../services/loggingService.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; +} from '../../../services/giveawayService.js'; +import { logEvent, EVENT_TYPES } from '../../../services/loggingService.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() @@ -77,7 +77,7 @@ export default { throw new NexoBotError( `Giveaway still active: ${messageId}`, ErrorTypes.VALIDATION, - "This giveaway is still active. Please use `/gend` to end it first.", + "This giveaway is still active. Please use `/giveaway end` to end it first.", { messageId, status: 'active' } ); } diff --git a/src/commands/Leveling/level.ts b/src/commands/Leveling/level.ts index 48c73603fb..89cee7f6dd 100644 --- a/src/commands/Leveling/level.ts +++ b/src/commands/Leveling/level.ts @@ -1,152 +1,19 @@ -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, MessageFlags } from 'discord.js'; -import type { TextChannel } from 'discord.js'; -import { createEmbed } from '../../utils/embeds.js'; -import { getLevelingConfig, saveLevelingConfig } from '../../services/leveling/leveling.js'; -import { botHasPermission } from '../../utils/permissionGuard.js'; -import { NexoBotError, 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'; -import type { Command } from '../../types/index.js'; - -export default { - data: new SlashCommandBuilder() - .setName('level') - .setDescription('Manage the leveling system') - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .setDMPermission(false) - .addSubcommand((subcommand) => - subcommand - .setName('setup') - .setDescription('Set up the leveling system — this also enables it') - .addChannelOption((option) => - option - .setName('channel') - .setDescription('Channel to send level-up notifications in') - .addChannelTypes(ChannelType.GuildText) - .setRequired(true), - ) - .addIntegerOption((option) => - option - .setName('xp_min') - .setDescription('Minimum XP awarded per message (default: 15)') - .setMinValue(1) - .setMaxValue(500) - .setRequired(false), - ) - .addIntegerOption((option) => - option - .setName('xp_max') - .setDescription('Maximum XP awarded per message (default: 25)') - .setMinValue(1) - .setMaxValue(500) - .setRequired(false), - ) - .addStringOption((option) => - option - .setName('message') - .setDescription( - 'Level-up message. Use {user} and {level} as placeholders (default provided)', - ) - .setMaxLength(500) - .setRequired(false), - ) - .addIntegerOption((option) => - option - .setName('xp_cooldown') - .setDescription('Seconds between XP grants per user (default: 60)') - .setMinValue(0) - .setMaxValue(3600) - .setRequired(false), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName('dashboard') - .setDescription('Open the interactive leveling configuration dashboard'), - ), - category: 'Leveling', - - async execute(interaction, config, client) { - 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.' }); - } - - const subcommand = interaction.options.getSubcommand(); - - if (subcommand === 'dashboard') { - return levelDashboard.execute(interaction, config, client); - } - - if (subcommand === 'setup') { - const channel = interaction.options.getChannel('channel') as TextChannel; - 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 (!botHasPermission(channel, ['SendMessages', 'EmbedLinks'])) { - throw new NexoBotError( - '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!); - - 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, - }; - - 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, - }); - - 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', - }), - ], - }); - } - }, -} satisfies Command; +import { PermissionFlagsBits } from 'discord.js'; +import { createRouterCommand } from '../../utils/commandRouter.js'; +import levelCommand from './modules/level.js'; +import leveladdCommand from './modules/leveladd.js'; +import levelremoveCommand from './modules/levelremove.js'; +import levelsetCommand from './modules/levelset.js'; + +export default createRouterCommand({ + name: 'level', + description: 'Configure leveling and manage member levels', + category: 'Leveling', + defaultMemberPermissions: PermissionFlagsBits.ManageGuild, + routes: [ + { legacyName: 'level', module: levelCommand, spread: true }, + { legacyName: 'leveladd', module: leveladdCommand }, + { legacyName: 'levelremove', module: levelremoveCommand }, + { legacyName: 'levelset', module: levelsetCommand }, + ], +}); diff --git a/src/commands/Leveling/leaderboard.ts b/src/commands/Leveling/modules/leaderboard.ts similarity index 89% rename from src/commands/Leveling/leaderboard.ts rename to src/commands/Leveling/modules/leaderboard.ts index e20308b10d..623d57200a 100644 --- a/src/commands/Leveling/leaderboard.ts +++ b/src/commands/Leveling/modules/leaderboard.ts @@ -1,10 +1,10 @@ import { SlashCommandBuilder, EmbedBuilder, MessageFlags } from 'discord.js'; -import { logger } from '../../utils/logger.js'; -import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { getLeaderboard, getLevelingConfig, getXpForLevel } from '../../services/leveling/leveling.js'; +import { logger } from '../../../utils/logger.js'; +import { NexoBotError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { getLeaderboard, getLevelingConfig, getXpForLevel } from '../../../services/leveling/leveling.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import type { Command } from '../../types/index.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('leaderboard') diff --git a/src/commands/Leveling/modules/level.ts b/src/commands/Leveling/modules/level.ts new file mode 100644 index 0000000000..6845fda10c --- /dev/null +++ b/src/commands/Leveling/modules/level.ts @@ -0,0 +1,152 @@ +import { getColor } from '../../../config/bot.js'; +import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, MessageFlags } from 'discord.js'; +import type { TextChannel } from 'discord.js'; +import { createEmbed } from '../../../utils/embeds.js'; +import { getLevelingConfig, saveLevelingConfig } from '../../../services/leveling/leveling.js'; +import { botHasPermission } from '../../../utils/permissionGuard.js'; +import { NexoBotError, ErrorTypes, replyUserError } from '../../../utils/errorHandler.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { logger } from '../../../utils/logger.js'; +import levelDashboard from './level_dashboard.js'; +import type { Command } from '../../../types/index.js'; + +export default { + data: new SlashCommandBuilder() + .setName('level') + .setDescription('Manage the leveling system') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .setDMPermission(false) + .addSubcommand((subcommand) => + subcommand + .setName('setup') + .setDescription('Set up the leveling system — this also enables it') + .addChannelOption((option) => + option + .setName('channel') + .setDescription('Channel to send level-up notifications in') + .addChannelTypes(ChannelType.GuildText) + .setRequired(true), + ) + .addIntegerOption((option) => + option + .setName('xp_min') + .setDescription('Minimum XP awarded per message (default: 15)') + .setMinValue(1) + .setMaxValue(500) + .setRequired(false), + ) + .addIntegerOption((option) => + option + .setName('xp_max') + .setDescription('Maximum XP awarded per message (default: 25)') + .setMinValue(1) + .setMaxValue(500) + .setRequired(false), + ) + .addStringOption((option) => + option + .setName('message') + .setDescription( + 'Level-up message. Use {user} and {level} as placeholders (default provided)', + ) + .setMaxLength(500) + .setRequired(false), + ) + .addIntegerOption((option) => + option + .setName('xp_cooldown') + .setDescription('Seconds between XP grants per user (default: 60)') + .setMinValue(0) + .setMaxValue(3600) + .setRequired(false), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('dashboard') + .setDescription('Open the interactive leveling configuration dashboard'), + ), + category: 'Leveling', + + async execute(interaction, config, client) { + 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.' }); + } + + const subcommand = interaction.options.getSubcommand(); + + if (subcommand === 'dashboard') { + return levelDashboard.execute(interaction, config, client); + } + + if (subcommand === 'setup') { + const channel = interaction.options.getChannel('channel') as TextChannel; + 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 (!botHasPermission(channel, ['SendMessages', 'EmbedLinks'])) { + throw new NexoBotError( + '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!); + + 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, + }; + + 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, + }); + + 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', + }), + ], + }); + } + }, +} satisfies Command; diff --git a/src/commands/Leveling/leveladd.ts b/src/commands/Leveling/modules/leveladd.ts similarity index 83% rename from src/commands/Leveling/leveladd.ts rename to src/commands/Leveling/modules/leveladd.ts index 372262dd17..381e5e1d6d 100644 --- a/src/commands/Leveling/leveladd.ts +++ b/src/commands/Leveling/modules/leveladd.ts @@ -1,12 +1,12 @@ import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, MessageFlags } from 'discord.js'; -import { logger } from '../../utils/logger.js'; -import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { checkUserPermissions } from '../../utils/permissionGuard.js'; -import { addLevels, getLevelingConfig } from '../../services/leveling/leveling.js'; -import { createEmbed } from '../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { NexoBotError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { checkUserPermissions } from '../../../utils/permissionGuard.js'; +import { addLevels, getLevelingConfig } from '../../../services/leveling/leveling.js'; +import { createEmbed } from '../../../utils/embeds.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import type { Command } from '../../types/index.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('leveladd') diff --git a/src/commands/Leveling/levelremove.ts b/src/commands/Leveling/modules/levelremove.ts similarity index 87% rename from src/commands/Leveling/levelremove.ts rename to src/commands/Leveling/modules/levelremove.ts index aff384e4df..5483ee17d0 100644 --- a/src/commands/Leveling/levelremove.ts +++ b/src/commands/Leveling/modules/levelremove.ts @@ -1,12 +1,12 @@ import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, MessageFlags } from 'discord.js'; -import { logger } from '../../utils/logger.js'; -import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { checkUserPermissions } from '../../utils/permissionGuard.js'; -import { removeLevels, getUserLevelData, getLevelingConfig } from '../../services/leveling/leveling.js'; -import { createEmbed } from '../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { NexoBotError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { checkUserPermissions } from '../../../utils/permissionGuard.js'; +import { removeLevels, getUserLevelData, getLevelingConfig } from '../../../services/leveling/leveling.js'; +import { createEmbed } from '../../../utils/embeds.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import type { Command } from '../../types/index.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('levelremove') diff --git a/src/commands/Leveling/levelset.ts b/src/commands/Leveling/modules/levelset.ts similarity index 83% rename from src/commands/Leveling/levelset.ts rename to src/commands/Leveling/modules/levelset.ts index f1bde1af5a..f4fc31d7c7 100644 --- a/src/commands/Leveling/levelset.ts +++ b/src/commands/Leveling/modules/levelset.ts @@ -1,12 +1,12 @@ import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, MessageFlags } from 'discord.js'; -import { logger } from '../../utils/logger.js'; -import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { checkUserPermissions } from '../../utils/permissionGuard.js'; -import { setUserLevel, getLevelingConfig } from '../../services/leveling/leveling.js'; -import { createEmbed } from '../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { NexoBotError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { checkUserPermissions } from '../../../utils/permissionGuard.js'; +import { setUserLevel, getLevelingConfig } from '../../../services/leveling/leveling.js'; +import { createEmbed } from '../../../utils/embeds.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import type { Command } from '../../types/index.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('levelset') diff --git a/src/commands/Leveling/modules/rank.ts b/src/commands/Leveling/modules/rank.ts new file mode 100644 index 0000000000..6c435d54a7 --- /dev/null +++ b/src/commands/Leveling/modules/rank.ts @@ -0,0 +1,100 @@ +import { SlashCommandBuilder, EmbedBuilder, MessageFlags } from 'discord.js'; +import { logger } from '../../../utils/logger.js'; +import { NexoBotError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { getUserLevelData, getLevelingConfig, getXpForLevel } from '../../../services/leveling/leveling.js'; + +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import type { Command } from '../../../types/index.js'; +export default { + data: new SlashCommandBuilder() + .setName('rank') + .setDescription("Check your or another user's rank and level") + .addUserOption((option) => + option + .setName('user') + .setDescription('The user to check the rank of') + .setRequired(false) + ) + .setDMPermission(false), + category: 'Leveling', + + async execute(interaction, config, client) { + 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 targetUser = interaction.options.getUser('user') || interaction.user; + const member = await interaction.guild!.members + .fetch(targetUser.id) + .catch(() => null); + + if (!member) { + throw new NexoBotError( + `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 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 embed = new EmbedBuilder() + .setTitle(`${member.displayName}'s Rank`) + .setThumbnail(member.displayAvatarURL()) + .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}`); + } +} satisfies Command; + +function createProgressBar(percentage: number, length = 10): string { + if (percentage < 0 || percentage > 100) { + percentage = Math.max(0, Math.min(100, percentage)); + } + const filled = Math.round((percentage / 100) * length); + return '█'.repeat(filled) + '░'.repeat(length - filled); +} \ No newline at end of file diff --git a/src/commands/Leveling/rank.ts b/src/commands/Leveling/rank.ts index 67e997462e..9cff97ddc4 100644 --- a/src/commands/Leveling/rank.ts +++ b/src/commands/Leveling/rank.ts @@ -1,100 +1,13 @@ -import { SlashCommandBuilder, EmbedBuilder, MessageFlags } from 'discord.js'; -import { logger } from '../../utils/logger.js'; -import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { getUserLevelData, getLevelingConfig, getXpForLevel } from '../../services/leveling/leveling.js'; +import { createRouterCommand } from '../../utils/commandRouter.js'; +import rankCommand from './modules/rank.js'; +import leaderboardCommand from './modules/leaderboard.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import type { Command } from '../../types/index.js'; -export default { - data: new SlashCommandBuilder() - .setName('rank') - .setDescription("Check your or another user's rank and level") - .addUserOption((option) => - option - .setName('user') - .setDescription('The user to check the rank of') - .setRequired(false) - ) - .setDMPermission(false), +export default createRouterCommand({ + name: 'rank', + description: 'Show ranks and the level leaderboard', category: 'Leveling', - - async execute(interaction, config, client) { - 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 targetUser = interaction.options.getUser('user') || interaction.user; - const member = await interaction.guild!.members - .fetch(targetUser.id) - .catch(() => null); - - if (!member) { - throw new NexoBotError( - `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 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 embed = new EmbedBuilder() - .setTitle(`${member.displayName}'s Rank`) - .setThumbnail(member.displayAvatarURL()) - .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}`); - } -} satisfies Command; - -function createProgressBar(percentage: number, length = 10): string { - if (percentage < 0 || percentage > 100) { - percentage = Math.max(0, Math.min(100, percentage)); - } - const filled = Math.round((percentage / 100) * length); - return '█'.repeat(filled) + '░'.repeat(length - filled); -} \ No newline at end of file + routes: [ + { legacyName: 'rank', module: rankCommand }, + { legacyName: 'leaderboard', module: leaderboardCommand }, + ], +}); diff --git a/src/commands/Moderation/ban.ts b/src/commands/Moderation/ban.ts index a099ec399d..9537ca846d 100644 --- a/src/commands/Moderation/ban.ts +++ b/src/commands/Moderation/ban.ts @@ -1,68 +1,17 @@ -import type { Command } from '../../types/index.js'; -import { SlashCommandBuilder, PermissionFlagsBits, type GuildMember } from 'discord.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { ModerationService } from '../../services/moderation/moderationService.js'; -import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { PermissionFlagsBits } from 'discord.js'; +import { createRouterCommand } from '../../utils/commandRouter.js'; +import banCommand from './modules/ban.js'; +import unbanCommand from './modules/unban.js'; +import massbanCommand from './modules/massban.js'; -export default { - data: new SlashCommandBuilder() - .setName("ban") - .setDescription("Ban a user from the server") - .addUserOption((option) => - option - .setName("target") - .setDescription("The user to ban") - .setRequired(true), - ) - .addStringOption((option) => - option.setName("reason").setDescription("Reason for the ban"), - ) - .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers), - category: "moderation", - - async execute(interaction, config, client) { - const user = interaction.options.getUser("target"); - const reason = interaction.options.getString("reason") || "No reason provided"; - - if (!user) { - throw new NexoBotError( - 'Missing target user', - ErrorTypes.USER_INPUT, - 'You must specify a user to ban.', - { subtype: 'invalid_user' }, - ); - } - - if (user.id === interaction.user.id) { - throw new NexoBotError( - 'Cannot ban self', - ErrorTypes.VALIDATION, - 'You cannot ban yourself.', - ); - } - if (user.id === client.user.id) { - throw new NexoBotError( - 'Cannot ban bot', - ErrorTypes.VALIDATION, - 'You cannot ban the bot.', - ); - } - - const result = await ModerationService.banUser({ - guild: interaction.guild!, - user, - moderator: interaction.member as GuildMember, - reason, - }); - - await InteractionHelper.universalReply(interaction, { - embeds: [ - successEmbed( - `🚫 **Banned** ${user.tag}`, - `**Reason:** ${reason}\n**Case ID:** #${result.caseId}`, - ), - ], - }); - }, -} satisfies Command; +export default createRouterCommand({ + name: 'ban', + description: 'Ban, unban and mass ban members', + category: 'Moderation', + defaultMemberPermissions: PermissionFlagsBits.BanMembers, + routes: [ + { legacyName: 'ban', module: banCommand }, + { legacyName: 'unban', module: unbanCommand }, + { legacyName: 'massban', module: massbanCommand }, + ], +}); diff --git a/src/commands/Moderation/channel.ts b/src/commands/Moderation/channel.ts new file mode 100644 index 0000000000..eaddb20ce5 --- /dev/null +++ b/src/commands/Moderation/channel.ts @@ -0,0 +1,15 @@ +import { PermissionFlagsBits } from 'discord.js'; +import { createRouterCommand } from '../../utils/commandRouter.js'; +import lockCommand from './modules/lock.js'; +import unlockCommand from './modules/unlock.js'; + +export default createRouterCommand({ + name: 'channel', + description: 'Lock and unlock channels', + category: 'Moderation', + defaultMemberPermissions: PermissionFlagsBits.ManageChannels, + routes: [ + { legacyName: 'lock', module: lockCommand }, + { legacyName: 'unlock', module: unlockCommand }, + ], +}); diff --git a/src/commands/Moderation/kick.ts b/src/commands/Moderation/kick.ts index c9fb2faf53..86ed6ccdde 100644 --- a/src/commands/Moderation/kick.ts +++ b/src/commands/Moderation/kick.ts @@ -1,79 +1,15 @@ -import type { Command } from '../../types/index.js'; -import { SlashCommandBuilder, PermissionFlagsBits, type GuildMember } from 'discord.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { ModerationService } from '../../services/moderation/moderationService.js'; -import { NexoBotError, 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", - - 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 NexoBotError( - 'Missing target user', - ErrorTypes.USER_INPUT, - 'You must specify a user to kick.', - { subtype: 'invalid_user' }, - ); - } - - if (targetUser.id === interaction.user.id) { - throw new NexoBotError( - "Cannot kick self", - ErrorTypes.VALIDATION, - "You cannot kick yourself.", - ); - } - - if (targetUser.id === client.user.id) { - throw new NexoBotError( - "Cannot kick bot", - ErrorTypes.VALIDATION, - "You cannot kick the bot.", - ); - } - - if (!member) { - throw new NexoBotError( - "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: member as GuildMember, - moderator: interaction.member as GuildMember, - reason, - }); - - await InteractionHelper.universalReply(interaction, { - embeds: [ - successEmbed( - `👢 **Kicked** ${targetUser.tag}`, - `**Reason:** ${reason}\n**Case ID:** #${result.caseId}`, - ), - ], - }); - }, -} satisfies Command; +import { PermissionFlagsBits } from 'discord.js'; +import { createRouterCommand } from '../../utils/commandRouter.js'; +import kickCommand from './modules/kick.js'; +import masskickCommand from './modules/masskick.js'; + +export default createRouterCommand({ + name: 'kick', + description: 'Kick members', + category: 'Moderation', + defaultMemberPermissions: PermissionFlagsBits.KickMembers, + routes: [ + { legacyName: 'kick', module: kickCommand }, + { legacyName: 'masskick', module: masskickCommand }, + ], +}); diff --git a/src/commands/Moderation/mod.ts b/src/commands/Moderation/mod.ts new file mode 100644 index 0000000000..1ae38a15aa --- /dev/null +++ b/src/commands/Moderation/mod.ts @@ -0,0 +1,21 @@ +import { PermissionFlagsBits } from 'discord.js'; +import { createRouterCommand } from '../../utils/commandRouter.js'; +import timeoutCommand from './modules/timeout.js'; +import untimeoutCommand from './modules/untimeout.js'; +import warnCommand from './modules/warn.js'; +import warningsCommand from './modules/warnings.js'; +import dmCommand from './modules/dm.js'; + +export default createRouterCommand({ + name: 'mod', + description: 'Timeouts, warnings and moderator DMs', + category: 'Moderation', + defaultMemberPermissions: PermissionFlagsBits.ModerateMembers, + routes: [ + { legacyName: 'timeout', module: timeoutCommand }, + { legacyName: 'untimeout', module: untimeoutCommand }, + { legacyName: 'warn', module: warnCommand }, + { legacyName: 'warnings', module: warningsCommand }, + { legacyName: 'dm', module: dmCommand }, + ], +}); diff --git a/src/commands/Moderation/modules/ban.ts b/src/commands/Moderation/modules/ban.ts new file mode 100644 index 0000000000..f3c79ca647 --- /dev/null +++ b/src/commands/Moderation/modules/ban.ts @@ -0,0 +1,68 @@ +import type { Command } from '../../../types/index.js'; +import { SlashCommandBuilder, PermissionFlagsBits, type GuildMember } from 'discord.js'; +import { successEmbed } from '../../../utils/embeds.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { ModerationService } from '../../../services/moderation/moderationService.js'; +import { NexoBotError, ErrorTypes } from '../../../utils/errorHandler.js'; + +export default { + data: new SlashCommandBuilder() + .setName("ban") + .setDescription("Ban a user from the server") + .addUserOption((option) => + option + .setName("target") + .setDescription("The user to ban") + .setRequired(true), + ) + .addStringOption((option) => + option.setName("reason").setDescription("Reason for the ban"), + ) + .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers), + category: "moderation", + + async execute(interaction, config, client) { + const user = interaction.options.getUser("target"); + const reason = interaction.options.getString("reason") || "No reason provided"; + + if (!user) { + throw new NexoBotError( + 'Missing target user', + ErrorTypes.USER_INPUT, + 'You must specify a user to ban.', + { subtype: 'invalid_user' }, + ); + } + + if (user.id === interaction.user.id) { + throw new NexoBotError( + 'Cannot ban self', + ErrorTypes.VALIDATION, + 'You cannot ban yourself.', + ); + } + if (user.id === client.user.id) { + throw new NexoBotError( + 'Cannot ban bot', + ErrorTypes.VALIDATION, + 'You cannot ban the bot.', + ); + } + + const result = await ModerationService.banUser({ + guild: interaction.guild!, + user, + moderator: interaction.member as GuildMember, + reason, + }); + + await InteractionHelper.universalReply(interaction, { + embeds: [ + successEmbed( + `🚫 **Banned** ${user.tag}`, + `**Reason:** ${reason}\n**Case ID:** #${result.caseId}`, + ), + ], + }); + }, +} satisfies Command; diff --git a/src/commands/Moderation/dm.ts b/src/commands/Moderation/modules/dm.ts similarity index 92% rename from src/commands/Moderation/dm.ts rename to src/commands/Moderation/modules/dm.ts index ab68716512..e47d275334 100644 --- a/src/commands/Moderation/dm.ts +++ b/src/commands/Moderation/modules/dm.ts @@ -1,12 +1,12 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, MessageFlags } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logEvent } from '../../utils/moderation.js'; -import { logger } from '../../utils/logger.js'; -import { sanitizeMarkdown } from '../../utils/validation.js'; +import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; +import { logEvent } from '../../../utils/moderation.js'; +import { logger } from '../../../utils/logger.js'; +import { sanitizeMarkdown } from '../../../utils/validation.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { replyUserError, ErrorTypes, type ErrorLike } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { replyUserError, ErrorTypes, type ErrorLike } from '../../../utils/errorHandler.js'; export default { data: new SlashCommandBuilder() .setName("dm") diff --git a/src/commands/Moderation/modules/kick.ts b/src/commands/Moderation/modules/kick.ts new file mode 100644 index 0000000000..7614fc2336 --- /dev/null +++ b/src/commands/Moderation/modules/kick.ts @@ -0,0 +1,79 @@ +import type { Command } from '../../../types/index.js'; +import { SlashCommandBuilder, PermissionFlagsBits, type GuildMember } from 'discord.js'; +import { successEmbed } from '../../../utils/embeds.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { ModerationService } from '../../../services/moderation/moderationService.js'; +import { NexoBotError, 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", + + 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 NexoBotError( + 'Missing target user', + ErrorTypes.USER_INPUT, + 'You must specify a user to kick.', + { subtype: 'invalid_user' }, + ); + } + + if (targetUser.id === interaction.user.id) { + throw new NexoBotError( + "Cannot kick self", + ErrorTypes.VALIDATION, + "You cannot kick yourself.", + ); + } + + if (targetUser.id === client.user.id) { + throw new NexoBotError( + "Cannot kick bot", + ErrorTypes.VALIDATION, + "You cannot kick the bot.", + ); + } + + if (!member) { + throw new NexoBotError( + "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: member as GuildMember, + moderator: interaction.member as GuildMember, + reason, + }); + + await InteractionHelper.universalReply(interaction, { + embeds: [ + successEmbed( + `👢 **Kicked** ${targetUser.tag}`, + `**Reason:** ${reason}\n**Case ID:** #${result.caseId}`, + ), + ], + }); + }, +} satisfies Command; diff --git a/src/commands/Moderation/lock.ts b/src/commands/Moderation/modules/lock.ts similarity index 85% rename from src/commands/Moderation/lock.ts rename to src/commands/Moderation/modules/lock.ts index 2a67bc418e..b10c8d8f60 100644 --- a/src/commands/Moderation/lock.ts +++ b/src/commands/Moderation/modules/lock.ts @@ -1,12 +1,12 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, type TextChannel } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logEvent } from '../../utils/moderation.js'; -import { logger } from '../../utils/logger.js'; -import { getColor } from '../../config/bot.js'; +import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; +import { logEvent } from '../../../utils/moderation.js'; +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'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; export default { data: new SlashCommandBuilder() .setName("lock") diff --git a/src/commands/Moderation/massban.ts b/src/commands/Moderation/modules/massban.ts similarity index 94% rename from src/commands/Moderation/massban.ts rename to src/commands/Moderation/modules/massban.ts index 5e35f07506..d2c2b37c9d 100644 --- a/src/commands/Moderation/massban.ts +++ b/src/commands/Moderation/modules/massban.ts @@ -1,12 +1,12 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, type GuildMember } from 'discord.js'; -import { createEmbed, successEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logModerationAction } from '../../utils/moderation.js'; -import { logger } from '../../utils/logger.js'; -import { ModerationService } from '../../services/moderation/moderationService.js'; -import { NexoBotError, replyUserError, ErrorTypes, type ErrorLike } from '../../utils/errorHandler.js'; +import { createEmbed, successEmbed, warningEmbed } from '../../../utils/embeds.js'; +import { logModerationAction } from '../../../utils/moderation.js'; +import { logger } from '../../../utils/logger.js'; +import { ModerationService } from '../../../services/moderation/moderationService.js'; +import { NexoBotError, replyUserError, ErrorTypes, type ErrorLike } from '../../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; interface MassActionResults { successful: { user: string; userId: string }[]; failed: { userId: string; reason: string }[]; diff --git a/src/commands/Moderation/masskick.ts b/src/commands/Moderation/modules/masskick.ts similarity index 94% rename from src/commands/Moderation/masskick.ts rename to src/commands/Moderation/modules/masskick.ts index d650715473..a7b89097ed 100644 --- a/src/commands/Moderation/masskick.ts +++ b/src/commands/Moderation/modules/masskick.ts @@ -1,12 +1,12 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, type GuildMember } from 'discord.js'; -import { createEmbed, successEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logModerationAction } from '../../utils/moderation.js'; -import { logger } from '../../utils/logger.js'; -import { ModerationService } from '../../services/moderation/moderationService.js'; -import { NexoBotError, replyUserError, ErrorTypes, type ErrorLike } from '../../utils/errorHandler.js'; +import { createEmbed, successEmbed, warningEmbed } from '../../../utils/embeds.js'; +import { logModerationAction } from '../../../utils/moderation.js'; +import { logger } from '../../../utils/logger.js'; +import { ModerationService } from '../../../services/moderation/moderationService.js'; +import { NexoBotError, replyUserError, ErrorTypes, type ErrorLike } from '../../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; interface MassActionResults { successful: { user: string; userId: string }[]; failed: { userId: string; reason: string }[]; diff --git a/src/commands/Moderation/purge.ts b/src/commands/Moderation/modules/purge.ts similarity index 86% rename from src/commands/Moderation/purge.ts rename to src/commands/Moderation/modules/purge.ts index 6085a21d68..577166b7be 100644 --- a/src/commands/Moderation/purge.ts +++ b/src/commands/Moderation/modules/purge.ts @@ -1,12 +1,12 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, MessageFlags, type GuildTextBasedChannel } from 'discord.js'; -import { createEmbed, successEmbed } from '../../utils/embeds.js'; -import { logEvent } from '../../utils/moderation.js'; -import { logger } from '../../utils/logger.js'; -import { getColor } from '../../config/bot.js'; +import { createEmbed, successEmbed } from '../../../utils/embeds.js'; +import { logEvent } from '../../../utils/moderation.js'; +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'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; export default { data: new SlashCommandBuilder() .setName("purge") diff --git a/src/commands/Moderation/say.ts b/src/commands/Moderation/modules/say.ts similarity index 91% rename from src/commands/Moderation/say.ts rename to src/commands/Moderation/modules/say.ts index 61dae9ed62..07395057bf 100644 --- a/src/commands/Moderation/say.ts +++ b/src/commands/Moderation/modules/say.ts @@ -1,4 +1,4 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder, PermissionFlagsBits, @@ -9,12 +9,12 @@ import { type NewsChannel, type TextChannel, } 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'; +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)[] = [ ChannelType.GuildText, diff --git a/src/commands/Moderation/timeout.ts b/src/commands/Moderation/modules/timeout.ts similarity index 90% rename from src/commands/Moderation/timeout.ts rename to src/commands/Moderation/modules/timeout.ts index 7acca9d7d2..3f832c30e5 100644 --- a/src/commands/Moderation/timeout.ts +++ b/src/commands/Moderation/modules/timeout.ts @@ -1,10 +1,10 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder, PermissionFlagsBits, type GuildMember } from 'discord.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { ModerationService } from '../../services/moderation/moderationService.js'; +import { successEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { NexoBotError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { ModerationService } from '../../../services/moderation/moderationService.js'; const durationChoices = [ { name: "5 minutes", value: 5 }, diff --git a/src/commands/Moderation/unban.ts b/src/commands/Moderation/modules/unban.ts similarity index 85% rename from src/commands/Moderation/unban.ts rename to src/commands/Moderation/modules/unban.ts index cc5d71602a..28bcfb62db 100644 --- a/src/commands/Moderation/unban.ts +++ b/src/commands/Moderation/modules/unban.ts @@ -1,10 +1,10 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder, PermissionFlagsBits, type GuildMember } from 'discord.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { ModerationService } from '../../services/moderation/moderationService.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { successEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.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() diff --git a/src/commands/Moderation/unlock.ts b/src/commands/Moderation/modules/unlock.ts similarity index 88% rename from src/commands/Moderation/unlock.ts rename to src/commands/Moderation/modules/unlock.ts index 9c571b8a10..95404c4a4d 100644 --- a/src/commands/Moderation/unlock.ts +++ b/src/commands/Moderation/modules/unlock.ts @@ -1,12 +1,12 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, type TextChannel } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logEvent } from '../../utils/moderation.js'; -import { logger } from '../../utils/logger.js'; -import { getColor } from '../../config/bot.js'; +import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; +import { logEvent } from '../../../utils/moderation.js'; +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'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; export default { data: new SlashCommandBuilder() .setName("unlock") diff --git a/src/commands/Moderation/untimeout.ts b/src/commands/Moderation/modules/untimeout.ts similarity index 83% rename from src/commands/Moderation/untimeout.ts rename to src/commands/Moderation/modules/untimeout.ts index 6cbd8ca8e9..142fb8b083 100644 --- a/src/commands/Moderation/untimeout.ts +++ b/src/commands/Moderation/modules/untimeout.ts @@ -1,10 +1,10 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder, PermissionFlagsBits, type GuildMember } from 'discord.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { ModerationService } from '../../services/moderation/moderationService.js'; -import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { successEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { ModerationService } from '../../../services/moderation/moderationService.js'; +import { NexoBotError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() diff --git a/src/commands/Moderation/usernotes.ts b/src/commands/Moderation/modules/usernotes.ts similarity index 96% rename from src/commands/Moderation/usernotes.ts rename to src/commands/Moderation/modules/usernotes.ts index 60f2a035a4..85fdba8fbc 100644 --- a/src/commands/Moderation/usernotes.ts +++ b/src/commands/Moderation/modules/usernotes.ts @@ -1,12 +1,12 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, type ChatInputCommandInteraction, type User } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { getFromDb, setInDb, deleteFromDb, getUserNotesKey, getUserNotesListKey } from '../../utils/database.js'; -import { sanitizeInput } from '../../utils/validation.js'; +import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { getFromDb, setInDb, deleteFromDb, getUserNotesKey, getUserNotesListKey } from '../../../utils/database.js'; +import { sanitizeInput } from '../../../utils/validation.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; export interface UserNote { id: number; diff --git a/src/commands/Moderation/warn.ts b/src/commands/Moderation/modules/warn.ts similarity index 86% rename from src/commands/Moderation/warn.ts rename to src/commands/Moderation/modules/warn.ts index 17ed8a1eab..35b3db5bc8 100644 --- a/src/commands/Moderation/warn.ts +++ b/src/commands/Moderation/modules/warn.ts @@ -1,12 +1,12 @@ -import type { Command } from '../../types/index.js'; +import type { Command } from '../../../types/index.js'; import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, MessageFlags, type GuildMember } 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 { WarningService } from '../../services/moderation/warningService.js'; -import { ModerationService } from '../../services/moderation/moderationService.js'; -import { NexoBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; +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/moderation/warningService.js'; +import { ModerationService } from '../../../services/moderation/moderationService.js'; +import { NexoBotError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("warn") diff --git a/src/commands/Moderation/warnings.ts b/src/commands/Moderation/modules/warnings.ts similarity index 89% rename from src/commands/Moderation/warnings.ts rename to src/commands/Moderation/modules/warnings.ts index c752e86e8e..9076c91ea1 100644 --- a/src/commands/Moderation/warnings.ts +++ b/src/commands/Moderation/modules/warnings.ts @@ -1,11 +1,11 @@ -import type { Command } from '../../types/index.js'; -import { getColor } from '../../config/bot.js'; +import type { Command } from '../../../types/index.js'; +import { getColor } from '../../../config/bot.js'; import { SlashCommandBuilder, PermissionFlagsBits, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; -import { createEmbed } from '../../utils/embeds.js'; -import { logEvent } from '../../utils/moderation.js'; -import { logger } from '../../utils/logger.js'; -import { WarningService } from '../../services/moderation/warningService.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { createEmbed } from '../../../utils/embeds.js'; +import { logEvent } from '../../../utils/moderation.js'; +import { logger } from '../../../utils/logger.js'; +import { WarningService } from '../../../services/moderation/warningService.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() diff --git a/src/commands/Moderation/msg.ts b/src/commands/Moderation/msg.ts new file mode 100644 index 0000000000..b2d89d7ec1 --- /dev/null +++ b/src/commands/Moderation/msg.ts @@ -0,0 +1,19 @@ +import { PermissionFlagsBits } from 'discord.js'; +import { createRouterCommand } from '../../utils/commandRouter.js'; +import purgeCommand from './modules/purge.js'; +import sayCommand from './modules/say.js'; +import embedbuilderCommand from '../Tools/modules/embedbuilder.js'; +import usernotesCommand from './modules/usernotes.js'; + +export default createRouterCommand({ + name: 'msg', + description: 'Purge, send and build messages, user notes', + category: 'Moderation', + defaultMemberPermissions: PermissionFlagsBits.ManageMessages, + routes: [ + { legacyName: 'purge', module: purgeCommand }, + { legacyName: 'say', module: sayCommand }, + { legacyName: 'embedbuilder', module: embedbuilderCommand }, + { legacyName: 'usernotes', module: usernotesCommand }, + ], +}); diff --git a/src/commands/Music/join.ts b/src/commands/Music/modules/join.ts similarity index 64% rename from src/commands/Music/join.ts rename to src/commands/Music/modules/join.ts index b6d7b9625c..6474ab296b 100644 --- a/src/commands/Music/join.ts +++ b/src/commands/Music/modules/join.ts @@ -1,8 +1,8 @@ import { SlashCommandBuilder } from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { joinVoiceChannel, replyMusicSuccess } from '../../services/music/musicActions.js'; -import { deferMusicCommand } from '../../services/music/prefixSupport.js'; -import type { Command } from '../../types/index.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { joinVoiceChannel, replyMusicSuccess } from '../../../services/music/musicActions.js'; +import { deferMusicCommand } from '../../../services/music/prefixSupport.js'; +import type { Command } from '../../../types/index.js'; export default { category: 'Music', diff --git a/src/commands/Music/modules/music.ts b/src/commands/Music/modules/music.ts new file mode 100644 index 0000000000..21d0f0cbb0 --- /dev/null +++ b/src/commands/Music/modules/music.ts @@ -0,0 +1,190 @@ +import { SlashCommandBuilder } from 'discord.js'; +import type { LoopOption } from 'riffy'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { + skipTrack, + stopPlayback, + pausePlayback, + resumePlayback, + shuffleQueue, + setLoopMode, + setVolume, + seekTrack, + removeFromQueue, + moveInQueue, + clearQueue, + setTwentyFourSeven, + leaveVoiceChannel, + replyMusicSuccess, +} from '../../../services/music/musicActions.js'; +import { deferMusicCommand } from '../../../services/music/prefixSupport.js'; +import type { Command } from '../../../types/index.js'; + +export default { + category: 'Music', + data: new SlashCommandBuilder() + .setName('music') + .setDescription('Manage playback, queue, and voice session settings') + .addSubcommand((sub) => + sub.setName('pause').setDescription('Pause playback'), + ) + .addSubcommand((sub) => + sub.setName('resume').setDescription('Resume playback'), + ) + .addSubcommand((sub) => + sub.setName('skip').setDescription('Skip the current track'), + ) + .addSubcommand((sub) => + sub.setName('stop').setDescription('Stop playback and clear the queue'), + ) + .addSubcommand((sub) => + sub.setName('shuffle').setDescription('Shuffle the queue'), + ) + .addSubcommand((sub) => + sub + .setName('loop') + .setDescription('Set loop mode') + .addStringOption((opt) => + opt + .setName('mode') + .setDescription('Loop mode') + .setRequired(true) + .addChoices( + { name: 'Off', value: 'none' }, + { name: 'Track', value: 'track' }, + { name: 'Queue', value: 'queue' }, + ), + ), + ) + .addSubcommand((sub) => + sub + .setName('volume') + .setDescription('Set playback volume') + .addIntegerOption((opt) => + opt.setName('level').setDescription('Volume (0-100)').setRequired(true).setMinValue(0).setMaxValue(100), + ), + ) + .addSubcommand((sub) => + sub + .setName('seek') + .setDescription('Seek to a position in the current track') + .addIntegerOption((opt) => + opt.setName('seconds').setDescription('Position in seconds').setRequired(true).setMinValue(0), + ), + ) + .addSubcommand((sub) => + sub + .setName('remove') + .setDescription('Remove a track from the queue') + .addIntegerOption((opt) => + opt.setName('position').setDescription('Queue position').setRequired(true).setMinValue(1), + ), + ) + .addSubcommand((sub) => + sub + .setName('move') + .setDescription('Move a track in the queue') + .addIntegerOption((opt) => + opt.setName('from').setDescription('Current position').setRequired(true).setMinValue(1), + ) + .addIntegerOption((opt) => + opt.setName('to').setDescription('New position').setRequired(true).setMinValue(1), + ), + ) + .addSubcommand((sub) => + sub.setName('clear').setDescription('Clear the queue'), + ) + .addSubcommand((sub) => + sub.setName('leave').setDescription('Disconnect the bot from the voice channel'), + ) + .addSubcommand((sub) => + sub + .setName('247') + .setDescription('Toggle 24/7 mode (stay in voice channel when idle)') + .addBooleanOption((opt) => + opt.setName('enabled').setDescription('Enable or disable 24/7 mode').setRequired(true), + ), + ), + + async execute(interaction, config, client) { + 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')! as LoopOption); + 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.', + }); + } + }, +} satisfies Command; diff --git a/src/commands/Music/nowplaying.ts b/src/commands/Music/modules/nowplaying.ts similarity index 63% rename from src/commands/Music/nowplaying.ts rename to src/commands/Music/modules/nowplaying.ts index 455651ab8f..f165a7e438 100644 --- a/src/commands/Music/nowplaying.ts +++ b/src/commands/Music/modules/nowplaying.ts @@ -1,8 +1,8 @@ import { SlashCommandBuilder } from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { buildNowPlayingReply } from '../../services/music/musicActions.js'; -import { deferMusicCommand } from '../../services/music/prefixSupport.js'; -import type { Command } from '../../types/index.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { buildNowPlayingReply } from '../../../services/music/musicActions.js'; +import { deferMusicCommand } from '../../../services/music/prefixSupport.js'; +import type { Command } from '../../../types/index.js'; export default { category: 'Music', diff --git a/src/commands/Music/play.ts b/src/commands/Music/modules/play.ts similarity index 78% rename from src/commands/Music/play.ts rename to src/commands/Music/modules/play.ts index 297684feaa..fb878e9e50 100644 --- a/src/commands/Music/play.ts +++ b/src/commands/Music/modules/play.ts @@ -1,7 +1,7 @@ import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { playQuery, replyMusicSuccess } from '../../services/music/musicActions.js'; -import type { Command } from '../../types/index.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { playQuery, replyMusicSuccess } from '../../../services/music/musicActions.js'; +import type { Command } from '../../../types/index.js'; export default { slashOnly: true, diff --git a/src/commands/Music/queue.ts b/src/commands/Music/modules/queue.ts similarity index 80% rename from src/commands/Music/queue.ts rename to src/commands/Music/modules/queue.ts index c8a439290b..91c3d16140 100644 --- a/src/commands/Music/queue.ts +++ b/src/commands/Music/modules/queue.ts @@ -1,7 +1,7 @@ import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { buildQueueReply } from '../../services/music/musicActions.js'; -import type { Command } from '../../types/index.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { buildQueueReply } from '../../../services/music/musicActions.js'; +import type { Command } from '../../../types/index.js'; export default { slashOnly: true, diff --git a/src/commands/Music/music.ts b/src/commands/Music/music.ts index e8bb2937e0..c4f2aa35cc 100644 --- a/src/commands/Music/music.ts +++ b/src/commands/Music/music.ts @@ -1,190 +1,19 @@ -import { SlashCommandBuilder } from 'discord.js'; -import type { LoopOption } from 'riffy'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { - skipTrack, - stopPlayback, - pausePlayback, - resumePlayback, - shuffleQueue, - setLoopMode, - setVolume, - seekTrack, - removeFromQueue, - moveInQueue, - clearQueue, - setTwentyFourSeven, - leaveVoiceChannel, - replyMusicSuccess, -} from '../../services/music/musicActions.js'; -import { deferMusicCommand } from '../../services/music/prefixSupport.js'; -import type { Command } from '../../types/index.js'; +import { createRouterCommand } from '../../utils/commandRouter.js'; +import musicCommand from './modules/music.js'; +import joinCommand from './modules/join.js'; +import playCommand from './modules/play.js'; +import queueCommand from './modules/queue.js'; +import nowplayingCommand from './modules/nowplaying.js'; -export default { - category: 'Music', - data: new SlashCommandBuilder() - .setName('music') - .setDescription('Manage playback, queue, and voice session settings') - .addSubcommand((sub) => - sub.setName('pause').setDescription('Pause playback'), - ) - .addSubcommand((sub) => - sub.setName('resume').setDescription('Resume playback'), - ) - .addSubcommand((sub) => - sub.setName('skip').setDescription('Skip the current track'), - ) - .addSubcommand((sub) => - sub.setName('stop').setDescription('Stop playback and clear the queue'), - ) - .addSubcommand((sub) => - sub.setName('shuffle').setDescription('Shuffle the queue'), - ) - .addSubcommand((sub) => - sub - .setName('loop') - .setDescription('Set loop mode') - .addStringOption((opt) => - opt - .setName('mode') - .setDescription('Loop mode') - .setRequired(true) - .addChoices( - { name: 'Off', value: 'none' }, - { name: 'Track', value: 'track' }, - { name: 'Queue', value: 'queue' }, - ), - ), - ) - .addSubcommand((sub) => - sub - .setName('volume') - .setDescription('Set playback volume') - .addIntegerOption((opt) => - opt.setName('level').setDescription('Volume (0-100)').setRequired(true).setMinValue(0).setMaxValue(100), - ), - ) - .addSubcommand((sub) => - sub - .setName('seek') - .setDescription('Seek to a position in the current track') - .addIntegerOption((opt) => - opt.setName('seconds').setDescription('Position in seconds').setRequired(true).setMinValue(0), - ), - ) - .addSubcommand((sub) => - sub - .setName('remove') - .setDescription('Remove a track from the queue') - .addIntegerOption((opt) => - opt.setName('position').setDescription('Queue position').setRequired(true).setMinValue(1), - ), - ) - .addSubcommand((sub) => - sub - .setName('move') - .setDescription('Move a track in the queue') - .addIntegerOption((opt) => - opt.setName('from').setDescription('Current position').setRequired(true).setMinValue(1), - ) - .addIntegerOption((opt) => - opt.setName('to').setDescription('New position').setRequired(true).setMinValue(1), - ), - ) - .addSubcommand((sub) => - sub.setName('clear').setDescription('Clear the queue'), - ) - .addSubcommand((sub) => - sub.setName('leave').setDescription('Disconnect the bot from the voice channel'), - ) - .addSubcommand((sub) => - sub - .setName('247') - .setDescription('Toggle 24/7 mode (stay in voice channel when idle)') - .addBooleanOption((opt) => - opt.setName('enabled').setDescription('Enable or disable 24/7 mode').setRequired(true), - ), - ), - - async execute(interaction, config, client) { - 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')! as LoopOption); - 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.', - }); - } - }, -} satisfies Command; +export default createRouterCommand({ + name: 'music', + description: 'Play and control music', + category: 'Music', + routes: [ + { legacyName: 'music', module: musicCommand, spread: true }, + { legacyName: 'join', module: joinCommand }, + { legacyName: 'play', module: playCommand }, + { legacyName: 'queue', module: queueCommand }, + { legacyName: 'nowplaying', module: nowplayingCommand }, + ], +}); diff --git a/src/commands/Ticket/claim.ts b/src/commands/Ticket/modules/claim.ts similarity index 77% rename from src/commands/Ticket/claim.ts rename to src/commands/Ticket/modules/claim.ts index 6b885b566c..9a0284f690 100644 --- a/src/commands/Ticket/claim.ts +++ b/src/commands/Ticket/modules/claim.ts @@ -1,12 +1,12 @@ -import type { Command } from '../../types/index.js'; -import { getColor } from '../../config/bot.js'; +import type { Command } from '../../../types/index.js'; +import { getColor } from '../../../config/bot.js'; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, type TextChannel } from 'discord.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getTicketPermissionContext } from '../../utils/ticket/ticketPermissions.js'; -import { claimTicket } from '../../services/ticket.js'; +import { successEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { getTicketPermissionContext } from '../../../utils/ticket/ticketPermissions.js'; +import { claimTicket } from '../../../services/ticket.js'; export default { data: new SlashCommandBuilder() .setName("claim") diff --git a/src/commands/Ticket/close.ts b/src/commands/Ticket/modules/close.ts similarity index 81% rename from src/commands/Ticket/close.ts rename to src/commands/Ticket/modules/close.ts index 9f3af092b4..00857ede36 100644 --- a/src/commands/Ticket/close.ts +++ b/src/commands/Ticket/modules/close.ts @@ -1,12 +1,12 @@ -import type { Command } from '../../types/index.js'; -import { getColor } from '../../config/bot.js'; +import type { Command } from '../../../types/index.js'; +import { getColor } from '../../../config/bot.js'; import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, MessageFlags, type TextChannel } from 'discord.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getTicketPermissionContext } from '../../utils/ticket/ticketPermissions.js'; -import { closeTicket } from '../../services/ticket.js'; +import { successEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { getTicketPermissionContext } from '../../../utils/ticket/ticketPermissions.js'; +import { closeTicket } from '../../../services/ticket.js'; export default { data: new SlashCommandBuilder() .setName("close") diff --git a/src/commands/Ticket/priority.ts b/src/commands/Ticket/modules/priority.ts similarity index 82% rename from src/commands/Ticket/priority.ts rename to src/commands/Ticket/modules/priority.ts index c424e024cb..b156d6469d 100644 --- a/src/commands/Ticket/priority.ts +++ b/src/commands/Ticket/modules/priority.ts @@ -1,12 +1,12 @@ -import type { Command } from '../../types/index.js'; -import { getColor } from '../../config/bot.js'; +import type { Command } from '../../../types/index.js'; +import { getColor } from '../../../config/bot.js'; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, type TextChannel } from 'discord.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getTicketPermissionContext } from '../../utils/ticket/ticketPermissions.js'; -import { updateTicketPriority } from '../../services/ticket.js'; +import { successEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { getTicketPermissionContext } from '../../../utils/ticket/ticketPermissions.js'; +import { updateTicketPriority } from '../../../services/ticket.js'; export default { data: new SlashCommandBuilder() diff --git a/src/commands/Ticket/tickets.ts b/src/commands/Ticket/tickets.ts new file mode 100644 index 0000000000..051651ea81 --- /dev/null +++ b/src/commands/Ticket/tickets.ts @@ -0,0 +1,15 @@ +import { createRouterCommand } from '../../utils/commandRouter.js'; +import claimCommand from './modules/claim.js'; +import closeCommand from './modules/close.js'; +import priorityCommand from './modules/priority.js'; + +export default createRouterCommand({ + name: 'tickets', + description: 'Ticket actions inside a ticket channel', + category: 'Ticket', + routes: [ + { legacyName: 'claim', module: claimCommand }, + { legacyName: 'close', module: closeCommand }, + { legacyName: 'priority', module: priorityCommand }, + ], +}); diff --git a/src/commands/Tools/baseconvert.ts b/src/commands/Tools/modules/baseconvert.ts similarity index 95% rename from src/commands/Tools/baseconvert.ts rename to src/commands/Tools/modules/baseconvert.ts index 0c8ffe09e9..f0b8cb5fc5 100644 --- a/src/commands/Tools/baseconvert.ts +++ b/src/commands/Tools/modules/baseconvert.ts @@ -1,10 +1,10 @@ import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getColor } from '../../config/bot.js'; -import type { Command } from '../../types/index.js'; +import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { getColor } from '../../../config/bot.js'; +import type { Command } from '../../../types/index.js'; interface BaseInfo { base: number; @@ -145,7 +145,7 @@ export default { 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`', + message: 'You must provide a number to convert.\n\n**Example:** `/tools baseconvert number:1010 from:BIN to:DEC`', }); } diff --git a/src/commands/Tools/calculate.ts b/src/commands/Tools/modules/calculate.ts similarity index 97% rename from src/commands/Tools/calculate.ts rename to src/commands/Tools/modules/calculate.ts index 00603a6b43..303dcddfcf 100644 --- a/src/commands/Tools/calculate.ts +++ b/src/commands/Tools/modules/calculate.ts @@ -1,10 +1,10 @@ import { SlashCommandBuilder, ButtonBuilder, ButtonStyle, ActionRowBuilder, type MessageComponentInteraction } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { evaluateMathExpression } from '../../utils/safeMathParser.js'; -import type { Command } from '../../types/index.js'; +import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { evaluateMathExpression } from '../../../utils/safeMathParser.js'; +import type { Command } from '../../../types/index.js'; /** Pending operand-modal context, read by handlers/calculateModals. */ export interface CalculationContext { diff --git a/src/commands/Tools/countdown.ts b/src/commands/Tools/modules/countdown.ts similarity index 92% rename from src/commands/Tools/countdown.ts rename to src/commands/Tools/modules/countdown.ts index a7b583a578..0e06893fe1 100644 --- a/src/commands/Tools/countdown.ts +++ b/src/commands/Tools/modules/countdown.ts @@ -1,9 +1,9 @@ import { SlashCommandBuilder, MessageFlags, type ActionRowBuilder, type ButtonBuilder, type GuildTextBasedChannel, type Message } from 'discord.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { createControlButtons, formatTime, startCountdown } from '../../handlers/countdownButtons.js'; -import type { Command } from '../../types/index.js'; +import { successEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { createControlButtons, formatTime, startCountdown } from '../../../handlers/countdownButtons.js'; +import type { Command } from '../../../types/index.js'; /** Live countdown state, shared with handlers/countdownButtons. */ export interface CountdownData { diff --git a/src/commands/Tools/embedbuilder.ts b/src/commands/Tools/modules/embedbuilder.ts similarity index 99% rename from src/commands/Tools/embedbuilder.ts rename to src/commands/Tools/modules/embedbuilder.ts index 92be6d42a4..1619b9ef2e 100644 --- a/src/commands/Tools/embedbuilder.ts +++ b/src/commands/Tools/modules/embedbuilder.ts @@ -24,12 +24,12 @@ import { type NewsChannel, type TextChannel, } from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { NexoBotError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { getColor } from '../../config/bot.js'; -import type { Command } from '../../types/index.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { successEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { NexoBotError, replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { getColor } from '../../../config/bot.js'; +import type { Command } from '../../../types/index.js'; interface EmbedAuthorState { name: string; diff --git a/src/commands/Tools/generatepassword.ts b/src/commands/Tools/modules/generatepassword.ts similarity index 94% rename from src/commands/Tools/generatepassword.ts rename to src/commands/Tools/modules/generatepassword.ts index 5a49afd0a2..67b3407ff0 100644 --- a/src/commands/Tools/generatepassword.ts +++ b/src/commands/Tools/modules/generatepassword.ts @@ -1,11 +1,11 @@ import { webcrypto as crypto } from 'node:crypto'; -import { getColor } from '../../config/bot.js'; +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 { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import type { Command } from '../../types/index.js'; +import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('generatepassword') diff --git a/src/commands/Tools/hexcolor.ts b/src/commands/Tools/modules/hexcolor.ts similarity index 93% rename from src/commands/Tools/hexcolor.ts rename to src/commands/Tools/modules/hexcolor.ts index 5db0f05f97..fc1e2ea339 100644 --- a/src/commands/Tools/hexcolor.ts +++ b/src/commands/Tools/modules/hexcolor.ts @@ -1,11 +1,11 @@ import { SlashCommandBuilder, MessageFlags, type HexColorString } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { getColor } from '../../config/bot.js'; +import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; +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'; -import type { Command } from '../../types/index.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; +import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('hexcolor') diff --git a/src/commands/Tools/poll.ts b/src/commands/Tools/modules/poll.ts similarity index 94% rename from src/commands/Tools/poll.ts rename to src/commands/Tools/modules/poll.ts index 36be331c00..5ad7fcb4ba 100644 --- a/src/commands/Tools/poll.ts +++ b/src/commands/Tools/modules/poll.ts @@ -1,9 +1,9 @@ import { SlashCommandBuilder, MessageFlags, type GuildTextBasedChannel } 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 { InteractionHelper } from '../../utils/interactionHelper.js'; -import type { Command } from '../../types/index.js'; +import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { getColor } from '../../../config/bot.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import type { Command } from '../../../types/index.js'; const EMOJIS = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣', '🔟']; const MAX_OPTIONS = 10; export default { diff --git a/src/commands/Tools/randomuser.ts b/src/commands/Tools/modules/randomuser.ts similarity index 96% rename from src/commands/Tools/randomuser.ts rename to src/commands/Tools/modules/randomuser.ts index 0ffec4e28b..250f046f05 100644 --- a/src/commands/Tools/randomuser.ts +++ b/src/commands/Tools/modules/randomuser.ts @@ -1,10 +1,10 @@ import { SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, type ColorResolvable, type MessageComponentInteraction } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { getColor } from '../../config/bot.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import type { Command } from '../../types/index.js'; +import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { getColor } from '../../../config/bot.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() diff --git a/src/commands/Tools/shorten.ts b/src/commands/Tools/modules/shorten.ts similarity index 92% rename from src/commands/Tools/shorten.ts rename to src/commands/Tools/modules/shorten.ts index b0e21d38fc..8442a049b2 100644 --- a/src/commands/Tools/shorten.ts +++ b/src/commands/Tools/modules/shorten.ts @@ -1,10 +1,10 @@ import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { getColor } from '../../config/bot.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import type { Command } from '../../types/index.js'; +import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { getColor } from '../../../config/bot.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() diff --git a/src/commands/Tools/time.ts b/src/commands/Tools/modules/time.ts similarity index 89% rename from src/commands/Tools/time.ts rename to src/commands/Tools/modules/time.ts index 0fd885678a..a10dfca3c6 100644 --- a/src/commands/Tools/time.ts +++ b/src/commands/Tools/modules/time.ts @@ -1,9 +1,9 @@ import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import type { Command } from '../../types/index.js'; +import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('time') diff --git a/src/commands/Tools/unixtime.ts b/src/commands/Tools/modules/unixtime.ts similarity index 83% rename from src/commands/Tools/unixtime.ts rename to src/commands/Tools/modules/unixtime.ts index a02f4b4c14..d9feb0bada 100644 --- a/src/commands/Tools/unixtime.ts +++ b/src/commands/Tools/modules/unixtime.ts @@ -1,9 +1,9 @@ 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 { InteractionHelper } from '../../utils/interactionHelper.js'; -import type { Command } from '../../types/index.js'; +import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { getColor } from '../../../config/bot.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('unixtime') diff --git a/src/commands/Tools/tools.ts b/src/commands/Tools/tools.ts new file mode 100644 index 0000000000..6a8a36a98f --- /dev/null +++ b/src/commands/Tools/tools.ts @@ -0,0 +1,33 @@ +import { createRouterCommand } from '../../utils/commandRouter.js'; +import baseconvertCommand from './modules/baseconvert.js'; +import calculateCommand from './modules/calculate.js'; +import countdownCommand from './modules/countdown.js'; +import generatepasswordCommand from './modules/generatepassword.js'; +import hexcolorCommand from './modules/hexcolor.js'; +import pollCommand from './modules/poll.js'; +import randomuserCommand from './modules/randomuser.js'; +import shortenCommand from './modules/shorten.js'; +import timeCommand from './modules/time.js'; +import unixtimeCommand from './modules/unixtime.js'; +import weatherCommand from '../Utility/modules/weather.js'; +import firstmsgCommand from '../Utility/modules/firstmsg.js'; + +export default createRouterCommand({ + name: 'tools', + description: 'Handy tools and utilities', + category: 'Tools', + routes: [ + { legacyName: 'baseconvert', module: baseconvertCommand }, + { legacyName: 'calculate', module: calculateCommand }, + { legacyName: 'countdown', module: countdownCommand }, + { legacyName: 'generatepassword', module: generatepasswordCommand }, + { legacyName: 'hexcolor', module: hexcolorCommand }, + { legacyName: 'poll', module: pollCommand }, + { legacyName: 'randomuser', module: randomuserCommand }, + { legacyName: 'shorten', module: shortenCommand }, + { legacyName: 'time', module: timeCommand }, + { legacyName: 'unixtime', module: unixtimeCommand }, + { legacyName: 'weather', module: weatherCommand }, + { legacyName: 'firstmsg', module: firstmsgCommand }, + ], +}); diff --git a/src/commands/Utility/info.ts b/src/commands/Utility/info.ts new file mode 100644 index 0000000000..68dcd0d1e9 --- /dev/null +++ b/src/commands/Utility/info.ts @@ -0,0 +1,15 @@ +import { createRouterCommand } from '../../utils/commandRouter.js'; +import avatarCommand from './modules/avatar.js'; +import serverinfoCommand from './modules/serverinfo.js'; +import userinfoCommand from './modules/userinfo.js'; + +export default createRouterCommand({ + name: 'info', + description: 'Information about users and the server', + category: 'Utility', + routes: [ + { legacyName: 'avatar', module: avatarCommand }, + { legacyName: 'serverinfo', module: serverinfoCommand }, + { legacyName: 'userinfo', module: userinfoCommand }, + ], +}); diff --git a/src/commands/Utility/avatar.ts b/src/commands/Utility/modules/avatar.ts similarity index 80% rename from src/commands/Utility/avatar.ts rename to src/commands/Utility/modules/avatar.ts index cd07a9dbe6..8145379739 100644 --- a/src/commands/Utility/avatar.ts +++ b/src/commands/Utility/modules/avatar.ts @@ -1,9 +1,9 @@ import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; +import { createEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import type { Command } from '../../types/index.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() .setName("avatar") diff --git a/src/commands/Utility/firstmsg.ts b/src/commands/Utility/modules/firstmsg.ts similarity index 89% rename from src/commands/Utility/firstmsg.ts rename to src/commands/Utility/modules/firstmsg.ts index b577016337..7dccf836a3 100644 --- a/src/commands/Utility/firstmsg.ts +++ b/src/commands/Utility/modules/firstmsg.ts @@ -1,8 +1,8 @@ import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, type GuildTextBasedChannel } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { logger } from '../../utils/logger.js'; -import type { Command } from '../../types/index.js'; +import { createEmbed, errorEmbed, successEmbed } from '../../../utils/embeds.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { logger } from '../../../utils/logger.js'; +import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() .setName("firstmsg") diff --git a/src/commands/Utility/serverinfo.ts b/src/commands/Utility/modules/serverinfo.ts similarity index 88% rename from src/commands/Utility/serverinfo.ts rename to src/commands/Utility/modules/serverinfo.ts index 03609baf1b..7321cc3423 100644 --- a/src/commands/Utility/serverinfo.ts +++ b/src/commands/Utility/modules/serverinfo.ts @@ -1,8 +1,8 @@ import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import type { Command } from '../../types/index.js'; +import { createEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() diff --git a/src/commands/Utility/userinfo.ts b/src/commands/Utility/modules/userinfo.ts similarity index 90% rename from src/commands/Utility/userinfo.ts rename to src/commands/Utility/modules/userinfo.ts index fedd63ab8f..ede8d3c10e 100644 --- a/src/commands/Utility/userinfo.ts +++ b/src/commands/Utility/modules/userinfo.ts @@ -1,8 +1,8 @@ import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import type { Command } from '../../types/index.js'; +import { createEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() .setName("userinfo") diff --git a/src/commands/Utility/weather.ts b/src/commands/Utility/modules/weather.ts similarity index 95% rename from src/commands/Utility/weather.ts rename to src/commands/Utility/modules/weather.ts index 7e83fa5f7d..21d21c38ac 100644 --- a/src/commands/Utility/weather.ts +++ b/src/commands/Utility/modules/weather.ts @@ -1,9 +1,9 @@ import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import type { Command } from '../../types/index.js'; +import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import type { Command } from '../../../types/index.js'; const GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search"; const WEATHER_URL = "https://api.open-meteo.com/v1/forecast"; diff --git a/src/commands/Verification/autoverify.ts b/src/commands/Verification/autoverify.ts deleted file mode 100644 index 562f81758f..0000000000 --- a/src/commands/Verification/autoverify.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './modules/autoVerify.js'; \ No newline at end of file diff --git a/src/commands/Verification/modules/autoVerifyDashboard.ts b/src/commands/Verification/modules/autoVerifyDashboard.ts index 84d811a43f..5cffffd355 100644 --- a/src/commands/Verification/modules/autoVerifyDashboard.ts +++ b/src/commands/Verification/modules/autoVerifyDashboard.ts @@ -164,7 +164,7 @@ export default { embeds: [ new EmbedBuilder() .setTitle('🤖 Auto-Verification Dashboard') - .setDescription(`Auto-verification is not yet configured.${blockingText}\n\nUse \`/autoverify setup\` to configure it.`) + .setDescription(`Auto-verification is not yet configured.${blockingText}\n\nUse \`/verification autoverify setup\` to configure it.`) .setColor(getColor('warning')) .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) .setTimestamp() diff --git a/src/commands/Verification/modules/verification.ts b/src/commands/Verification/modules/verification.ts new file mode 100644 index 0000000000..6e6178db93 --- /dev/null +++ b/src/commands/Verification/modules/verification.ts @@ -0,0 +1,252 @@ +import { botConfig, getColor } from '../../../config/bot.js'; +import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js'; +import type { ChatInputCommandInteraction, Guild, TextChannel } from 'discord.js'; +import { createEmbed, infoEmbed, successEmbed } from '../../../utils/embeds.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'; +import { getWelcomeConfig } from '../../../utils/database.js'; +import verificationDashboard from './verification_dashboard.js'; +import type { Command, NexoClient } from '../../../types/index.js'; + +export default { + data: new SlashCommandBuilder() + .setName("verification") + .setDescription("Manage the server verification system") + .addSubcommand(subcommand => + subcommand + .setName("setup") + .setDescription("Set up the verification system") + .addChannelOption(option => + option + .setName("verification_channel") + .setDescription("Channel where verification messages will be sent") + .addChannelTypes(ChannelType.GuildText) + .setRequired(true) + ) + .addRoleOption(option => + option + .setName("verified_role") + .setDescription("Role to give to verified users") + .setRequired(true) + ) + .addStringOption(option => + option + .setName("message") + .setDescription("Custom verification message") + .setMaxLength(2000) + .setRequired(false) + ) + .addStringOption(option => + option + .setName("button_text") + .setDescription("Text for the verification button") + .setMaxLength(80) + .setRequired(false) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName("remove") + .setDescription("Remove verification from a user") + .addUserOption(option => + option + .setName("user") + .setDescription("User to remove verification from") + .setRequired(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName("dashboard") + .setDescription("Open the verification system configuration dashboard") + ), + + async execute(interaction, config, client) { + const wrappedExecute = withErrorHandling(async (..._args: unknown[]) => { + const subcommand = interaction.options.getSubcommand(); + const guild = interaction.guild!; + + if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { + throw createError( + 'Missing ManageGuild permission for verification admin subcommand', + ErrorTypes.PERMISSION, + 'You need the **Manage Server** permission to use this verification subcommand.', + { subcommand, requiredPermission: 'ManageGuild', userId: interaction.user.id } + ); + } + + switch (subcommand) { + case "setup": + return await handleSetup(interaction, guild, client); + case "remove": + return await handleRemove(interaction, guild, client); + case "dashboard": + return await verificationDashboard.execute(interaction, config, client); + default: + throw createError( + `Unknown subcommand: ${subcommand}`, + ErrorTypes.VALIDATION, + "Please select a valid subcommand.", + { subcommand } + ); + } + }, { command: 'verification', subcommand: interaction.options.getSubcommand() }); + + return await wrappedExecute(interaction, config, client); + } +} satisfies Command; + +async function handleSetup(interaction: ChatInputCommandInteraction, guild: Guild, client: NexoClient) { + const verificationChannel = interaction.options.getChannel("verification_channel") as TextChannel; + const verifiedRole = interaction.options.getRole("verified_role")!; + const message = interaction.options.getString("message") || botConfig.verification.defaultMessage; + const buttonText = interaction.options.getString("button_text") || botConfig.verification.defaultButtonText; + const botMember = guild.members.me; + + if (!botMember) { + throw createError( + 'Bot member not found in guild cache', + ErrorTypes.CONFIGURATION, + 'I could not verify my permissions in this server. Please try again in a moment.', + { guildId: guild.id } + ); + } + + const requiredChannelPermissions = [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.EmbedLinks + ]; + const missingChannelPerms = requiredChannelPermissions.filter(perm => + !verificationChannel.permissionsFor(botMember).has(perm) + ); + + if (missingChannelPerms.length > 0) { + throw createError( + `Missing channel permissions: ${missingChannelPerms.join(', ')}`, + ErrorTypes.PERMISSION, + 'I need **View Channel**, **Send Messages**, and **Embed Links** in the verification channel.', + { missingPermissions: missingChannelPerms, channel: verificationChannel.id } + ); + } + + if (!botMember.permissions.has(PermissionFlagsBits.ManageRoles)) { + throw createError( + "Missing ManageRoles permission", + ErrorTypes.PERMISSION, + "I need the 'Manage Roles' permission to give verified roles.", + { missingPermission: "ManageRoles" } + ); + } + + if (verifiedRole.id === guild.id || verifiedRole.managed) { + throw createError( + 'Invalid verified role selected', + ErrorTypes.VALIDATION, + 'Please choose a normal assignable role (not @everyone or an integration-managed role).', + { roleId: verifiedRole.id, managed: verifiedRole.managed } + ); + } + + const botRole = botMember.roles.highest; + if (verifiedRole.position >= botRole.position) { + throw createError( + "Role hierarchy error", + ErrorTypes.PERMISSION, + "The verified role must be below my highest role in the server role hierarchy.", + { rolePosition: verifiedRole.position, botRolePosition: botRole.position } + ); + } + + const guildConfig = await getGuildConfig(client, guild.id); + const welcomeConfig = await getWelcomeConfig(client, guild.id); + const hasAutoVerifyEnabled = Boolean(guildConfig.verification?.autoVerify?.enabled); + const hasAutoRoleConfigured = Boolean(guildConfig.autoRole) || (Array.isArray(welcomeConfig.roleIds) && welcomeConfig.roleIds.length > 0); + + if (hasAutoVerifyEnabled || hasAutoRoleConfigured) { + throw createError( + 'Verification setup blocked by conflicting onboarding system', + ErrorTypes.CONFIGURATION, + 'You cannot enable the verification system while **AutoVerify** or **AutoRole** is configured. Disable those first.', + { + guildId: guild.id, + hasAutoVerifyEnabled, + hasAutoRoleConfigured, + expected: true, + suppressErrorLog: true + } + ); + } + + await InteractionHelper.safeDefer(interaction); + + const verifyEmbed = createEmbed({ + title: "Server Verification", + description: message, + color: getColor('success') + }); + + const verifyButton = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("verify_user") + .setLabel(buttonText) + .setStyle(ButtonStyle.Success) + .setEmoji("✅") + ); + + const verifyMessage = await verificationChannel.send({ + embeds: [verifyEmbed], + components: [verifyButton] + }); + + guildConfig.verification = { + enabled: true, + channelId: verificationChannel.id, + messageId: verifyMessage.id, + roleId: verifiedRole.id, + message: message, + buttonText: buttonText + }; + + await setGuildConfig(client, guild.id, guildConfig); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed( + 'Verification System Updated', + [ + `Channel: ${verificationChannel}`, + `Verified Role: ${verifiedRole}`, + `Button Text: ${buttonText}` + ].join('\n') + )] + }); +} + +async function handleRemove(interaction: ChatInputCommandInteraction, guild: Guild, client: NexoClient) { + const targetUser = interaction.options.getUser("user")!; + + 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: [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 + }); + + 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/modules/verification_dashboard.ts b/src/commands/Verification/modules/verification_dashboard.ts index 3f48ff3b1d..585fc2facb 100644 --- a/src/commands/Verification/modules/verification_dashboard.ts +++ b/src/commands/Verification/modules/verification_dashboard.ts @@ -334,7 +334,7 @@ export default { if (!wasEnabled && autoVerifyEnabled) { await replyUserError(btnInteraction, { type: ErrorTypes.CONFIGURATION, - message: 'AutoVerify is currently enabled. Please disable AutoVerify first before enabling the manual Verification system.\n\nRun `/autoverify` to access the AutoVerify dashboard.', + message: 'AutoVerify is currently enabled. Please disable AutoVerify first before enabling the manual Verification system.\n\nRun `/verification autoverify dashboard` to access the AutoVerify dashboard.', }); return; } diff --git a/src/commands/Verification/verification.ts b/src/commands/Verification/verification.ts index 61c57ba67b..ff5fb151c8 100644 --- a/src/commands/Verification/verification.ts +++ b/src/commands/Verification/verification.ts @@ -1,252 +1,13 @@ -import { botConfig, getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js'; -import type { ChatInputCommandInteraction, Guild, TextChannel } from 'discord.js'; -import { createEmbed, infoEmbed, successEmbed } from '../../utils/embeds.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'; -import { getWelcomeConfig } from '../../utils/database.js'; -import verificationDashboard from './modules/verification_dashboard.js'; -import type { Command, NexoClient } from '../../types/index.js'; - -export default { - data: new SlashCommandBuilder() - .setName("verification") - .setDescription("Manage the server verification system") - .addSubcommand(subcommand => - subcommand - .setName("setup") - .setDescription("Set up the verification system") - .addChannelOption(option => - option - .setName("verification_channel") - .setDescription("Channel where verification messages will be sent") - .addChannelTypes(ChannelType.GuildText) - .setRequired(true) - ) - .addRoleOption(option => - option - .setName("verified_role") - .setDescription("Role to give to verified users") - .setRequired(true) - ) - .addStringOption(option => - option - .setName("message") - .setDescription("Custom verification message") - .setMaxLength(2000) - .setRequired(false) - ) - .addStringOption(option => - option - .setName("button_text") - .setDescription("Text for the verification button") - .setMaxLength(80) - .setRequired(false) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName("remove") - .setDescription("Remove verification from a user") - .addUserOption(option => - option - .setName("user") - .setDescription("User to remove verification from") - .setRequired(true) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName("dashboard") - .setDescription("Open the verification system configuration dashboard") - ), - - async execute(interaction, config, client) { - const wrappedExecute = withErrorHandling(async (..._args: unknown[]) => { - const subcommand = interaction.options.getSubcommand(); - const guild = interaction.guild!; - - if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { - throw createError( - 'Missing ManageGuild permission for verification admin subcommand', - ErrorTypes.PERMISSION, - 'You need the **Manage Server** permission to use this verification subcommand.', - { subcommand, requiredPermission: 'ManageGuild', userId: interaction.user.id } - ); - } - - switch (subcommand) { - case "setup": - return await handleSetup(interaction, guild, client); - case "remove": - return await handleRemove(interaction, guild, client); - case "dashboard": - return await verificationDashboard.execute(interaction, config, client); - default: - throw createError( - `Unknown subcommand: ${subcommand}`, - ErrorTypes.VALIDATION, - "Please select a valid subcommand.", - { subcommand } - ); - } - }, { command: 'verification', subcommand: interaction.options.getSubcommand() }); - - return await wrappedExecute(interaction, config, client); - } -} satisfies Command; - -async function handleSetup(interaction: ChatInputCommandInteraction, guild: Guild, client: NexoClient) { - const verificationChannel = interaction.options.getChannel("verification_channel") as TextChannel; - const verifiedRole = interaction.options.getRole("verified_role")!; - const message = interaction.options.getString("message") || botConfig.verification.defaultMessage; - const buttonText = interaction.options.getString("button_text") || botConfig.verification.defaultButtonText; - const botMember = guild.members.me; - - if (!botMember) { - throw createError( - 'Bot member not found in guild cache', - ErrorTypes.CONFIGURATION, - 'I could not verify my permissions in this server. Please try again in a moment.', - { guildId: guild.id } - ); - } - - const requiredChannelPermissions = [ - PermissionFlagsBits.ViewChannel, - PermissionFlagsBits.SendMessages, - PermissionFlagsBits.EmbedLinks - ]; - const missingChannelPerms = requiredChannelPermissions.filter(perm => - !verificationChannel.permissionsFor(botMember).has(perm) - ); - - if (missingChannelPerms.length > 0) { - throw createError( - `Missing channel permissions: ${missingChannelPerms.join(', ')}`, - ErrorTypes.PERMISSION, - 'I need **View Channel**, **Send Messages**, and **Embed Links** in the verification channel.', - { missingPermissions: missingChannelPerms, channel: verificationChannel.id } - ); - } - - if (!botMember.permissions.has(PermissionFlagsBits.ManageRoles)) { - throw createError( - "Missing ManageRoles permission", - ErrorTypes.PERMISSION, - "I need the 'Manage Roles' permission to give verified roles.", - { missingPermission: "ManageRoles" } - ); - } - - if (verifiedRole.id === guild.id || verifiedRole.managed) { - throw createError( - 'Invalid verified role selected', - ErrorTypes.VALIDATION, - 'Please choose a normal assignable role (not @everyone or an integration-managed role).', - { roleId: verifiedRole.id, managed: verifiedRole.managed } - ); - } - - const botRole = botMember.roles.highest; - if (verifiedRole.position >= botRole.position) { - throw createError( - "Role hierarchy error", - ErrorTypes.PERMISSION, - "The verified role must be below my highest role in the server role hierarchy.", - { rolePosition: verifiedRole.position, botRolePosition: botRole.position } - ); - } - - const guildConfig = await getGuildConfig(client, guild.id); - const welcomeConfig = await getWelcomeConfig(client, guild.id); - const hasAutoVerifyEnabled = Boolean(guildConfig.verification?.autoVerify?.enabled); - const hasAutoRoleConfigured = Boolean(guildConfig.autoRole) || (Array.isArray(welcomeConfig.roleIds) && welcomeConfig.roleIds.length > 0); - - if (hasAutoVerifyEnabled || hasAutoRoleConfigured) { - throw createError( - 'Verification setup blocked by conflicting onboarding system', - ErrorTypes.CONFIGURATION, - 'You cannot enable the verification system while **AutoVerify** or **AutoRole** is configured. Disable those first.', - { - guildId: guild.id, - hasAutoVerifyEnabled, - hasAutoRoleConfigured, - expected: true, - suppressErrorLog: true - } - ); - } - - await InteractionHelper.safeDefer(interaction); - - const verifyEmbed = createEmbed({ - title: "Server Verification", - description: message, - color: getColor('success') - }); - - const verifyButton = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId("verify_user") - .setLabel(buttonText) - .setStyle(ButtonStyle.Success) - .setEmoji("✅") - ); - - const verifyMessage = await verificationChannel.send({ - embeds: [verifyEmbed], - components: [verifyButton] - }); - - guildConfig.verification = { - enabled: true, - channelId: verificationChannel.id, - messageId: verifyMessage.id, - roleId: verifiedRole.id, - message: message, - buttonText: buttonText - }; - - await setGuildConfig(client, guild.id, guildConfig); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed( - 'Verification System Updated', - [ - `Channel: ${verificationChannel}`, - `Verified Role: ${verifiedRole}`, - `Button Text: ${buttonText}` - ].join('\n') - )] - }); -} - -async function handleRemove(interaction: ChatInputCommandInteraction, guild: Guild, client: NexoClient) { - const targetUser = interaction.options.getUser("user")!; - - 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: [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 - }); - - return await InteractionHelper.safeReply(interaction, { - embeds: [successEmbed('Verification Removed', `Verification removed from ${targetUser.tag}.`)] - }); -} \ No newline at end of file +import { createRouterCommand } from '../../utils/commandRouter.js'; +import verificationCommand from './modules/verification.js'; +import autoverifyCommand from './modules/autoVerify.js'; + +export default createRouterCommand({ + name: 'verification', + description: 'Manage member verification', + category: 'Verification', + routes: [ + { legacyName: 'verification', module: verificationCommand, spread: true }, + { legacyName: 'autoverify', module: autoverifyCommand }, + ], +}); diff --git a/src/commands/Welcome/autorole.ts b/src/commands/Welcome/modules/autorole.ts similarity index 95% rename from src/commands/Welcome/autorole.ts rename to src/commands/Welcome/modules/autorole.ts index 149a48c8de..b3365ede39 100644 --- a/src/commands/Welcome/autorole.ts +++ b/src/commands/Welcome/modules/autorole.ts @@ -1,12 +1,12 @@ -import { getColor } from '../../config/bot.js'; +import { getColor } from '../../../config/bot.js'; import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, EmbedBuilder, MessageFlags } from 'discord.js'; import type { ChatInputCommandInteraction, Role } from 'discord.js'; -import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getGuildConfig } from '../../services/config/guildConfig.js'; -import { ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; -import type { Command } from '../../types/index.js'; +import { getWelcomeConfig, updateWelcomeConfig } from '../../../utils/database.js'; +import { logger } from '../../../utils/logger.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { getGuildConfig } from '../../../services/config/guildConfig.js'; +import { ErrorTypes, replyUserError } from '../../../utils/errorHandler.js'; +import type { Command } from '../../../types/index.js'; function createAutoroleInfoEmbed(description: string) { return new EmbedBuilder() @@ -53,7 +53,7 @@ export default { } if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the **Manage Server** permission to use `/autorole`.' }); + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the **Manage Server** permission to use `/welcome autorole`.' }); } const { options, guild, client } = interaction as ChatInputCommandInteraction<'cached'>; diff --git a/src/commands/Welcome/goodbye.ts b/src/commands/Welcome/modules/goodbye.ts similarity index 88% rename from src/commands/Welcome/goodbye.ts rename to src/commands/Welcome/modules/goodbye.ts index 95a4a18bbc..25903fd2ad 100644 --- a/src/commands/Welcome/goodbye.ts +++ b/src/commands/Welcome/modules/goodbye.ts @@ -1,13 +1,13 @@ -import { getColor } from '../../config/bot.js'; +import { getColor } from '../../../config/bot.js'; import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, EmbedBuilder, MessageFlags } from 'discord.js'; import type { ChatInputCommandInteraction } from 'discord.js'; -import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; -import { formatWelcomeMessage, truncateForEmbedField } from '../../utils/welcome.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; -import type { Command } from '../../types/index.js'; -import type { WelcomeEmbedConfig } from '../../utils/database.js'; +import { getWelcomeConfig, updateWelcomeConfig } from '../../../utils/database.js'; +import { formatWelcomeMessage, truncateForEmbedField } from '../../../utils/welcome.js'; +import { logger } from '../../../utils/logger.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { ErrorTypes, replyUserError } from '../../../utils/errorHandler.js'; +import type { Command } from '../../../types/index.js'; +import type { WelcomeEmbedConfig } from '../../../utils/database.js'; export default { data: new SlashCommandBuilder() @@ -50,7 +50,7 @@ export default { const { options, guild, client } = interaction as ChatInputCommandInteraction<'cached'>; if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the **Manage Server** permission to use `/goodbye`.' }); + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the **Manage Server** permission to use `/welcome goodbye`.' }); } const subcommand = options.getSubcommand(); @@ -64,7 +64,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 **/greet dashboard** to customize channel, message, ping, or image.` }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `Goodbye is already configured for <#${existingConfig.goodbyeChannelId}>. Use **/welcome greet dashboard** to customize channel, message, ping, or image.` }); } if (!message || message.trim().length === 0) { @@ -113,7 +113,7 @@ export default { { name: 'Ping User', value: ping ? 'Yes' : 'No' }, { name: 'Status', value: 'Enabled' } ) - .setFooter({ text: 'Tip: Use /greet dashboard to customize goodbye settings' }); + .setFooter({ text: 'Tip: Use /welcome greet dashboard to customize goodbye settings' }); if (image) { embed.setImage(image); diff --git a/src/commands/Welcome/greet.ts b/src/commands/Welcome/modules/greet.ts similarity index 78% rename from src/commands/Welcome/greet.ts rename to src/commands/Welcome/modules/greet.ts index a854d43eee..799fd3dcd3 100644 --- a/src/commands/Welcome/greet.ts +++ b/src/commands/Welcome/modules/greet.ts @@ -1,9 +1,9 @@ import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, NexoBotError, ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; -import greetDashboard from './modules/greet_dashboard.js'; -import type { Command } from '../../types/index.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { logger } from '../../../utils/logger.js'; +import { handleInteractionError, NexoBotError, ErrorTypes, replyUserError } from '../../../utils/errorHandler.js'; +import greetDashboard from './greet_dashboard.js'; +import type { Command } from '../../../types/index.js'; export default { slashOnly: true, @@ -20,7 +20,7 @@ export default { async execute(interaction, config, client) { try { if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the **Manage Server** permission to use `/greet`.' }); + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the **Manage Server** permission to use `/welcome greet`.' }); } const subcommand = interaction.options.getSubcommand(); @@ -29,7 +29,7 @@ export default { case 'dashboard': return await greetDashboard.execute(interaction, config, client); default: - logger.warn(`Unknown /greet subcommand: ${subcommand}`); + logger.warn(`Unknown /welcome greet subcommand: ${subcommand}`); } } catch (error) { if (error instanceof NexoBotError) { diff --git a/src/commands/Welcome/modules/greet_dashboard.ts b/src/commands/Welcome/modules/greet_dashboard.ts index d644e788e2..26cea6cebd 100644 --- a/src/commands/Welcome/modules/greet_dashboard.ts +++ b/src/commands/Welcome/modules/greet_dashboard.ts @@ -190,7 +190,7 @@ export default { throw new NexoBotError( 'Greet system not configured', ErrorTypes.CONFIGURATION, - 'Neither Welcome nor Goodbye has been set up yet. Run `/welcome setup` or `/goodbye setup` first.', + 'Neither Welcome nor Goodbye has been set up yet. Run `/welcome message setup` or `/welcome goodbye setup` first.', ); } diff --git a/src/commands/Welcome/modules/welcome.ts b/src/commands/Welcome/modules/welcome.ts new file mode 100644 index 0000000000..40470c6c09 --- /dev/null +++ b/src/commands/Welcome/modules/welcome.ts @@ -0,0 +1,126 @@ +import { getColor } from '../../../config/bot.js'; +import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, EmbedBuilder, MessageFlags } from 'discord.js'; +import type { ChatInputCommandInteraction } from 'discord.js'; +import { getWelcomeConfig, updateWelcomeConfig } from '../../../utils/database.js'; +import { formatWelcomeMessage, truncateForEmbedField } from '../../../utils/welcome.js'; +import { logger } from '../../../utils/logger.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { ErrorTypes, replyUserError } from '../../../utils/errorHandler.js'; +import type { Command } from '../../../types/index.js'; + +export default { + data: new SlashCommandBuilder() + .setName('welcome') + .setDescription('Configure the welcome system') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommand(subcommand => + subcommand + .setName('setup') + .setDescription('Set up the welcome message') + .addChannelOption(option => + option.setName('channel') + .setDescription('The channel to send welcome messages to') + .addChannelTypes(ChannelType.GuildText) + .setRequired(true)) + .addStringOption(option => + option.setName('message') + .setDescription('Welcome message. Variables: {user}, {username}, {server}, {memberCount}') + .setRequired(true)) + .addStringOption(option => + option.setName('image') + .setDescription('URL of the image to include in the welcome message') + .setRequired(false)) + .addBooleanOption(option => + option.setName('ping') + .setDescription('Whether to ping the user in the welcome message') + .setRequired(false))), + + async execute(interaction) { + try { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`Welcome interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'welcome' + }); + return; + } + } catch (deferError) { + logger.error(`Welcome defer error`, { error: deferError instanceof Error ? deferError.message : String(deferError) }); + return; + } + + const { options, guild, client } = interaction as ChatInputCommandInteraction<'cached'>; + + if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the **Manage Server** permission to use `/welcome message`.' }); + } + + const subcommand = options.getSubcommand(); + + if (subcommand === 'setup') { + const channel = options.getChannel('channel')!; + const message = options.getString('message'); + const image = options.getString('image'); + const ping = options.getBoolean('ping') ?? false; + + 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 greet dashboard** to customize channel, message, ping, or image.` }); + } + + if (!message || message.trim().length === 0) { + logger.warn(`[Welcome] Empty message provided by ${interaction.user.tag} in ${guild.name}`); + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Welcome message cannot be empty' }); + } + + if (image) { + try { + new URL(image); + } catch (e) { + logger.warn(`[Welcome] Invalid image URL provided by ${interaction.user.tag}: ${image}`); + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please provide a valid image URL (must start with http:// or https://' }); + } + } + + try { + await updateWelcomeConfig(client, guild.id, { + enabled: true, + channelId: channel.id, + welcomeMessage: message, + welcomeImage: image || undefined, + welcomePing: ping + }); + + logger.info(`[Welcome] Setup configured by ${interaction.user.tag} for guild ${guild.name} (${guild.id})`); + + const previewMessage = formatWelcomeMessage(message, { + user: interaction.user, + guild + }); + + const embed = new EmbedBuilder() + .setColor(getColor('success')) + .setTitle('Welcome System Configured') + .setDescription(`Welcome messages will now be sent to ${channel}`) + .addFields( + { name: 'Message Preview', value: truncateForEmbedField(previewMessage) }, + { name: 'Ping User', value: ping ? 'Yes' : 'No' }, + { name: 'Status', value: 'Enabled' } + ) + .setFooter({ text: 'Tip: Use /welcome greet dashboard to customize welcome settings' }); + + if (image) { + embed.setImage(image); + } + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + } catch (error) { + logger.error(`[Welcome] Failed to setup welcome system for guild ${guild.id}:`, error); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while configuring the welcome system. Please try again.' }); + } + } + }, +} satisfies Command; diff --git a/src/commands/Welcome/welcome.ts b/src/commands/Welcome/welcome.ts index f38ee2cb62..6458cad65f 100644 --- a/src/commands/Welcome/welcome.ts +++ b/src/commands/Welcome/welcome.ts @@ -1,126 +1,19 @@ -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, EmbedBuilder, MessageFlags } from 'discord.js'; -import type { ChatInputCommandInteraction } from 'discord.js'; -import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; -import { formatWelcomeMessage, truncateForEmbedField } from '../../utils/welcome.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; -import type { Command } from '../../types/index.js'; - -export default { - data: new SlashCommandBuilder() - .setName('welcome') - .setDescription('Configure the welcome system') - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .addSubcommand(subcommand => - subcommand - .setName('setup') - .setDescription('Set up the welcome message') - .addChannelOption(option => - option.setName('channel') - .setDescription('The channel to send welcome messages to') - .addChannelTypes(ChannelType.GuildText) - .setRequired(true)) - .addStringOption(option => - option.setName('message') - .setDescription('Welcome message. Variables: {user}, {username}, {server}, {memberCount}') - .setRequired(true)) - .addStringOption(option => - option.setName('image') - .setDescription('URL of the image to include in the welcome message') - .setRequired(false)) - .addBooleanOption(option => - option.setName('ping') - .setDescription('Whether to ping the user in the welcome message') - .setRequired(false))), - - async execute(interaction) { - try { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Welcome interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'welcome' - }); - return; - } - } catch (deferError) { - logger.error(`Welcome defer error`, { error: deferError instanceof Error ? deferError.message : String(deferError) }); - return; - } - - const { options, guild, client } = interaction as ChatInputCommandInteraction<'cached'>; - - if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the **Manage Server** permission to use `/welcome`.' }); - } - - const subcommand = options.getSubcommand(); - - if (subcommand === 'setup') { - const channel = options.getChannel('channel')!; - const message = options.getString('message'); - const image = options.getString('image'); - const ping = options.getBoolean('ping') ?? false; - - 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 **/greet dashboard** to customize channel, message, ping, or image.` }); - } - - if (!message || message.trim().length === 0) { - logger.warn(`[Welcome] Empty message provided by ${interaction.user.tag} in ${guild.name}`); - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Welcome message cannot be empty' }); - } - - if (image) { - try { - new URL(image); - } catch (e) { - logger.warn(`[Welcome] Invalid image URL provided by ${interaction.user.tag}: ${image}`); - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please provide a valid image URL (must start with http:// or https://' }); - } - } - - try { - await updateWelcomeConfig(client, guild.id, { - enabled: true, - channelId: channel.id, - welcomeMessage: message, - welcomeImage: image || undefined, - welcomePing: ping - }); - - logger.info(`[Welcome] Setup configured by ${interaction.user.tag} for guild ${guild.name} (${guild.id})`); - - const previewMessage = formatWelcomeMessage(message, { - user: interaction.user, - guild - }); - - const embed = new EmbedBuilder() - .setColor(getColor('success')) - .setTitle('Welcome System Configured') - .setDescription(`Welcome messages will now be sent to ${channel}`) - .addFields( - { name: 'Message Preview', value: truncateForEmbedField(previewMessage) }, - { name: 'Ping User', value: ping ? 'Yes' : 'No' }, - { name: 'Status', value: 'Enabled' } - ) - .setFooter({ text: 'Tip: Use /greet dashboard to customize welcome settings' }); - - if (image) { - embed.setImage(image); - } - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } catch (error) { - logger.error(`[Welcome] Failed to setup welcome system for guild ${guild.id}:`, error); - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while configuring the welcome system. Please try again.' }); - } - } - }, -} satisfies Command; +import { PermissionFlagsBits } from 'discord.js'; +import { createRouterCommand } from '../../utils/commandRouter.js'; +import autoroleCommand from './modules/autorole.js'; +import goodbyeCommand from './modules/goodbye.js'; +import greetCommand from './modules/greet.js'; +import welcomeCommand from './modules/welcome.js'; + +export default createRouterCommand({ + name: 'welcome', + description: 'Welcome, goodbye and auto role settings', + category: 'Welcome', + defaultMemberPermissions: PermissionFlagsBits.ManageGuild, + routes: [ + { legacyName: 'autorole', module: autoroleCommand }, + { legacyName: 'goodbye', module: goodbyeCommand }, + { legacyName: 'greet', module: greetCommand }, + { legacyName: 'welcome', module: welcomeCommand }, + ], +}); diff --git a/src/config/commands/commandAliases.ts b/src/config/commands/commandAliases.ts index 77eb4072ab..99d656924f 100644 --- a/src/config/commands/commandAliases.ts +++ b/src/config/commands/commandAliases.ts @@ -23,7 +23,6 @@ export const commandAliases: Record = { 'ping': 'ping', 'help': 'help', 'h': 'help', - 'info': 'help', 'ban': 'ban', 'kick': 'kick', diff --git a/src/config/commands/legacyCommandMap.ts b/src/config/commands/legacyCommandMap.ts new file mode 100644 index 0000000000..e2dea5c582 --- /dev/null +++ b/src/config/commands/legacyCommandMap.ts @@ -0,0 +1,226 @@ +/** + * Legacy command map: pre-consolidation top-level commands -> their new location. + * + * Single source of truth for everything that still knows the old names: + * - command access checks (stored `disabledCommands` keys use old names, no data migration) + * - prefix routing (`!bal` -> `/eco balance`) and aliases + * - help menus ("formerly /balance") + * - docs/COMMAND_MIGRATION.md + * + * An entry with `subcommand` maps an old command without subcommands to one subcommand. + * An entry with only `group` maps an old command with subcommands to a subcommand group; + * the old subcommand names are kept inside that group. + */ + +export interface LegacyCommandTarget { + command: string; + group?: string; + subcommand?: string; + /** Category folder the old command lived in (for disabledCategories). */ + category: string; +} + +export const LEGACY_COMMAND_MAP: Readonly> = { + // Economy + balance: { command: 'eco', subcommand: 'balance', category: 'Economy' }, + beg: { command: 'eco', subcommand: 'beg', category: 'Economy' }, + buy: { command: 'eco', subcommand: 'buy', category: 'Economy' }, + crime: { command: 'eco', subcommand: 'crime', category: 'Economy' }, + daily: { command: 'eco', subcommand: 'daily', category: 'Economy' }, + deposit: { command: 'eco', subcommand: 'deposit', category: 'Economy' }, + eleaderboard: { command: 'eco', subcommand: 'leaderboard', category: 'Economy' }, + fish: { command: 'eco', subcommand: 'fish', category: 'Economy' }, + gamble: { command: 'eco', subcommand: 'gamble', category: 'Economy' }, + inventory: { command: 'eco', subcommand: 'inventory', category: 'Economy' }, + mine: { command: 'eco', subcommand: 'mine', category: 'Economy' }, + pay: { command: 'eco', subcommand: 'pay', category: 'Economy' }, + rob: { command: 'eco', subcommand: 'rob', category: 'Economy' }, + shop: { command: 'eco', subcommand: 'shop', category: 'Economy' }, + slut: { command: 'eco', subcommand: 'slut', category: 'Economy' }, + withdraw: { command: 'eco', subcommand: 'withdraw', category: 'Economy' }, + work: { command: 'eco', subcommand: 'work', category: 'Economy' }, + 'shop-config': { command: 'economy', group: 'shop', category: 'Economy' }, + + // Music + join: { command: 'music', subcommand: 'join', category: 'Music' }, + play: { command: 'music', subcommand: 'play', category: 'Music' }, + queue: { command: 'music', subcommand: 'queue', category: 'Music' }, + nowplaying: { command: 'music', subcommand: 'nowplaying', category: 'Music' }, + + // Giveaway + gcreate: { command: 'giveaway', subcommand: 'create', category: 'Giveaway' }, + gdelete: { command: 'giveaway', subcommand: 'delete', category: 'Giveaway' }, + gend: { command: 'giveaway', subcommand: 'end', category: 'Giveaway' }, + greroll: { command: 'giveaway', subcommand: 'reroll', category: 'Giveaway' }, + + // Leveling + leveladd: { command: 'level', subcommand: 'add', category: 'Leveling' }, + levelremove: { command: 'level', subcommand: 'remove', category: 'Leveling' }, + levelset: { command: 'level', subcommand: 'set', category: 'Leveling' }, + rank: { command: 'rank', subcommand: 'view', category: 'Leveling' }, + leaderboard: { command: 'rank', subcommand: 'leaderboard', category: 'Leveling' }, + + // Tools + baseconvert: { command: 'tools', subcommand: 'baseconvert', category: 'Tools' }, + calculate: { command: 'tools', subcommand: 'calculate', category: 'Tools' }, + countdown: { command: 'tools', subcommand: 'countdown', category: 'Tools' }, + generatepassword: { command: 'tools', subcommand: 'generatepassword', category: 'Tools' }, + hexcolor: { command: 'tools', subcommand: 'hexcolor', category: 'Tools' }, + poll: { command: 'tools', subcommand: 'poll', category: 'Tools' }, + randomuser: { command: 'tools', subcommand: 'randomuser', category: 'Tools' }, + shorten: { command: 'tools', subcommand: 'shorten', category: 'Tools' }, + time: { command: 'tools', subcommand: 'time', category: 'Tools' }, + unixtime: { command: 'tools', subcommand: 'unixtime', category: 'Tools' }, + weather: { command: 'tools', subcommand: 'weather', category: 'Utility' }, + firstmsg: { command: 'tools', subcommand: 'firstmsg', category: 'Utility' }, + + // Info + avatar: { command: 'info', subcommand: 'avatar', category: 'Utility' }, + serverinfo: { command: 'info', subcommand: 'server', category: 'Utility' }, + userinfo: { command: 'info', subcommand: 'user', category: 'Utility' }, + + // Bot + ping: { command: 'bot', subcommand: 'ping', category: 'Core' }, + stats: { command: 'bot', subcommand: 'stats', category: 'Core' }, + support: { command: 'bot', subcommand: 'support', category: 'Core' }, + uptime: { command: 'bot', subcommand: 'uptime', category: 'Core' }, + + // Fun + fight: { command: 'fun', subcommand: 'fight', category: 'Fun' }, + flip: { command: 'fun', subcommand: 'flip', category: 'Fun' }, + roll: { command: 'fun', subcommand: 'roll', category: 'Fun' }, + + // Welcome + autorole: { command: 'welcome', group: 'autorole', category: 'Welcome' }, + goodbye: { command: 'welcome', group: 'goodbye', category: 'Welcome' }, + greet: { command: 'welcome', group: 'greet', category: 'Welcome' }, + welcome: { command: 'welcome', group: 'message', category: 'Welcome' }, + + // Verification + autoverify: { command: 'verification', group: 'autoverify', category: 'Verification' }, + + // Tickets + claim: { command: 'tickets', subcommand: 'claim', category: 'Ticket' }, + close: { command: 'tickets', subcommand: 'close', category: 'Ticket' }, + priority: { command: 'tickets', subcommand: 'priority', category: 'Ticket' }, + + // Moderation + ban: { command: 'ban', subcommand: 'user', category: 'Moderation' }, + unban: { command: 'ban', subcommand: 'remove', category: 'Moderation' }, + massban: { command: 'ban', subcommand: 'mass', category: 'Moderation' }, + kick: { command: 'kick', subcommand: 'user', category: 'Moderation' }, + masskick: { command: 'kick', subcommand: 'mass', category: 'Moderation' }, + timeout: { command: 'mod', subcommand: 'timeout', category: 'Moderation' }, + untimeout: { command: 'mod', subcommand: 'untimeout', category: 'Moderation' }, + warn: { command: 'mod', subcommand: 'warn', category: 'Moderation' }, + warnings: { command: 'mod', subcommand: 'warnings', category: 'Moderation' }, + dm: { command: 'mod', subcommand: 'dm', category: 'Moderation' }, + lock: { command: 'channel', subcommand: 'lock', category: 'Moderation' }, + unlock: { command: 'channel', subcommand: 'unlock', category: 'Moderation' }, + purge: { command: 'msg', subcommand: 'purge', category: 'Moderation' }, + say: { command: 'msg', subcommand: 'say', category: 'Moderation' }, + embedbuilder: { command: 'msg', subcommand: 'embed', category: 'Tools' }, + usernotes: { command: 'msg', group: 'notes', category: 'Moderation' }, +}; + +/** First-level names (subcommands or groups) a legacy entry occupies in its new command. */ +function firstLevelName(target: LegacyCommandTarget): string { + return (target.group ?? target.subcommand)!; +} + +const NEW_FIRST_LEVEL_NAMES = new Map>(); +for (const target of Object.values(LEGACY_COMMAND_MAP)) { + const names = NEW_FIRST_LEVEL_NAMES.get(target.command) ?? new Set(); + names.add(firstLevelName(target)); + NEW_FIRST_LEVEL_NAMES.set(target.command, names); +} + +export function getLegacyTarget(name: string): LegacyCommandTarget | null { + return LEGACY_COMMAND_MAP[name.toLowerCase()] ?? null; +} + +/** New path tokens for a legacy target, e.g. ['eco', 'balance'] or ['msg', 'notes']. */ +export function getLegacyPath(target: LegacyCommandTarget): string[] { + return [target.command, firstLevelName(target)]; +} + +/** `/eco balance` style display string for a legacy command name. */ +export function formatLegacyTarget(name: string): string | null { + const target = getLegacyTarget(name); + return target ? `/${getLegacyPath(target).join(' ')}` : null; +} + +/** + * Whether `firstArg` is already a valid first-level name of the new command. + * Used for old names that equal a new top-level name (ban, kick, rank, welcome). + */ +function isNewFirstLevelName(command: string, firstArg: string | undefined): boolean { + return Boolean(firstArg && NEW_FIRST_LEVEL_NAMES.get(command)?.has(firstArg.toLowerCase())); +} + +/** + * Translate a stored access key (old or new naming) to the new naming. + * `balance` -> `eco balance`, `usernotes add` -> `msg notes add`, `welcome setup` -> `welcome message setup`. + * For old names that are also new top-level names, a bare key keeps the new meaning (whole command). + */ +export function translateLegacyAccessKey(key: string): string { + const tokens = key.toLowerCase().trim().split(/\s+/); + const target = LEGACY_COMMAND_MAP[tokens[0]]; + if (!target) { + return tokens.join(' '); + } + + if (target.command === tokens[0]) { + if (tokens.length === 1 || isNewFirstLevelName(target.command, tokens[1])) { + return tokens.join(' '); + } + } + + return [...getLegacyPath(target), ...tokens.slice(1)].join(' '); +} + +/** + * Rewrite a prefix invocation that uses an old command name. + * Returns null when the name is not a legacy name or the args already address the new structure. + */ +export function routeLegacyPrefixCommand( + commandName: string, + args: string[], +): { commandName: string; args: string[] } | null { + const target = getLegacyTarget(commandName); + if (!target) { + return null; + } + + if (target.command === commandName.toLowerCase() && isNewFirstLevelName(target.command, args[0])) { + return null; + } + + return { commandName: target.command, args: [firstLevelName(target), ...args] }; +} + +/** + * Old command name for a new path, e.g. ('mod', null, 'warn') -> 'warn', ('msg', 'notes', 'add') -> 'usernotes'. + * Returns null when the path is not a moved legacy command. + */ +export function resolveLegacyName(command: string, group: string | null, subcommand: string | null): string | null { + const firstLevel = group ?? subcommand; + if (!firstLevel) { + return null; + } + + for (const [legacyName, target] of Object.entries(LEGACY_COMMAND_MAP)) { + if (target.command === command && firstLevelName(target) === firstLevel && Boolean(target.group) === Boolean(group)) { + return legacyName; + } + } + + return null; +} + +/** All legacy names that now live under `command` (for help hints and docs). */ +export function getLegacyNamesFor(command: string): string[] { + return Object.entries(LEGACY_COMMAND_MAP) + .filter(([, target]) => target.command === command) + .map(([legacyName]) => legacyName); +} diff --git a/src/config/commands/prefixRestrictions.ts b/src/config/commands/prefixRestrictions.ts index 56f980d704..1cd7075252 100644 --- a/src/config/commands/prefixRestrictions.ts +++ b/src/config/commands/prefixRestrictions.ts @@ -29,7 +29,6 @@ function isSubcommandOption(opt: APIApplicationCommandOption): opt is APIApplica export const SLASH_ONLY_COMMANDS = new Set([ 'configwizard', 'help', - 'embedbuilder', 'wipedata', 'apply', ]); @@ -55,23 +54,21 @@ export const COMMAND_BLOCKED_SUBCOMMANDS: Record | undefined 'move', 'clear', '247', + 'play', + 'queue', ]), + eco: new Set(['shop']), + msg: new Set(['embed']), birthday: new Set(['setchannel']), report: new Set(['setchannel']), }; function collectSubcommandNames(commandJson: RESTPostAPIChatInputApplicationCommandsJSONBody): string[] { - const subcommandGroup = commandJson.options?.find(isSubcommandGroupOption); - - if (subcommandGroup) { - const names: string[] = []; - for (const group of subcommandGroup.options || []) { - names.push(...(group.options?.map((opt) => opt.name) || [])); - } - return names; + const names = (commandJson.options?.filter(isSubcommandOption) || []).map((sub) => sub.name); + for (const group of commandJson.options?.filter(isSubcommandGroupOption) || []) { + names.push(...(group.options?.map((sub) => sub.name) || [])); } - - return (commandJson.options?.filter(isSubcommandOption) || []).map((sub) => sub.name); + return names; } function isSubcommandBlocked(commandName: string, subcommandName: string | null | undefined): boolean { @@ -118,7 +115,9 @@ export function getPrefixRestriction( const resolvedFirstArg = firstArg ? resolveSubcommandAlias(firstArg) : null; const resolvedSecondArg = secondArg ? resolveSubcommandAlias(secondArg) : null; - const subcommandGroup = commandJson.options?.find(isSubcommandGroupOption); + const matchedGroup = commandJson.options?.find( + (opt): opt is APIApplicationCommandSubcommandGroupOption => isSubcommandGroupOption(opt) && opt.name === firstArg, + ); const allSubcommandNames = collectSubcommandNames(commandJson); const allSubcommandsBlocked = @@ -136,14 +135,14 @@ export function getPrefixRestriction( }; } - if (resolvedFirstArg && isSubcommandBlocked(commandName, resolvedFirstArg)) { + if (firstArg && (isSubcommandBlocked(commandName, firstArg) || isSubcommandBlocked(commandName, resolvedFirstArg))) { return { blocked: true, reason: 'This subcommand is only available as a slash command.', }; } - if (subcommandGroup && resolvedSecondArg && isSubcommandBlocked(commandName, resolvedSecondArg)) { + if (matchedGroup && secondArg && (isSubcommandBlocked(commandName, secondArg) || isSubcommandBlocked(commandName, resolvedSecondArg))) { return { blocked: true, reason: 'This subcommand is only available as a slash command.', diff --git a/src/config/shop/items.ts b/src/config/shop/items.ts index befd3ca480..b07ef4cd74 100644 --- a/src/config/shop/items.ts +++ b/src/config/shop/items.ts @@ -40,7 +40,7 @@ export const shopItems: ShopItem[] = [ id: 'extra_work', name: 'Extra Work Shift', price: 5000, - description: 'Allows 1 extra use of the `/work` command.', + description: 'Allows 1 extra use of the `/eco work` command.', type: 'consumable', maxQuantity: 5, cooldown: 86400000, @@ -66,7 +66,7 @@ cooldown: 86400000, id: 'diamond_pickaxe', name: 'Diamond Pickaxe', price: 50000, - description: 'Increases yield from `/mine`', + description: 'Increases yield from `/eco mine`', type: 'tool', durability: 100, effect: { @@ -90,7 +90,7 @@ roleId: null, id: 'lucky_clover', name: 'Lucky Clover', price: 10000, - description: 'Increases the chance of winning a higher payout on `/gamble` once.', + description: 'Increases the chance of winning a higher payout on `/eco gamble` once.', type: 'consumable', maxQuantity: 10, effect: { diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index a8547f39c6..c065b0819d 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -1,5 +1,5 @@ import { Events, MessageFlags } from 'discord.js'; -import type { GuildTextBasedChannel, Interaction } from 'discord.js'; +import type { ChatInputCommandInteraction, GuildTextBasedChannel, Interaction } from 'discord.js'; import { logger } from '../utils/logger.js'; import { getGuildConfig } from '../services/config/guildConfig.js'; import { @@ -24,7 +24,7 @@ import type { ErrorContext, ErrorLike } from '../utils/errorHandler.js'; import type { InteractionExtras, NexoInteraction } from '../utils/interactionHelper.js'; import type { TraceContext } from '../utils/logger.js'; import type { ReactionRoleMessage } from '../services/reactionRoleService.js'; -import type { BotEvent, GuildConfig } from '../types/index.js'; +import type { BotEvent, Command, GuildConfig, ResolvedRoute } from '../types/index.js'; /** Error context plus the identifiers withTraceContext folds into the trace fields. */ type TraceableContext = ErrorContext & { commandName?: string; guildId?: string | null; userId?: string | null }; @@ -48,6 +48,14 @@ const COMMAND_ERROR_SUBTYPES: Record = { greroll: 'giveaway_failed', }; +/** For consolidated router commands: the old command behind the invoked subcommand. */ +function resolveCommandRoute(command: Command | undefined, interaction: ChatInputCommandInteraction): ResolvedRoute | null { + return command?.resolveRoute?.( + interaction.options.getSubcommandGroup(false), + interaction.options.getSubcommand(false), + ) ?? null; +} + function withTraceContext(context: TraceableContext = {}, traceContext: Partial = {}): ErrorContext { return { traceId: traceContext.traceId, @@ -105,7 +113,11 @@ export default { ); } - if (!isCommandCategoryEnabled(command.category)) { + const route = resolveCommandRoute(command, interaction); + // Bucket name for cooldowns and abuse protection: the old command name, so limits stay per old command. + const protectionName = route?.legacyName ?? interaction.commandName; + + if (!isCommandCategoryEnabled(command.category) || (route && !isCommandCategoryEnabled(route.category))) { throw createError( `Feature disabled for category ${command.category}`, ErrorTypes.CONFIGURATION, @@ -116,7 +128,7 @@ export default { const defaultCooldownSec = Number(botConfig.commands?.defaultCooldown) || 0; if (defaultCooldownSec > 0 && !isBotOwner(interaction.user.id)) { - const cooldownKey = `${interaction.user.id}:${interaction.commandName}`; + const cooldownKey = `${interaction.user.id}:${protectionName}`; const expiresAt = client.cooldowns.get(cooldownKey); if (expiresAt && Date.now() < expiresAt) { @@ -132,7 +144,11 @@ export default { client.cooldowns.set(cooldownKey, Date.now() + defaultCooldownSec * 1000); } - const abuseProtection = await enforceAbuseProtection(interaction, command, interaction.commandName); + const abuseProtection = await enforceAbuseProtection( + interaction, + route ? { category: route.category, abuseProtection: route.module.abuseProtection } : command, + protectionName, + ); if (!abuseProtection.allowed) { const formattedCooldown = formatCooldownDuration(abuseProtection.remainingMs); throw createError( @@ -177,7 +193,7 @@ export default { await handleInteractionError(interaction, error, withTraceContext({ type: 'command', commandName: interaction.commandName, - subtype: COMMAND_ERROR_SUBTYPES[interaction.commandName] || (error as ErrorLike | null | undefined)?.context?.subtype, + subtype: COMMAND_ERROR_SUBTYPES[resolveCommandRoute(client.commands.get(interaction.commandName), interaction)?.legacyName ?? interaction.commandName] || (error as ErrorLike | null | undefined)?.context?.subtype, }, interactionTraceContext)); } } else if (interaction.isAutocomplete()) { diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index fb51979f03..e4f559bd76 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -8,6 +8,7 @@ import { parsePrefixCommand } from '../utils/prefixParser.js'; import { supportsPrefixExecution, executePrefixCommand, resolvePrefixAccessKey } from '../utils/messageAdapter.js'; import { resolveCommandAlias, resolveSubcommandAlias } from '../config/commands/commandAliases.js'; import { getPrefixRestriction } from '../config/commands/prefixRestrictions.js'; +import { routeLegacyPrefixCommand } from '../config/commands/legacyCommandMap.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'; @@ -66,7 +67,13 @@ async function handlePrefixCommand(message: Message, client: NexoClient) { logger.info(`Prefix command detected: ${commandName}, args: ${args.join(', ')}`); - const resolvedCommandName = resolveCommandAlias(commandName); + let resolvedCommandName = resolveCommandAlias(commandName); + // Old command names keep working: `!bal` -> `/eco balance`. + const legacyRoute = routeLegacyPrefixCommand(resolvedCommandName, args); + if (legacyRoute) { + resolvedCommandName = legacyRoute.commandName; + args = legacyRoute.args; + } logger.info(`Resolved command name: ${resolvedCommandName}`); const command = client.commands.get(resolvedCommandName); @@ -86,7 +93,12 @@ async function handlePrefixCommand(message: Message, client: NexoClient) { return; } - if (!isCommandCategoryEnabled(command.category)) { + // Loaded commands always have a name, so the access key is never null here. + const accessKey = resolvePrefixAccessKey(command.data, args)!; + const [, firstLevel = null, secondLevel = null] = accessKey.split(' '); + const route = command.resolveRoute?.(secondLevel ? firstLevel : null, secondLevel ?? firstLevel) ?? null; + + if (!isCommandCategoryEnabled(command.category) || (route && !isCommandCategoryEnabled(route.category))) { await message.channel.send({ embeds: [createEmbed({ title: 'Feature Disabled', @@ -102,7 +114,7 @@ async function handlePrefixCommand(message: Message, client: NexoClient) { if (restriction.blocked && restriction.reason) { const embed = createEmbed({ title: 'Slash Command Only', - description: `${restriction.reason}\nUse \`/${resolvedCommandName}\` instead.`, + description: `${restriction.reason}\nUse \`/${route ? accessKey : resolvedCommandName}\` instead.`, color: 'info', }); await message.channel.send({ embeds: [embed] }).catch(() => {}); @@ -110,8 +122,7 @@ async function handlePrefixCommand(message: Message, client: NexoClient) { return; } - // Loaded commands always have a name, so the access key is never null here. - if (!(await isCommandEnabled(client, message.guild.id, resolvePrefixAccessKey(command.data, args)!, command.category))) { + if (!(await isCommandEnabled(client, message.guild.id, accessKey, command.category))) { const embed = createEmbed({ title: 'Command Disabled', description: 'This command has been disabled for this server.', @@ -127,8 +138,8 @@ async function handlePrefixCommand(message: Message, client: NexoClient) { }; const abuseProtection = await enforceAbuseProtection( mockInteractionForProtection, - command, - resolvedCommandName, + route ? { category: route.category, abuseProtection: route.module.abuseProtection } : command, + route?.legacyName ?? resolvedCommandName, ); if (!abuseProtection.allowed) { const formattedCooldown = formatCooldownDuration(abuseProtection.remainingMs); diff --git a/src/handlers/calculateModals.ts b/src/handlers/calculateModals.ts index 14a10b6343..74b6a8573a 100644 --- a/src/handlers/calculateModals.ts +++ b/src/handlers/calculateModals.ts @@ -20,7 +20,7 @@ async function calculateModalHandler(interaction: ModalSubmitInteraction, client return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Failed to retrieve calculation context.' }); } - const { calculationContexts } = await import('../commands/Tools/calculate.js'); + const { calculationContexts } = await import('../commands/Tools/modules/calculate.js'); const context = calculationContexts.get(contextKey); if (!context) { diff --git a/src/handlers/countdownButtons.ts b/src/handlers/countdownButtons.ts index 3ef4c4e734..2c676d0de3 100644 --- a/src/handlers/countdownButtons.ts +++ b/src/handlers/countdownButtons.ts @@ -1,6 +1,6 @@ import { ActionRowBuilder, ButtonBuilder, ButtonStyle, PermissionFlagsBits } from 'discord.js'; import type { ButtonInteraction, GuildMember } from 'discord.js'; -import type { CountdownData } from '../commands/Tools/countdown.js'; +import type { CountdownData } from '../commands/Tools/modules/countdown.js'; import type { NexoClient } from '../types/index.js'; import { successEmbed } from '../utils/embeds.js'; import { logger } from '../utils/logger.js'; @@ -104,7 +104,7 @@ function cleanupCountdown(countdownId: string, activeCountdowns: Map { try { - const { activeCountdowns } = await import('../commands/Tools/countdown.js'); + const { activeCountdowns } = await import('../commands/Tools/modules/countdown.js'); const action = args[0]; const countdownId = args[1]; diff --git a/src/handlers/help/helpSelectMenus.ts b/src/handlers/help/helpSelectMenus.ts index 384041ee90..af65ba47f1 100644 --- a/src/handlers/help/helpSelectMenus.ts +++ b/src/handlers/help/helpSelectMenus.ts @@ -8,6 +8,7 @@ import { Collection, ActionRowBuilder, MessageFlags } from 'discord.js'; import type { ApplicationCommand, ButtonBuilder, EmbedBuilder } from 'discord.js'; import type { NexoClient, SelectMenuHandler } from '../../types/index.js'; import { logger } from '../../utils/logger.js'; +import { resolveLegacyName } from '../../config/commands/legacyCommandMap.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; const __filename = fileURLToPath(import.meta.url); @@ -79,6 +80,15 @@ function formatCategoryName(rawCategory: string): string { .replace(/\b\w/g, (char) => char.toUpperCase()); } +/** Appends the pre-consolidation name, e.g. "Check your balance (formerly /balance)". */ +function withLegacyHint(description: string, command: string, group: string | null, subcommand: string): string { + const legacyName = resolveLegacyName(command, group, subcommand); + if (!legacyName || legacyName === command) { + return description; + } + return `${description} (formerly /${group ? `${legacyName} ${subcommand}` : legacyName})`; +} + function buildHelpEntries(command: unknown, category: string): HelpEntry[] { const commandData = normalizeCommandData(command); if (!commandData?.name) { @@ -98,7 +108,7 @@ function buildHelpEntries(command: unknown, category: string): HelpEntry[] { entries.push({ baseName, displayName: `${baseName} ${option.name}`, - description: option.description || baseDescription, + description: withLegacyHint(option.description || baseDescription, baseName, null, option.name ?? ""), category, }); continue; @@ -112,7 +122,7 @@ function buildHelpEntries(command: unknown, category: string): HelpEntry[] { entries.push({ baseName, displayName: `${baseName} ${option.name} ${nested.name}`, - description: nested.description || option.description || baseDescription, + description: withLegacyHint(nested.description || option.description || baseDescription, baseName, option.name ?? null, nested.name ?? ""), category, }); } diff --git a/src/handlers/musicButtons.ts b/src/handlers/musicButtons.ts index f23908828a..c1187c5d2d 100644 --- a/src/handlers/musicButtons.ts +++ b/src/handlers/musicButtons.ts @@ -84,7 +84,7 @@ async function handleMusicButton(interaction: ButtonInteraction, client: NexoCli } if (!player) { - return replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'No music is playing. Use `/play` first.' }); + return replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'No music is playing. Use `/music play` first.' }); } if (!canControlMusic(interaction.member as GuildMember, player)) { diff --git a/src/services/commandAccessService.ts b/src/services/commandAccessService.ts index 5113e44c3d..273a3ccb91 100644 --- a/src/services/commandAccessService.ts +++ b/src/services/commandAccessService.ts @@ -7,6 +7,7 @@ import { getCategoryIcon, PROTECTED_COMMANDS, } from '../config/commands/commandCategories.js'; +import { resolveLegacyName, LEGACY_COMMAND_MAP, translateLegacyAccessKey } from '../config/commands/legacyCommandMap.js'; import type { RESTPostAPIChatInputApplicationCommandsJSONBody } from 'discord.js'; import type { GuildConfigContext } from '../utils/database/guildConfigStorage.js'; import type { CommandData, NexoClient } from '../types/index.js'; @@ -51,6 +52,29 @@ function normalizeToggleRecord(raw: unknown): Record { return {}; } +/** + * Stored keys may still use pre-consolidation names (`balance`, `usernotes add`); there is no data + * migration, so they are translated to the new naming on read. + */ +function translateDisabledCommands(record: Record): Record { + const translated: Record = {}; + for (const [key, disabled] of Object.entries(record)) { + if (disabled) { + translated[translateLegacyAccessKey(key)] = true; + } + } + return translated; +} + +/** Remove a key and every stored legacy key that translates to it. */ +function deleteDisabledKey(record: Record, normalizedName: string): void { + for (const key of Object.keys(record)) { + if (key === normalizedName || translateLegacyAccessKey(key) === normalizedName) { + delete record[key]; + } + } +} + export function buildCommandRegistry(client: NexoClient): Map { const categories = new Map(); @@ -136,11 +160,9 @@ export function isProtectedCommand(commandName: string | null | undefined): bool } export function isCommandEnabledInConfig(config: CommandAccessConfig | null | undefined, commandName: string, category: string | null | undefined): boolean { - const normalizedName = String(commandName || '').toLowerCase(); - - // Check if it's a subcommand (contains space) - const isSubcommand = normalizedName.includes(' '); - const baseCommand = isSubcommand ? normalizedName.split(' ')[0] : normalizedName; + const normalizedName = translateLegacyAccessKey(String(commandName || '')); + const tokens = normalizedName.split(' '); + const baseCommand = tokens[0]; const isProtected = isProtectedCommand(baseCommand); // Protected commands and their subcommands should always remain enabled. @@ -148,21 +170,25 @@ export function isCommandEnabledInConfig(config: CommandAccessConfig | null | un return true; } - const disabledCommands = normalizeToggleRecord(config?.disabledCommands); + const disabledCommands = translateDisabledCommands(normalizeToggleRecord(config?.disabledCommands)); const disabledCategories = normalizeToggleRecord(config?.disabledCategories); - // Check if the specific command/subcommand is disabled - if (disabledCommands[normalizedName]) { - return false; + // The command/subcommand itself or any parent path (msg notes add -> msg notes -> msg) + for (let length = tokens.length; length > 0; length -= 1) { + if (disabledCommands[tokens.slice(0, length).join(' ')]) { + return false; + } } - // For subcommands, also check if the base command is disabled - if (isSubcommand && disabledCommands[baseCommand]) { + // Check if the category is disabled + if (disabledCategories[normalizeCategoryKey(category)]) { return false; } - // Check if the category is disabled - if (disabledCategories[normalizeCategoryKey(category)]) { + // Moved commands also follow the category they used to live in (e.g. /tools weather was Utility). + const legacyName = resolveLegacyName(baseCommand, tokens.length > 2 ? tokens[1] : null, tokens.length > 2 ? tokens[2] : tokens[1] ?? null); + const legacyCategory = legacyName ? LEGACY_COMMAND_MAP[legacyName].category : null; + if (legacyCategory && disabledCategories[normalizeCategoryKey(legacyCategory)]) { return false; } @@ -183,7 +209,7 @@ export async function isCommandEnabled(client: NexoClient, guildId: string, comm export function getCommandAccessSnapshot(client: NexoClient, config: CommandAccessConfig | null | undefined) { const registry = buildCommandRegistry(client); - const disabledCommands = normalizeToggleRecord(config?.disabledCommands); + const disabledCommands = translateDisabledCommands(normalizeToggleRecord(config?.disabledCommands)); const disabledCategories = normalizeToggleRecord(config?.disabledCategories); const categories: Array; + isGroup: boolean; + permissions: string | null; + guildOnly: boolean; +} + +function isSubcommand(option: APIApplicationCommandOption): option is APIApplicationCommandSubcommandOption { + return option.type === ApplicationCommandOptionType.Subcommand; +} + +function isGroup(option: APIApplicationCommandOption): option is APIApplicationCommandSubcommandGroupOption { + return option.type === ApplicationCommandOptionType.SubcommandGroup; +} + +function isBasicOption(option: APIApplicationCommandOption): option is APIApplicationCommandBasicOption { + return !isSubcommand(option) && !isGroup(option); +} + +export function createRouterCommand(options: RouterOptions): Command { + const routerPermissions = options.defaultMemberPermissions == null + ? null + : options.defaultMemberPermissions.toString(); + const jsonOptions: APIApplicationCommandOption[] = []; + const builtRoutes: BuiltRoute[] = []; + + for (const route of options.routes) { + const json = route.module.data.toJSON(); + const moduleOptions = json.options ?? []; + const base = { + legacyName: route.legacyName, + module: route.module, + permissions: json.default_member_permissions ?? null, + guildOnly: json.dm_permission === false, + }; + + if (route.spread) { + if (!moduleOptions.every(isSubcommand)) { + throw new Error(`Router ${options.name}: spread route ${route.legacyName} may only contain subcommands`); + } + jsonOptions.push(...moduleOptions); + builtRoutes.push({ + ...base, + category: options.category, + names: new Set(moduleOptions.map((option) => option.name)), + isGroup: false, + }); + continue; + } + + const target = LEGACY_COMMAND_MAP[route.legacyName]; + if (!target || target.command !== options.name) { + throw new Error(`Router ${options.name}: ${route.legacyName} is not mapped to this command`); + } + + if (target.group) { + jsonOptions.push({ + type: ApplicationCommandOptionType.SubcommandGroup, + name: target.group, + description: json.description, + options: moduleOptions.filter(isSubcommand), + }); + } else { + jsonOptions.push({ + type: ApplicationCommandOptionType.Subcommand, + name: target.subcommand!, + description: json.description, + options: moduleOptions.filter(isBasicOption), + }); + } + + builtRoutes.push({ + ...base, + category: target.category, + names: new Set([(target.group ?? target.subcommand)!]), + isGroup: Boolean(target.group), + }); + } + + const names = jsonOptions.map((option) => option.name); + if (names.length > MAX_OPTIONS || new Set(names).size !== names.length) { + throw new Error(`Router ${options.name}: needs at most ${MAX_OPTIONS} unique subcommands/groups, got ${names.join(', ')}`); + } + + const allGuildOnly = builtRoutes.every((route) => route.guildOnly); + const commandJson: RESTPostAPIChatInputApplicationCommandsJSONBody = { + name: options.name, + description: options.description, + options: jsonOptions, + ...(routerPermissions !== null ? { default_member_permissions: routerPermissions } : {}), + ...(allGuildOnly ? { dm_permission: false } : {}), + }; + + const data: CommandData & { description: string } = { + name: options.name, + description: options.description, + toJSON: () => structuredClone(commandJson), + }; + + const findRoute = (group: string | null, subcommand: string | null): BuiltRoute | null => { + if (group) { + return builtRoutes.find((route) => route.isGroup && route.names.has(group)) ?? null; + } + if (subcommand) { + return builtRoutes.find((route) => !route.isGroup && route.names.has(subcommand)) ?? null; + } + return null; + }; + + const dispatch = async ( + handler: 'execute' | 'prefixExecute', + interaction: ChatInputCommandInteraction, + guildConfig: GuildConfig | null, + client: NexoClient, + ): Promise => { + const route = findRoute( + interaction.options.getSubcommandGroup(false), + interaction.options.getSubcommand(false), + ); + + if (!route) { + throw createError( + `No route for /${options.name}`, + ErrorTypes.VALIDATION, + 'Unknown subcommand.', + { commandName: options.name }, + ); + } + + if (route.guildOnly && !allGuildOnly && !interaction.guildId) { + throw createError( + `/${options.name} ${route.legacyName} used outside a guild`, + ErrorTypes.VALIDATION, + 'This command can only be used in a server.', + { commandName: options.name }, + ); + } + + // Old commands that were stricter than the router keep their own permission requirement. + if (route.permissions !== null && route.permissions !== routerPermissions) { + const allowed = await enforceDefaultCommandPermissions( + interaction, + { data: route.module.data, category: route.category }, + { source: 'commandRouter', guildConfig }, + ); + if (!allowed) { + return undefined; + } + } + + const target = handler === 'prefixExecute' && route.module.prefixExecute + ? route.module.prefixExecute + : route.module.execute; + return target.call(route.module, interaction, guildConfig, client); + }; + + return { + data, + slashOnly: options.routes.every((route) => route.module.slashOnly === true) || undefined, + execute: (interaction, guildConfig, client) => dispatch('execute', interaction, guildConfig, client), + prefixExecute: (interaction, guildConfig, client) => dispatch('prefixExecute', interaction, guildConfig, client), + resolveRoute(group, subcommand) { + const route = findRoute(group, subcommand); + return route ? { legacyName: route.legacyName, category: route.category, module: route.module } : null; + }, + }; +} diff --git a/src/utils/prefixParser.ts b/src/utils/prefixParser.ts index 7a28788369..90979ce3b7 100644 --- a/src/utils/prefixParser.ts +++ b/src/utils/prefixParser.ts @@ -155,51 +155,57 @@ export function mapArgumentsToOptions(args: string[], commandData: PrefixCommand }; } - const subcommandGroup = cmdData.options.find((opt) => opt.type === 2); + // Commands may mix direct subcommands and subcommand groups (e.g. /msg purge and /msg notes add). + const groups = cmdData.options.filter((opt) => opt.type === 2); const subcommands = cmdData.options.filter((opt) => opt.type === 1); - const hasSubcommands = subcommands.length > 0 && !subcommandGroup; + const hasSubcommands = subcommands.length > 0; + const hasGroups = groups.length > 0; + let matchedGroup: PrefixOptionDef | null = null; + + // An exact name wins over a subcommand alias (e.g. /fun roll vs. the "roll" -> "reroll" alias). + const findSubcommand = (list: readonly PrefixOptionDef[], raw: string): PrefixOptionDef | undefined => + list.find((s) => s.name === raw.toLowerCase()) ?? list.find((s) => s.name === resolveSubcommandAlias(raw)); + const basicOptions = (sub: PrefixOptionDef): PrefixOptionDef[] => + sub.options?.filter((opt) => opt.type !== 1 && opt.type !== 2) || []; let currentArgs = args; let optionDefs: PrefixOptionDef[] = []; logger.debug( - `Parsing prefix command: commandName=${cmdData.name}, args=${JSON.stringify(args)}, hasSubcommands=${hasSubcommands}, hasSubcommandGroup=${!!subcommandGroup}, optionsCount=${cmdData.options.length}`, + `Parsing prefix command: commandName=${cmdData.name}, args=${JSON.stringify(args)}, hasSubcommands=${hasSubcommands}, hasSubcommandGroup=${hasGroups}, optionsCount=${cmdData.options.length}`, ); - if (subcommandGroup) { - if (args.length > 0) { - subcommandGroupName = args[0].toLowerCase(); - const group = subcommandGroup.options?.find((g) => g.name === subcommandGroupName); - if (group && args.length > 1) { - subcommandName = resolveSubcommandAlias(args[1]); - const sub = group.options?.find((s) => s.name === subcommandName); + if ((hasSubcommands || hasGroups) && args.length > 0) { + const firstArg = args[0].toLowerCase(); + matchedGroup = groups.find((g) => g.name === firstArg) ?? null; + + if (matchedGroup) { + subcommandGroupName = firstArg; + if (args.length > 1) { + const sub = findSubcommand(matchedGroup.options || [], args[1]); if (sub) { - optionDefs = sub.options?.filter((opt) => opt.type !== 1 && opt.type !== 2) || []; + subcommandName = sub.name; + optionDefs = basicOptions(sub); currentArgs = args.slice(2); } else { - logger.debug(`Subcommand ${subcommandName} not found in group ${subcommandGroupName}`); + logger.debug(`Subcommand ${args[1]} not found in group ${subcommandGroupName}`); } - } else if (!group) { - logger.debug(`Subcommand group ${subcommandGroupName} not found`); } - } - } else if (hasSubcommands) { - if (args.length > 0) { - const resolvedSubcommand = resolveSubcommandAlias(args[0]); - logger.debug( - `Looking for subcommand: ${resolvedSubcommand}, available: ${subcommands.map((s) => s.name).join(', ')}`, - ); - const sub = subcommands.find((s) => s.name === resolvedSubcommand); + } else if (hasSubcommands) { + logger.debug(`Looking for subcommand: ${args[0]}, available: ${subcommands.map((s) => s.name).join(', ')}`); + const sub = findSubcommand(subcommands, args[0]); if (sub) { - subcommandName = resolvedSubcommand; - optionDefs = sub.options?.filter((opt) => opt.type !== 1 && opt.type !== 2) || []; + subcommandName = sub.name; + optionDefs = basicOptions(sub); currentArgs = args.slice(1); logger.debug(`Found subcommand ${subcommandName}, optionDefs: ${optionDefs.length}`); } else { - logger.debug(`Subcommand ${resolvedSubcommand} not found`); + logger.debug(`Subcommand ${args[0]} not found`); } + } else { + logger.debug(`Subcommand group ${firstArg} not found`); } - } else { + } else if (!hasSubcommands && !hasGroups) { optionDefs = cmdData.options.filter((opt) => opt.type !== 1 && opt.type !== 2); } @@ -211,7 +217,7 @@ export function mapArgumentsToOptions(args: string[], commandData: PrefixCommand } const missing: MissingPrefixOption[] = []; - if (subcommandName || (!hasSubcommands && !subcommandGroup)) { + if (subcommandName || (!hasSubcommands && !hasGroups)) { for (const opt of optionDefs) { if (opt.required && !options[opt.name]) { missing.push({ @@ -223,29 +229,21 @@ export function mapArgumentsToOptions(args: string[], commandData: PrefixCommand } } - if ((hasSubcommands || subcommandGroup) && !subcommandName && !subcommandGroupName) { - const availableSubcommands = hasSubcommands - ? subcommands.map((s) => s.name).join(',') || 'none' - : subcommandGroup?.options?.map((g) => g.name).join(',') || 'none'; - missing.push({ - name: subcommandGroup ? 'subcommand group' : 'subcommand', - description: `Available: ${availableSubcommands}`, - type: 1, - }); - } else if (hasSubcommands && args.length > 0 && !subcommandName) { - missing.push({ - name: 'subcommand', - description: `Available: ${subcommands.map((s) => s.name).join(', ')}`, - type: 1, - }); - } else if (subcommandGroup && subcommandGroupName && !subcommandName) { - const group = subcommandGroup.options?.find((g) => g.name === subcommandGroupName); - const availableSubcommands = group?.options?.map((s) => s.name).join(',') || 'none'; - missing.push({ - name: 'subcommand', - description: `Available: ${availableSubcommands}`, - type: 1, - }); + if ((hasSubcommands || hasGroups) && !subcommandName) { + if (matchedGroup) { + missing.push({ + name: 'subcommand', + description: `Available: ${matchedGroup.options?.map((s) => s.name).join(', ') || 'none'}`, + type: 1, + }); + } else { + const available = [...subcommands, ...groups].map((s) => s.name).join(', ') || 'none'; + missing.push({ + name: hasSubcommands ? 'subcommand' : 'subcommand group', + description: `Available: ${available}`, + type: 1, + }); + } } return { diff --git a/tests/commandConsolidation.test.ts b/tests/commandConsolidation.test.ts new file mode 100644 index 0000000000..6d29568246 --- /dev/null +++ b/tests/commandConsolidation.test.ts @@ -0,0 +1,111 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { SlashCommandBuilder } from 'discord.js'; +import type { ChatInputCommandInteraction } from 'discord.js'; +import { + resolveLegacyName, + routeLegacyPrefixCommand, + translateLegacyAccessKey, +} from '../src/config/commands/legacyCommandMap.js'; +import { isCommandEnabledInConfig } from '../src/services/commandAccessService.js'; +import { mapArgumentsToOptions } from '../src/utils/prefixParser.js'; +import { createRouterCommand } from '../src/utils/commandRouter.js'; +import type { Command } from '../src/types/index.js'; + +test('stored legacy access keys translate to the new paths', () => { + assert.equal(translateLegacyAccessKey('balance'), 'eco balance'); + assert.equal(translateLegacyAccessKey('usernotes add'), 'msg notes add'); + assert.equal(translateLegacyAccessKey('shop-config setrole'), 'economy shop setrole'); + assert.equal(translateLegacyAccessKey('welcome setup'), 'welcome message setup'); + // Old names that are also new top-level names keep the new meaning. + assert.equal(translateLegacyAccessKey('ban'), 'ban'); + assert.equal(translateLegacyAccessKey('welcome autorole add'), 'welcome autorole add'); + assert.equal(translateLegacyAccessKey('music pause'), 'music pause'); +}); + +test('disabled legacy commands and categories block the new subcommands', () => { + const config = { disabledCommands: { balance: true, usernotes: true }, disabledCategories: { utility: true } }; + assert.equal(isCommandEnabledInConfig(config, 'eco balance', 'Economy'), false); + assert.equal(isCommandEnabledInConfig(config, 'eco work', 'Economy'), true); + assert.equal(isCommandEnabledInConfig(config, 'msg notes add', 'Moderation'), false); + assert.equal(isCommandEnabledInConfig(config, 'msg purge', 'Moderation'), true); + assert.equal(isCommandEnabledInConfig(config, 'tools weather', 'Tools'), false); + assert.equal(isCommandEnabledInConfig(config, 'tools hexcolor', 'Tools'), true); + assert.equal(isCommandEnabledInConfig({ disabledCommands: ['commands'] }, 'commands disable', 'Core'), true); +}); + +test('prefix invocations with old names are routed', () => { + assert.deepEqual(routeLegacyPrefixCommand('balance', ['@x']), { commandName: 'eco', args: ['balance', '@x'] }); + assert.deepEqual(routeLegacyPrefixCommand('ban', ['@x', 'spam']), { commandName: 'ban', args: ['user', '@x', 'spam'] }); + assert.equal(routeLegacyPrefixCommand('ban', ['remove', '@x']), null); + assert.deepEqual(routeLegacyPrefixCommand('welcome', ['setup']), { commandName: 'welcome', args: ['message', 'setup'] }); + assert.deepEqual(routeLegacyPrefixCommand('usernotes', ['view', '@x']), { commandName: 'msg', args: ['notes', 'view', '@x'] }); + assert.equal(routeLegacyPrefixCommand('rank', ['leaderboard']), null); + assert.equal(routeLegacyPrefixCommand('todo', ['add']), null); +}); + +test('new paths resolve back to the old command name', () => { + assert.equal(resolveLegacyName('mod', null, 'warn'), 'warn'); + assert.equal(resolveLegacyName('msg', 'notes', 'add'), 'usernotes'); + assert.equal(resolveLegacyName('eco', null, 'leaderboard'), 'eleaderboard'); + assert.equal(resolveLegacyName('music', null, 'pause'), null); +}); + +function fakeCommand(builder: { toJSON(): ReturnType; name: string }, calls: string[]): Command { + return { + data: builder, + async execute(interaction) { + calls.push(`${builder.name}:${interaction.options.getSubcommand(false) ?? '-'}`); + }, + }; +} + +test('router builds subcommands/groups and dispatches to the old modules', async () => { + const calls: string[] = []; + const purge = fakeCommand(new SlashCommandBuilder().setName('purge').setDescription('Purge') + .addIntegerOption((o) => o.setName('amount').setDescription('n').setRequired(true)), calls); + const notes = fakeCommand(new SlashCommandBuilder().setName('usernotes').setDescription('Notes') + .addSubcommand((s) => s.setName('add').setDescription('Add')) + .addSubcommand((s) => s.setName('view').setDescription('View')), calls); + const router = createRouterCommand({ + name: 'msg', + description: 'Messages', + category: 'Moderation', + routes: [{ legacyName: 'purge', module: purge }, { legacyName: 'usernotes', module: notes }], + }); + + const json = router.data.toJSON(); + assert.deepEqual(json.options?.map((o) => [o.name, o.type]), [['purge', 1], ['notes', 2]]); + + const invoke = (group: string | null, sub: string | null) => ({ + guildId: 'g', + options: { getSubcommandGroup: () => group, getSubcommand: () => sub }, + }) as unknown as ChatInputCommandInteraction; // minimal stand-in for the two option getters the router reads + + await router.execute(invoke(null, 'purge'), null, {} as never); + await router.execute(invoke('notes', 'view'), null, {} as never); + assert.deepEqual(calls, ['purge:purge', 'usernotes:view']); + assert.equal(router.resolveRoute?.('notes', 'add')?.legacyName, 'usernotes'); +}); + +test('prefix parser handles commands mixing subcommands and groups', () => { + const data = { + name: 'msg', + options: [ + { name: 'purge', type: 1, options: [{ name: 'amount', type: 4, required: true }] }, + { name: 'notes', type: 2, options: [{ name: 'view', type: 1, options: [{ name: 'target', type: 6, required: true }] }] }, + ], + }; + const purge = mapArgumentsToOptions(['purge', '10'], data); + assert.equal(purge.getSubcommand(), 'purge'); + assert.equal(purge.getString('amount'), '10'); + + const view = mapArgumentsToOptions(['notes', 'view', '123'], data); + assert.equal(view.getSubcommandGroup(), 'notes'); + assert.equal(view.getSubcommand(), 'view'); + assert.equal(view.validateRequired().valid, true); + + // An exact subcommand name wins over the "roll" -> "reroll" alias. + const fun = mapArgumentsToOptions(['roll'], { name: 'fun', options: [{ name: 'roll', type: 1 }] }); + assert.equal(fun.getSubcommand(), 'roll'); +}); From dfe76388da412e0619b2737143d9a9483f5d2603 Mon Sep 17 00:00:00 2001 From: robertwalter Date: Sat, 17 Oct 2026 17:13:09 +0200 Subject: [PATCH 08/20] feat(i18n): translate all user-facing texts to German Embeds, replies, error messages, command/option descriptions, choice names, buttons, modals and config defaults are German (informal du). Command names, option names, choice values, customIds, DB keys and stored status values are unchanged. Logs stay English. Comparisons against displayed strings (ticket embeds, panel fields, error/footer classification) accept the old English texts as well, so messages posted before the deploy keep working. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 18 +- README.md | 2 +- roadmap.txt | 78 ++--- src/commands/Birthday/birthday.ts | 24 +- .../Birthday/modules/birthday_info.ts | 10 +- .../Birthday/modules/birthday_list.ts | 16 +- .../Birthday/modules/birthday_remove.ts | 8 +- src/commands/Birthday/modules/birthday_set.ts | 4 +- .../Birthday/modules/birthday_setchannel.ts | 16 +- .../Birthday/modules/next_birthdays.ts | 28 +- src/commands/Community/app-admin.ts | 136 ++++----- src/commands/Community/apply.ts | 94 +++--- .../Community/modules/app_dashboard.ts | 284 +++++++++--------- src/commands/Core/bot.ts | 2 +- src/commands/Core/commands.ts | 36 +-- src/commands/Core/configWizard.ts | 180 +++++------ src/commands/Core/help.ts | 72 ++--- .../Core/modules/commands_dashboard.ts | 76 ++--- src/commands/Core/modules/ping.ts | 18 +- src/commands/Core/modules/stats.ts | 10 +- src/commands/Core/modules/support.ts | 8 +- src/commands/Core/modules/uptime.ts | 6 +- src/commands/Economy/eco.ts | 2 +- src/commands/Economy/economy.ts | 2 +- src/commands/Economy/modules/balance.ts | 18 +- src/commands/Economy/modules/beg.ts | 28 +- src/commands/Economy/modules/buy.ts | 36 +-- src/commands/Economy/modules/crime.ts | 40 +-- src/commands/Economy/modules/daily.ts | 18 +- src/commands/Economy/modules/deposit.ts | 26 +- src/commands/Economy/modules/economy.ts | 4 +- .../Economy/modules/economy_dashboard.ts | 110 +++---- src/commands/Economy/modules/eleaderboard.ts | 10 +- src/commands/Economy/modules/fish.ts | 54 ++-- src/commands/Economy/modules/gamble.ts | 28 +- src/commands/Economy/modules/inventory.ts | 8 +- src/commands/Economy/modules/mine.ts | 26 +- src/commands/Economy/modules/pay.ts | 32 +- src/commands/Economy/modules/rob.ts | 32 +- src/commands/Economy/modules/shop-config.ts | 6 +- src/commands/Economy/modules/shop.ts | 2 +- src/commands/Economy/modules/shop_browse.ts | 14 +- .../Economy/modules/shop_config_setrole.ts | 6 +- src/commands/Economy/modules/slut.ts | 60 ++-- src/commands/Economy/modules/withdraw.ts | 18 +- src/commands/Economy/modules/work.ts | 34 +-- src/commands/Fun/count.ts | 62 ++-- src/commands/Fun/fun.ts | 2 +- src/commands/Fun/modules/fight.ts | 28 +- src/commands/Fun/modules/flip.ts | 10 +- src/commands/Fun/modules/roll.ts | 16 +- src/commands/Giveaway/giveaway.ts | 2 +- src/commands/Giveaway/modules/gcreate.ts | 32 +- src/commands/Giveaway/modules/gdelete.ts | 42 +-- src/commands/Giveaway/modules/gend.ts | 36 +-- src/commands/Giveaway/modules/greroll.ts | 58 ++-- src/commands/JoinToCreate/jointocreate.ts | 182 +++++------ .../JoinToCreate/modules/config_setup.ts | 152 +++++----- src/commands/JoinToCreate/modules/setup.ts | 20 +- src/commands/Leveling/level.ts | 2 +- src/commands/Leveling/modules/leaderboard.ts | 16 +- src/commands/Leveling/modules/level.ts | 40 +-- .../Leveling/modules/level_dashboard.ts | 182 +++++------ src/commands/Leveling/modules/leveladd.ts | 16 +- src/commands/Leveling/modules/levelremove.ts | 18 +- src/commands/Leveling/modules/levelset.ts | 16 +- src/commands/Leveling/modules/rank.ts | 14 +- src/commands/Leveling/rank.ts | 2 +- src/commands/Logging/logging.ts | 22 +- .../Logging/modules/logging_channel.ts | 22 +- .../Logging/modules/logging_dashboard.ts | 70 ++--- src/commands/Moderation/ban.ts | 2 +- src/commands/Moderation/cases.ts | 34 +-- src/commands/Moderation/channel.ts | 2 +- src/commands/Moderation/kick.ts | 2 +- src/commands/Moderation/mod.ts | 2 +- src/commands/Moderation/modules/ban.ts | 18 +- src/commands/Moderation/modules/dm.ts | 26 +- src/commands/Moderation/modules/kick.ts | 20 +- src/commands/Moderation/modules/lock.ts | 12 +- src/commands/Moderation/modules/massban.ts | 34 +-- src/commands/Moderation/modules/masskick.ts | 34 +-- src/commands/Moderation/modules/purge.ts | 16 +- src/commands/Moderation/modules/say.ts | 18 +- src/commands/Moderation/modules/timeout.ts | 38 +-- src/commands/Moderation/modules/unban.ts | 16 +- src/commands/Moderation/modules/unlock.ts | 12 +- src/commands/Moderation/modules/untimeout.ts | 10 +- src/commands/Moderation/modules/usernotes.ts | 86 +++--- src/commands/Moderation/modules/warn.ts | 16 +- src/commands/Moderation/modules/warnings.ts | 22 +- src/commands/Moderation/msg.ts | 2 +- src/commands/Music/modules/join.ts | 2 +- src/commands/Music/modules/music.ts | 50 +-- src/commands/Music/modules/nowplaying.ts | 2 +- src/commands/Music/modules/play.ts | 4 +- src/commands/Music/modules/queue.ts | 4 +- src/commands/Music/music.ts | 2 +- src/commands/Reaction_roles/reactroles.ts | 226 +++++++------- src/commands/Search/modules/search_define.ts | 10 +- src/commands/Search/modules/search_google.ts | 6 +- src/commands/Search/modules/search_urban.ts | 18 +- src/commands/Search/search.ts | 16 +- .../ServerStats/modules/serverstats_create.ts | 20 +- .../ServerStats/modules/serverstats_delete.ts | 32 +- .../ServerStats/modules/serverstats_list.ts | 38 +-- .../ServerStats/modules/serverstats_update.ts | 18 +- src/commands/ServerStats/serverstats.ts | 40 +-- src/commands/Ticket/modules/claim.ts | 10 +- src/commands/Ticket/modules/close.ts | 14 +- src/commands/Ticket/modules/priority.ts | 24 +- .../Ticket/modules/ticket_dashboard.ts | 246 +++++++-------- src/commands/Ticket/ticket.ts | 72 ++--- src/commands/Ticket/tickets.ts | 2 +- src/commands/Tools/modules/baseconvert.ts | 48 +-- src/commands/Tools/modules/calculate.ts | 58 ++-- src/commands/Tools/modules/countdown.ts | 19 +- src/commands/Tools/modules/embedbuilder.ts | 232 +++++++------- .../Tools/modules/generatepassword.ts | 32 +- src/commands/Tools/modules/hexcolor.ts | 48 +-- src/commands/Tools/modules/poll.ts | 37 +-- src/commands/Tools/modules/randomuser.ts | 48 +-- src/commands/Tools/modules/shorten.ts | 24 +- src/commands/Tools/modules/time.ts | 16 +- src/commands/Tools/modules/unixtime.ts | 14 +- src/commands/Tools/tools.ts | 2 +- src/commands/Utility/info.ts | 2 +- src/commands/Utility/modules/avatar.ts | 8 +- src/commands/Utility/modules/firstmsg.ts | 8 +- src/commands/Utility/modules/report.ts | 20 +- .../Utility/modules/report_setchannel.ts | 8 +- src/commands/Utility/modules/serverinfo.ts | 14 +- src/commands/Utility/modules/userinfo.ts | 22 +- src/commands/Utility/modules/weather.ts | 38 +-- src/commands/Utility/report.ts | 14 +- src/commands/Utility/todo.ts | 128 ++++---- src/commands/Utility/wipedata.ts | 24 +- .../Verification/modules/autoVerify.ts | 36 +-- .../modules/autoVerifyDashboard.ts | 116 +++---- .../Verification/modules/verification.ts | 48 +-- .../modules/verification_dashboard.ts | 120 ++++---- src/commands/Verification/verification.ts | 2 +- src/commands/Verification/verify.ts | 8 +- src/commands/Welcome/modules/autorole.ts | 48 +-- src/commands/Welcome/modules/goodbye.ts | 38 +-- src/commands/Welcome/modules/greet.ts | 8 +- .../Welcome/modules/greet_dashboard.ts | 168 +++++------ src/commands/Welcome/modules/welcome.ts | 34 +-- src/commands/Welcome/welcome.ts | 2 +- src/config/bot.ts | 68 ++--- src/config/commands/commandCategories.ts | 37 ++- src/config/commands/prefixRestrictions.ts | 12 +- src/config/guild/guildConfigDefaults.ts | 2 +- src/config/shop/index.ts | 22 +- src/config/shop/items.ts | 52 ++-- src/events/guildMemberAdd.ts | 18 +- src/events/guildMemberRemove.ts | 18 +- src/events/guildMemberUpdate.ts | 8 +- src/events/interactionCreate.ts | 22 +- src/events/messageCreate.ts | 18 +- src/events/messageDelete.ts | 22 +- src/events/messageUpdate.ts | 18 +- src/events/roleCreate.ts | 4 +- src/events/roleDelete.ts | 4 +- src/events/userUpdate.ts | 12 +- src/events/voiceStateUpdate.ts | 10 +- src/handlers/calculateModals.ts | 22 +- src/handlers/countdownButtons.ts | 26 +- src/handlers/counterButtons.ts | 12 +- src/handlers/giveawayButtons.ts | 74 ++--- src/handlers/help/helpButtons.ts | 22 +- src/handlers/help/helpSelectMenus.ts | 57 +--- .../reactionRolesSelectMenu.ts | 26 +- src/handlers/loggingButtons.ts | 72 ++--- src/handlers/musicButtons.ts | 8 +- src/handlers/ticketButtons.ts | 120 ++++---- src/handlers/todoButtons.ts | 100 +++--- src/handlers/verificationButtons.ts | 8 +- src/handlers/warningHandlers.ts | 44 +-- src/handlers/wipedataButtons.ts | 18 +- .../buttons/ticket/ticketFeedback.ts | 48 +-- .../modals/config/config_modal.ts | 20 +- .../modals/ticket/ticketFeedbackComment.ts | 20 +- .../selectMenus/ticket/ticketFeedback.ts | 34 +-- src/services/applicationService.ts | 48 +-- src/services/birthdayService.ts | 22 +- src/services/commandAccessService.ts | 18 +- src/services/config/configService.ts | 54 ++-- src/services/config/guildConfig.ts | 14 +- src/services/countingGameService.ts | 28 +- src/services/economyService.ts | 38 +-- src/services/giveawayService.ts | 70 ++--- src/services/joinToCreateService.ts | 72 ++--- src/services/leveling/levelRoleSyncService.ts | 2 +- src/services/leveling/leveling.ts | 48 +-- src/services/leveling/xpSystem.ts | 14 +- src/services/loggingService.ts | 55 +++- src/services/moderation/moderationService.ts | 83 ++--- src/services/moderation/warningService.ts | 4 +- src/services/music/musicActions.ts | 112 +++---- src/services/music/musicEmbeds.ts | 56 ++-- src/services/music/musicVoiceState.ts | 4 +- src/services/music/permissions.ts | 2 +- src/services/music/playerHandler.ts | 2 +- src/services/reactionRoleService.ts | 38 +-- src/services/serverstatsService.ts | 22 +- src/services/ticket.ts | 156 +++++----- src/services/verificationService.ts | 58 ++-- src/utils/abuseProtection.ts | 2 +- src/utils/commandInputValidation.ts | 2 +- src/utils/commandRouter.ts | 4 +- src/utils/components.ts | 6 +- src/utils/constants.ts | 10 +- src/utils/dashboardSession.ts | 8 +- src/utils/database.ts | 38 +-- src/utils/database/guildConfigStorage.ts | 4 +- src/utils/economy.ts | 80 ++--- src/utils/embeds.ts | 39 +-- src/utils/errorHandler.ts | 113 ++++--- src/utils/giveaways.ts | 12 +- src/utils/logging/logEmbeds.ts | 26 +- src/utils/logging/loggingUi.ts | 58 ++-- src/utils/messageAdapter.ts | 4 +- src/utils/moderation.ts | 31 +- src/utils/panelStatus.ts | 16 +- src/utils/permissionGuard.ts | 10 +- src/utils/prefixParser.ts | 6 +- src/utils/responseCoordinator.ts | 4 +- src/utils/schemas.ts | 2 +- src/utils/ticket/ticketLogging.ts | 65 ++-- src/utils/welcome.ts | 14 +- 231 files changed, 4167 insertions(+), 4057 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 37a17382b5..7443ace2d4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,17 +29,21 @@ Before starting large features, open an issue on [GitHub](https://github.com/Rob ```bash cp .env.example .env ``` - At minimum, set `DISCORD_TOKEN`, `CLIENT_ID`, and `GUILD_ID` for single-server development. See [README.md](README.md#manual-installation-steps) for full configuration options. -4. Verify your database setup (when using PostgreSQL): + At minimum, set `DISCORD_TOKEN`, `CLIENT_ID`, and `GUILD_ID` for single-server development. See [README.md](README.md#umgebungsvariablen) for full configuration options. +4. Build and verify your database setup (when using PostgreSQL): ```bash + npm run build npm run migrate:check ``` -5. Start the bot: +5. Start the bot in watch mode (runs the TypeScript sources via tsx): ```bash - npm start + npm run dev ``` + Production-style start: `npm run build && npm start`. -For Docker-based setup, see [README.md](README.md#docker-deployment-recommended). +Before opening a pull request, run `npm run typecheck`, `npm test` and `npm run build`. + +For Docker-based setup, see [README.md](README.md#docker). ## Development Workflow @@ -71,7 +75,9 @@ Test features that read or write guild data with **both** PostgreSQL and the mem ## Code Guidelines -- **Match existing style** — ES modules (`import`/`export`), async/await, and the conventions used in neighboring files. +- **Match existing style** — strict TypeScript (no `any`), ES modules with `.js` import specifiers, async/await, and the conventions used in neighboring files. +- **Commands** — top-level commands live in `src/commands//`, subcommand implementations in `modules/`. Consolidated commands are built with `createRouterCommand`; moved command names go into `src/config/commands/legacyCommandMap.ts`. +- **Language** — user-facing texts are German (informal "du"); code, comments and logs are English. - **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 (`guild:{guildId}:...` keys, `interaction.guildId`). - **Keep changes minimal** — prefer extending existing utilities and services over duplicating logic. diff --git a/README.md b/README.md index 82b63c770a..3913fe2c08 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ npm run build npm start ``` -Für die Entwicklung startet `npm run dev` den Bot mit automatischem Neuladen direkt aus den TypeScript-Quellen. `npm run typecheck` prüft die Typen ohne Build. +Für die Entwicklung startet `npm run dev` den Bot mit automatischem Neuladen direkt aus den TypeScript-Quellen. `npm run typecheck` prüft die Typen ohne Build. `npm test` führt die Tests aus. Eine lokale Datenbank lässt sich am einfachsten per Docker starten: diff --git a/roadmap.txt b/roadmap.txt index ec78c506eb..9b3b346617 100644 --- a/roadmap.txt +++ b/roadmap.txt @@ -5,50 +5,50 @@ Goal - Show main folders and important files only (not every file). Root -- index.js -> process entry point -- package.json -> scripts, dependencies, project metadata -- README.md -> setup and usage docs +- package.json -> scripts (build, start, dev, typecheck, test, migrate, backup), dependencies +- tsconfig.json -> TypeScript config (strict, NodeNext, src -> dist) +- Dockerfile -> multi-stage build (tsc) + slim runtime (node dist/app.js) +- README.md -> setup and usage docs (German) - SECURITY.md -> security policy -- src/scripts/ -> maintenance scripts (backup, restore, migrate), run from dist/scripts +- docs/COMMAND_MIGRATION.md -> old -> new command mapping (99 -> 36) +- tests/ -> node:test checks (npm test, run via tsx) +- lavalink/ -> Lavalink node list and local Lavalink config - logs/ -> runtime log output -- src/ -> main bot application code -- tests/ -> unit tests (run via npm test) +- dist/ -> compiled output (git-ignored) src/ -- app.js -> creates client, initializes systems, starts web server - -- commands/ -> slash command categories by feature - - Core/ -> help, ping, stats, uptime, invite - - Community/ -> application management + dashboards - - Logging/ -> audit logging + event management - - modules/logging_dashboard.js - - modules/logging_setchannel.js - - Ticket/ -> support ticket system + dashboard - - ServerStats/ -> server stats channel system - - Economy/ -> economy system - - Moderation/ -> moderation/admin commands - - Verification/ -> member verification flow - - JoinToCreate/ -> dynamic voice channel creation - - Reaction_roles/ -> automatic role assignment - - Birthday/, Fun/, Giveaway/, Leveling/, Search/, Tools/, Utility/, Welcome/ - -- config/ -> centralized configuration modules -- events/ -> Discord event listeners -- handlers/ -> interaction routing and component handlers - - commandLoader.js -> loads slash commands at startup - - events.js -> Discord event listener registration - - interactions.js -> button/select/modal router - - ticketButtons.js, reactionRoles.js, etc. - -- interactions/ -> button, modal, and select menu handlers +- app.ts -> NexoBot client, startup, web server (/health, /ready), cron jobs +- types/index.ts -> shared types (Command, BotEvent, handlers, Client augmentation, GuildConfig) + +- commands/ -> slash commands by category folder + - /.ts -> top-level command (router or standalone) + - /modules/ -> subcommand implementations (old top-level commands live here) + - Economy/ (eco, economy), Music/ (music), Giveaway/ (giveaway), Leveling/ (level, rank), + Tools/ (tools), Utility/ (info, todo, report, wipedata), Core/ (bot, help, commands, configwizard), + Fun/ (fun, count), Welcome/ (welcome), Verification/ (verification, verify), Ticket/ (tickets, ticket), + Moderation/ (ban, kick, mod, channel, msg, cases), Community/, Birthday/, JoinToCreate/, + Logging/, Reaction_roles/, Search/, ServerStats/ + +- config/ -> centralized configuration + - bot.ts -> single source of truth for theming/colors and core bot config + - env.ts -> zod schema for environment variables + - commands/legacyCommandMap.ts -> old command names -> new paths (access, prefix, help, docs) + - commands/ -> aliases, categories, prefix restrictions + - database/ -> Postgres config and schema version + +- events/ -> Discord event listeners (interactionCreate, messageCreate, ...) +- handlers/ -> component handlers and loaders + - loaders/commandLoader.ts -> loads and registers slash commands + - loaders/events.ts -> event registration + - loaders/interactions.ts -> button/select/modal registry +- interactions/ -> button, modal and select menu handler modules - services/ -> business logic + data operations - utils/ -> shared helpers - - database.js -> facade re-exporting database modules - - database/wrapper.js -> DatabaseWrapper, get/set helpers - - database/keys.js -> Redis-style key builders - - database/tickets.js -> ticket persistence + guild ticket stats - - panelStatus.js -> shared panel message detection - - errorHandler.js, logger.js, embeds.js, etc. + - commandRouter.ts -> builds consolidated commands from old command modules + - database.ts -> facade re-exporting database modules + - database/wrapper.ts -> DatabaseWrapper, typed key-value API + - errorHandler.ts, logger.ts, embeds.ts, interactionHelper.ts, ... +- scripts/ -> maintenance scripts (backup, restore, migrate), run from dist/scripts Database - PostgreSQL (primary) with in-memory fallback for development @@ -56,4 +56,4 @@ Database Notes - Modular by feature area: commands -> handlers -> services -> utils -- bot.js is the single source of truth for embed theming/colors and core bot config +- User-facing texts are German; code, comments and logs are English diff --git a/src/commands/Birthday/birthday.ts b/src/commands/Birthday/birthday.ts index 8fa638cae4..36bbf28355 100644 --- a/src/commands/Birthday/birthday.ts +++ b/src/commands/Birthday/birthday.ts @@ -14,15 +14,15 @@ import type { Command } from '../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('birthday') - .setDescription('Birthday system commands') + .setDescription('Commands für das Geburtstagssystem') .addSubcommand(subcommand => subcommand .setName('set') - .setDescription('Set your birthday') + .setDescription('Lege deinen Geburtstag fest') .addIntegerOption(option => option .setName('month') - .setDescription('Birth month (1-12)') + .setDescription('Geburtsmonat (1-12)') .setRequired(true) .setMinValue(1) .setMaxValue(12) @@ -30,7 +30,7 @@ export default { .addIntegerOption(option => option .setName('day') - .setDescription('Birth day (1-31)') + .setDescription('Tag des Geburtstags (1-31)') .setRequired(true) .setMinValue(1) .setMaxValue(31) @@ -39,37 +39,37 @@ export default { .addSubcommand(subcommand => subcommand .setName('info') - .setDescription('View birthday information') + .setDescription('Geburtstagsinfos anzeigen') .addUserOption(option => option .setName('user') - .setDescription('User to check birthday for') + .setDescription('Nutzer, dessen Geburtstag du sehen willst') .setRequired(false) ) ) .addSubcommand(subcommand => subcommand .setName('list') - .setDescription('List all birthdays in the server') + .setDescription('Alle Geburtstage auf dem Server anzeigen') ) .addSubcommand(subcommand => subcommand .setName('remove') - .setDescription('Remove your birthday') + .setDescription('Deinen Geburtstag entfernen') ) .addSubcommand(subcommand => subcommand .setName('next') - .setDescription('Show upcoming birthdays') + .setDescription('Anstehende Geburtstage anzeigen') ) .addSubcommand(subcommand => subcommand .setName('setchannel') - .setDescription('Set or disable the channel for birthday announcements. (Manage Server required)') + .setDescription('Kanal für Geburtstagsankündigungen setzen oder deaktivieren. („Server verwalten“ nötig)') .addChannelOption(option => option .setName('channel') - .setDescription('The text channel for announcements. Leave empty to disable.') + .setDescription('Der Textkanal für Ankündigungen. Leer lassen zum Deaktivieren.') .addChannelTypes(ChannelType.GuildText) .setRequired(false) ) @@ -92,7 +92,7 @@ export default { case 'setchannel': return await birthdaySetchannel.execute(interaction, config, client); default: - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Unknown subcommand' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Unbekannter Subcommand' }); } } } satisfies Command; \ No newline at end of file diff --git a/src/commands/Birthday/modules/birthday_info.ts b/src/commands/Birthday/modules/birthday_info.ts index a60f3a23a1..15d14a3655 100644 --- a/src/commands/Birthday/modules/birthday_info.ts +++ b/src/commands/Birthday/modules/birthday_info.ts @@ -17,10 +17,10 @@ export default { if (!birthdayData) { const embed = new EmbedBuilder() .setColor(0xFF0000) - .setTitle('No Birthday Found') + .setTitle('Kein Geburtstag gefunden') .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.`); + ? "Du hast deinen Geburtstag noch nicht eingetragen. Nutze `/birthday set`, um ihn hinzuzufügen!" + : `${targetUser.username} hat noch keinen Geburtstag eingetragen.`); return await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); @@ -28,8 +28,8 @@ export default { const embed = new EmbedBuilder() .setColor(0x00FF00) - .setTitle('Birthday Information') - .setDescription(`**Date:** ${birthdayData.monthName} ${birthdayData.day}\n**User:** ${targetUser.toString()}`); + .setTitle('Geburtstagsinfo') + .setDescription(`**Datum:** ${birthdayData.day}. ${birthdayData.monthName}\n**Nutzer:** ${targetUser.toString()}`); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] diff --git a/src/commands/Birthday/modules/birthday_list.ts b/src/commands/Birthday/modules/birthday_list.ts index 785b24c3c7..7974e15051 100644 --- a/src/commands/Birthday/modules/birthday_list.ts +++ b/src/commands/Birthday/modules/birthday_list.ts @@ -16,8 +16,8 @@ export default { if (sortedBirthdays.length === 0) { const embed = new EmbedBuilder() .setColor(0xFF0000) - .setTitle('No Birthdays') - .setDescription('No birthdays have been set in this server yet.'); + .setTitle('Keine Geburtstage') + .setDescription('Auf diesem Server wurden noch keine Geburtstage eingetragen.'); return await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); @@ -36,7 +36,7 @@ export default { continue; } displayIndex++; - birthdayList += `${displayIndex}. <@${birthday.userId}> - ${birthday.monthName} ${birthday.day}\n`; + birthdayList += `${displayIndex}. <@${birthday.userId}> - ${birthday.day}. ${birthday.monthName}\n`; } if (fetchedMembers && staleUserIds.length > 0) { @@ -48,19 +48,19 @@ export default { if (displayIndex === 0) { const embed = new EmbedBuilder() .setColor(0xFF0000) - .setTitle('No Birthdays') - .setDescription('No birthdays have been set by current server members.'); + .setTitle('Keine Geburtstage') + .setDescription('Keines der aktuellen Mitglieder hat einen Geburtstag eingetragen.'); return await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); } - birthdayList = `**${displayIndex} birthday${displayIndex !== 1 ? 's' : ''} in ${interaction.guild!.name}**\n\n` + birthdayList; + birthdayList = `**${displayIndex} Geburtstag${displayIndex !== 1 ? 'e' : ''} auf ${interaction.guild!.name}**\n\n` + birthdayList; const embed = new EmbedBuilder() .setColor(0x00FF00) - .setTitle('Server Birthdays') - .setDescription(`${birthdayList}\n\nTotal: ${displayIndex} birthday${displayIndex !== 1 ? 's' : ''}`); + .setTitle('Geburtstage auf dem Server') + .setDescription(`${birthdayList}\n\nGesamt: ${displayIndex} Geburtstag${displayIndex !== 1 ? 'e' : ''}`); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] diff --git a/src/commands/Birthday/modules/birthday_remove.ts b/src/commands/Birthday/modules/birthday_remove.ts index 53f83c0f3d..8e8112b747 100644 --- a/src/commands/Birthday/modules/birthday_remove.ts +++ b/src/commands/Birthday/modules/birthday_remove.ts @@ -15,8 +15,8 @@ export default { 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.'); + .setTitle('Kein Geburtstag gefunden') + .setDescription('Du hast keinen Geburtstag eingetragen, den du entfernen könntest.'); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); @@ -25,8 +25,8 @@ export default { const embed = new EmbedBuilder() .setColor(0x00FF00) - .setTitle('Birthday Removed') - .setDescription('Your birthday has been successfully removed from the server.'); + .setTitle('Geburtstag entfernt') + .setDescription('Dein Geburtstag wurde vom Server entfernt.'); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); diff --git a/src/commands/Birthday/modules/birthday_set.ts b/src/commands/Birthday/modules/birthday_set.ts index c47ac0aed1..d5272100d6 100644 --- a/src/commands/Birthday/modules/birthday_set.ts +++ b/src/commands/Birthday/modules/birthday_set.ts @@ -16,8 +16,8 @@ export default { const embed = new EmbedBuilder() .setColor(0x00FF00) - .setTitle('Birthday Set!') - .setDescription(`Your birthday has been set to **${result.data.monthName} ${result.data.day}**!`); + .setTitle('Geburtstag gespeichert!') + .setDescription(`Dein Geburtstag ist jetzt der **${result.data.day}. ${result.data.monthName}**!`); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] diff --git a/src/commands/Birthday/modules/birthday_setchannel.ts b/src/commands/Birthday/modules/birthday_setchannel.ts index d18f8b4e30..b676e90e09 100644 --- a/src/commands/Birthday/modules/birthday_setchannel.ts +++ b/src/commands/Birthday/modules/birthday_setchannel.ts @@ -9,8 +9,8 @@ export default { if (!(interaction.member as GuildMember).permissions.has(PermissionsBitField.Flags.ManageGuild)) { const embed = new EmbedBuilder() .setColor(0xFF0000) - .setTitle('Permission Denied') - .setDescription('You need **Manage Server** permissions to configure the birthday channel.'); + .setTitle('Keine Berechtigung') + .setDescription('Du brauchst die Berechtigung **Server verwalten**, um den Geburtstagskanal einzurichten.'); return InteractionHelper.safeReply(interaction, { embeds: [embed], flags: MessageFlags.Ephemeral, @@ -27,8 +27,8 @@ export default { await setGuildConfig(client, guildId, guildConfig); const embed = new EmbedBuilder() .setColor(0x00FF00) - .setTitle('Birthday Announcements Enabled') - .setDescription(`Birthday announcements will now be posted in ${channel}.`); + .setTitle('Geburtstagsankündigungen aktiviert') + .setDescription(`Geburtstagsankündigungen werden ab jetzt in ${channel} gepostet.`); return InteractionHelper.safeReply(interaction, { embeds: [embed], flags: MessageFlags.Ephemeral, @@ -38,8 +38,8 @@ export default { await setGuildConfig(client, guildId, guildConfig); const embed = new EmbedBuilder() .setColor(0xFFFF00) - .setTitle('Birthday Announcements Disabled') - .setDescription('No channel provided — birthday announcements have been disabled.'); + .setTitle('Geburtstagsankündigungen deaktiviert') + .setDescription('Kein Kanal angegeben – Geburtstagsankündigungen wurden deaktiviert.'); return InteractionHelper.safeReply(interaction, { embeds: [embed], flags: MessageFlags.Ephemeral, @@ -49,8 +49,8 @@ export default { logger.error('birthday_setchannel error:', error); const embed = new EmbedBuilder() .setColor(0xFF0000) - .setTitle('⚠️ Configuration Error') - .setDescription('Could not save the birthday channel configuration.'); + .setTitle('⚠️ Konfigurationsfehler') + .setDescription('Die Einstellung für den Geburtstagskanal konnte nicht gespeichert werden.'); return InteractionHelper.safeReply(interaction, { embeds: [embed], flags: MessageFlags.Ephemeral, diff --git a/src/commands/Birthday/modules/next_birthdays.ts b/src/commands/Birthday/modules/next_birthdays.ts index 57e5107697..5dc424c676 100644 --- a/src/commands/Birthday/modules/next_birthdays.ts +++ b/src/commands/Birthday/modules/next_birthdays.ts @@ -14,8 +14,8 @@ export default { 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!'); + .setTitle('Keine Geburtstage gefunden') + .setDescription('Auf diesem Server sind noch keine Geburtstage eingetragen. Nutze `/birthday set`, um deinen hinzuzufügen!'); return await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); @@ -32,25 +32,25 @@ export default { let timeUntil = ''; if (birthday.daysUntil === 0) { - timeUntil = '🎉 **Today!**'; + timeUntil = '🎉 **Heute!**'; } else if (birthday.daysUntil === 1) { - timeUntil = '📅 **Tomorrow!**'; + timeUntil = '📅 **Morgen!**'; } else { - timeUntil = `In ${birthday.daysUntil} day${birthday.daysUntil > 1 ? 's' : ''}`; + timeUntil = `In ${birthday.daysUntil} Tag${birthday.daysUntil > 1 ? 'en' : ''}`; } } if (displayIndex === 0) { const embed = new EmbedBuilder() .setColor(0xFF0000) - .setTitle('No Upcoming Birthdays') - .setDescription('No upcoming birthdays found for current server members.'); + .setTitle('Keine anstehenden Geburtstage') + .setDescription('Für die aktuellen Mitglieder stehen keine Geburtstage an.'); return await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); } - let birthdayList = `🎂 **Next 5 Upcoming Birthdays**\n\nHere are the next 5 birthdays in ${interaction.guild!.name}:\n\n`; + let birthdayList = `🎂 **Die nächsten 5 Geburtstage**\n\nDas sind die nächsten 5 Geburtstage auf ${interaction.guild!.name}:\n\n`; displayIndex = 0; for (const birthday of next5) { const member = await interaction.guild!.members.fetch(birthday.userId).catch(() => null); @@ -61,21 +61,21 @@ export default { let timeUntil = ''; if (birthday.daysUntil === 0) { - timeUntil = '🎉 **Today!**'; + timeUntil = '🎉 **Heute!**'; } else if (birthday.daysUntil === 1) { - timeUntil = '📅 **Tomorrow!**'; + timeUntil = '📅 **Morgen!**'; } else { - timeUntil = `In ${birthday.daysUntil} day${birthday.daysUntil > 1 ? 's' : ''}`; + timeUntil = `In ${birthday.daysUntil} Tag${birthday.daysUntil > 1 ? 'en' : ''}`; } - birthdayList += `${displayIndex}. **${member.displayName}**\n<@${birthday.userId}>\n📅 **Date:** ${birthday.monthName} ${birthday.day}\n⏰ **Time:** ${timeUntil}\n\n`; + birthdayList += `${displayIndex}. **${member.displayName}**\n<@${birthday.userId}>\n📅 **Datum:** ${birthday.day}. ${birthday.monthName}\n⏰ **Wann:** ${timeUntil}\n\n`; } - birthdayList += `Use /birthday set to add your birthday!`; + birthdayList += `Nutze /birthday set, um deinen Geburtstag einzutragen!`; const embed = new EmbedBuilder() .setColor(0x00FF00) - .setTitle('Next 5 Upcoming Birthdays') + .setTitle('Die nächsten 5 Geburtstage') .setDescription(birthdayList); await InteractionHelper.safeEditReply(interaction, { diff --git a/src/commands/Community/app-admin.ts b/src/commands/Community/app-admin.ts index d653d8264b..fefd661dc0 100644 --- a/src/commands/Community/app-admin.ts +++ b/src/commands/Community/app-admin.ts @@ -25,10 +25,10 @@ import appDashboard from './modules/app_dashboard.js'; function getApplicationStatusPresentation(statusValue: unknown) { const normalized = typeof statusValue === 'string' ? statusValue.trim().toLowerCase() : 'unknown'; const statusLabel = - normalized === 'pending' ? 'In Progress' : - normalized === 'approved' ? 'Accepted' : - normalized === 'denied' ? 'Denied' : - 'Unknown'; + normalized === 'pending' ? 'In Bearbeitung' : + normalized === 'approved' ? 'Angenommen' : + normalized === 'denied' ? 'Abgelehnt' : + 'Unbekannt'; const statusEmoji = normalized === 'pending' ? '🟡' : normalized === 'approved' ? '🟢' : @@ -41,49 +41,49 @@ function getApplicationStatusPresentation(statusValue: unknown) { export default { data: new SlashCommandBuilder() .setName("app-admin") - .setDescription("Manage staff applications") + .setDescription("Team-Bewerbungen verwalten") .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .addSubcommand((subcommand) => subcommand .setName("setup") - .setDescription("Set up a new application") + .setDescription("Eine neue Bewerbung einrichten") ) .addSubcommand((subcommand) => subcommand .setName("review") - .setDescription("Approve or deny an application") + .setDescription("Eine Bewerbung annehmen oder ablehnen") .addStringOption((option) => option .setName("id") - .setDescription("The application ID") + .setDescription("Die Bewerbungs-ID") .setRequired(true), ), ) .addSubcommand((subcommand) => subcommand .setName("list") - .setDescription("List all applications") + .setDescription("Alle Bewerbungen auflisten") .addStringOption((option) => option .setName("status") - .setDescription("Filter by status") + .setDescription("Nach Status filtern") .addChoices( - { name: "Pending", value: "pending" }, - { name: "Approved", value: "approved" }, - { name: "Denied", value: "denied" }, + { name: "Offen", value: "pending" }, + { name: "Angenommen", value: "approved" }, + { name: "Abgelehnt", value: "denied" }, ), ) .addStringOption((option) => - option.setName("role").setDescription("Filter by role ID"), + option.setName("role").setDescription("Nach Rollen-ID filtern"), ) .addUserOption((option) => - option.setName("user").setDescription("Filter by user"), + option.setName("user").setDescription("Nach Nutzer filtern"), ) .addNumberOption((option) => option .setName("limit") .setDescription( - "Maximum number of applications to show (default: 10)", + "Maximale Anzahl angezeigter Bewerbungen (Standard: 10)", ) .setMinValue(1) .setMaxValue(25), @@ -92,11 +92,11 @@ export default { .addSubcommand((subcommand) => subcommand .setName("dashboard") - .setDescription("Open the applications configuration dashboard") + .setDescription("Öffnet das Konfigurations-Dashboard für Bewerbungen") .addStringOption((option) => option .setName("application") - .setDescription("Select an application to configure") + .setDescription("Wähle eine Bewerbung zum Konfigurieren") .setRequired(false) .setAutocomplete(true), ), @@ -106,7 +106,7 @@ export default { execute: withErrorHandling(async (interaction: ChatInputCommandInteraction) => { if (!interaction.inGuild()) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This command can only be used in a server.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Dieser Command kann nur auf einem Server genutzt werden.' }); } const { options, guild, member } = interaction; @@ -140,56 +140,56 @@ export default { async function handleSetup(interaction: ChatInputCommandInteraction) { if (interaction.deferred || interaction.replied) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This interaction has already been processed. Please try the command again.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Diese Interaktion wurde bereits verarbeitet. Bitte führe den Command erneut aus.' }); } const modal = new ModalBuilder() .setCustomId('app_setup_modal') - .setTitle('Set Up New Application'); + .setTitle('Neue Bewerbung einrichten'); const roleSelect = new RoleSelectMenuBuilder() .setCustomId('role_id') - .setPlaceholder('Select the role users will apply for') + .setPlaceholder('Rolle wählen, auf die sich Nutzer bewerben') .setRequired(true); const roleLabel = new LabelBuilder() - .setLabel('Application Role') - .setDescription('The role that users will be applying for') + .setLabel('Bewerbungsrolle') + .setDescription('Die Rolle, auf die sich Nutzer bewerben') .setRoleSelectMenuComponent(roleSelect); const appNameInput = new TextInputBuilder() .setCustomId('app_name') .setStyle(TextInputStyle.Short) - .setPlaceholder('e.g., Moderator, Helper, Developer') + .setPlaceholder('z. B. Moderator, Helfer, Entwickler') .setMaxLength(50) .setMinLength(1) .setRequired(true); const appNameLabel = new LabelBuilder() - .setLabel('Application Name') + .setLabel('Name der Bewerbung') .setTextInputComponent(appNameInput); const q1Input = new TextInputBuilder() .setCustomId('app_question_1') .setStyle(TextInputStyle.Short) - .setPlaceholder('Why do you want this role?') + .setPlaceholder('Warum möchtest du diese Rolle?') .setMaxLength(100) .setMinLength(1) .setRequired(true); const q1Label = new LabelBuilder() - .setLabel('Question 1 (required)') + .setLabel('Frage 1 (Pflicht)') .setTextInputComponent(q1Input); const q2Input = new TextInputBuilder() .setCustomId('app_question_2') .setStyle(TextInputStyle.Short) - .setPlaceholder('What experience do you have?') + .setPlaceholder('Welche Erfahrung hast du?') .setMaxLength(100) .setRequired(false); const q2Label = new LabelBuilder() - .setLabel('Question 2 (optional)') + .setLabel('Frage 2 (optional)') .setTextInputComponent(q2Input); const q3Input = new TextInputBuilder() @@ -199,7 +199,7 @@ async function handleSetup(interaction: ChatInputCommandInteraction) { .setRequired(false); const q3Label = new LabelBuilder() - .setLabel('Question 3 (optional)') + .setLabel('Frage 3 (optional)') .setTextInputComponent(q3Input); modal.addLabelComponents(roleLabel, appNameLabel, q1Label, q2Label, q3Label); @@ -223,7 +223,7 @@ async function handleSetup(interaction: ChatInputCommandInteraction) { const roleId = selectedRoles!.first()?.id; if (!roleId) { - await replyUserError(submitted, { type: ErrorTypes.USER_INPUT, message: 'You must select a role for the application.' }); + await replyUserError(submitted, { type: ErrorTypes.USER_INPUT, message: 'Du musst eine Rolle für die Bewerbung auswählen.' }); return; } @@ -235,13 +235,13 @@ async function handleSetup(interaction: ChatInputCommandInteraction) { const role = await interaction.guild!.roles.fetch(roleId).catch(() => null); if (!role) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'The selected role could not be found.' }); + await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Die ausgewählte Rolle wurde nicht gefunden.' }); return; } 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: `Die Rolle ${role} ist bereits als Bewerbung eingerichtet.` }); return; } @@ -262,8 +262,8 @@ async function handleSetup(interaction: ChatInputCommandInteraction) { await submitted.reply({ embeds: [successEmbed( - '✅ Application Created', - `**${appName}** application has been created for ${role}.\n\nYou can customize the log channel, manager roles, questions, and retention period in the dashboard.`, + '✅ Bewerbung erstellt', + `Die Bewerbung **${appName}** wurde für ${role} erstellt.\n\nLog-Kanal, Manager-Rollen, Fragen und Aufbewahrungsdauer kannst du im Dashboard anpassen.`, )], flags: ['Ephemeral'], }); @@ -282,24 +282,24 @@ async function handleReview(interaction: ChatInputCommandInteraction) { appId, ); if (!application) { - return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Application not found.' }); + return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Bewerbung nicht gefunden.' }); } if (application.status !== "pending") { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This application has already been processed.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Diese Bewerbung wurde bereits bearbeitet.' }); } const appEmbed = createEmbed({ - title: `Review Application`, - description: `**User:** <@${application.userId}>\n**Application:** ${application.roleName}\n**Application ID:** \`${appId}\``, + title: `Bewerbung prüfen`, + description: `**Nutzer:** <@${application.userId}>\n**Bewerbung:** ${application.roleName}\n**Bewerbungs-ID:** \`${appId}\``, color: 'info', }); if (application.answers && application.answers.length > 0) { application.answers.forEach((item, index) => { appEmbed.addFields({ - name: `Q${index + 1}: ${item.question}`, - value: item.answer || '*No answer provided*', + name: `F${index + 1}: ${item.question}`, + value: item.answer || '*Keine Antwort angegeben*', inline: false }); }); @@ -308,11 +308,11 @@ async function handleReview(interaction: ChatInputCommandInteraction) { const buttonRow = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`app_review_approve_${appId}`) - .setLabel('Approve') + .setLabel('Annehmen') .setStyle(ButtonStyle.Success), new ButtonBuilder() .setCustomId(`app_review_deny_${appId}`) - .setLabel('Deny') + .setLabel('Ablehnen') .setStyle(ButtonStyle.Danger), ); @@ -337,15 +337,15 @@ async function handleReview(interaction: ChatInputCommandInteraction) { const reasonModal = new ModalBuilder() .setCustomId(`app_review_reason_${appId}_${isApprove ? 'approve' : 'deny'}`) - .setTitle(`${isApprove ? 'Approve' : 'Deny'} Application - Reason`); + .setTitle(`Bewerbung ${isApprove ? 'annehmen' : 'ablehnen'} – Grund`); reasonModal.addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('review_reason') - .setLabel('Reason (optional)') + .setLabel('Grund (optional)') .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('Provide a reason for this decision...') + .setPlaceholder('Gib einen Grund für diese Entscheidung an...') .setMaxLength(500) .setRequired(false), ), @@ -363,7 +363,7 @@ async function handleReview(interaction: ChatInputCommandInteraction) { if (!reasonSubmit) return; - const reason = reasonSubmit.fields.getTextInputValue('review_reason').trim() || "No reason provided."; + const reason = reasonSubmit.fields.getTextInputValue('review_reason').trim() || "Kein Grund angegeben."; const action = isApprove ? 'approve' : 'deny'; const status = isApprove ? 'approved' : 'denied'; @@ -383,10 +383,10 @@ async function handleReview(interaction: ChatInputCommandInteraction) { const statusColor = getApplicationStatusColor(status); const reviewStatus = getApplicationStatusPresentation(status); 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.` + title: `${reviewStatus.statusEmoji} Bewerbung ${reviewStatus.statusLabel.toLowerCase()}`, + description: `Deine Bewerbung für **${application.roleName}** wurde **${reviewStatus.statusLabel.toLowerCase()}**.\n` + + `**Hinweis:** ${reason}\n\n` + + `Details siehst du mit \`/apply status id:${appId}\`.` }).setColor(statusColor); await user.send({ embeds: [dmEmbed] }); @@ -454,8 +454,8 @@ async function handleReview(interaction: ChatInputCommandInteraction) { await reasonSubmit.reply({ embeds: [ successEmbed( - `Application ${status}`, - `The application has been **${status}**.`, + `Bewerbung ${isApprove ? 'angenommen' : 'abgelehnt'}`, + `Die Bewerbung wurde **${isApprove ? 'angenommen' : 'abgelehnt'}**.`, ), ], flags: ["Ephemeral"], @@ -463,15 +463,15 @@ async function handleReview(interaction: ChatInputCommandInteraction) { } catch (error) { logger.error('Error reviewing application:', error); - await replyUserError(buttonInteraction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while reviewing the application.' }); + await replyUserError(buttonInteraction, { type: ErrorTypes.UNKNOWN, message: 'Beim Prüfen der Bewerbung ist ein Fehler aufgetreten.' }); } }); collector.on('end', async (collected, reason) => { if (reason === 'time') { const timeoutEmbed = createEmbed({ - title: 'Review Timeout', - description: 'The review buttons have timed out.', + title: 'Prüfung abgelaufen', + description: 'Die Buttons zur Prüfung sind abgelaufen.', color: 'warning', }); @@ -526,29 +526,29 @@ async function handleList(interaction: ChatInputCommandInteraction) { if (applicationRoles.length > 0) { const embed = createEmbed({ - title: "No Applications Found", - description: "No submitted applications found matching the specified criteria.\n\nHowever, the following application roles are configured:" + title: "Keine Bewerbungen gefunden", + description: "Es wurden keine eingereichten Bewerbungen gefunden, die zu deinen Kriterien passen.\n\nDiese Bewerbungsrollen sind aber eingerichtet:" }); applicationRoles.forEach((appRole, index) => { const role = interaction.guild!.roles.cache.get(appRole.roleId); embed.addFields({ name: `${index + 1}. ${appRole.name}`, - value: `**Role:** ${role ?`<@&${appRole.roleId}>`: 'Role not found'}\n**Available for applications:** Yes`, + value: `**Rolle:** ${role ?`<@&${appRole.roleId}>`: 'Rolle nicht gefunden'}\n**Für Bewerbungen verfügbar:** Ja`, inline: false }); }); embed.setFooter({ - text: "Users can apply with /apply submit or see available roles with /apply list" + text: "Nutzer bewerben sich mit /apply submit und sehen verfügbare Rollen mit /apply list" }); 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' + - 'Use `/app-admin roles add` to configure application roles first.' + message: 'Keine Bewerbungen gefunden und keine Bewerbungsrollen eingerichtet.\n' + + 'Richte zuerst mit `/app-admin setup` eine Bewerbungsrolle ein.' }); } } @@ -557,23 +557,23 @@ async function handleList(interaction: ChatInputCommandInteraction) { .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .slice(0, limit); - const embed = createEmbed({ title: "Submitted Applications", description: `Showing ${applications.length} applications.`, }); + const embed = createEmbed({ title: "Eingereichte Bewerbungen", description: `${applications.length} Bewerbung(en) werden angezeigt.`, }); applications.forEach((app) => { const statusView = getApplicationStatusPresentation(app?.status); - const roleName = app?.roleName || 'Unknown Role'; - const username = app?.username || 'Unknown User'; + const roleName = app?.roleName || 'Unbekannte Rolle'; + const username = app?.username || 'Unbekannter Nutzer'; const createdAt = app?.createdAt ? new Date(app.createdAt) : null; const createdAtDisplay = createdAt && !Number.isNaN(createdAt.getTime()) ? createdAt.toLocaleString() - : 'Unknown date'; + : 'Unbekanntes Datum'; embed.addFields({ name: `${statusView.statusEmoji} ${roleName} - ${username}`, value: `**ID:** \`${app.id}\`\n` + `**Status:** ${statusView.statusEmoji} ${statusView.statusLabel}\n` + - `**Date:** ${createdAtDisplay}`, + `**Datum:** ${createdAtDisplay}`, inline: true, }); }); diff --git a/src/commands/Community/apply.ts b/src/commands/Community/apply.ts index 2529c8d286..f352c4c7e5 100644 --- a/src/commands/Community/apply.ts +++ b/src/commands/Community/apply.ts @@ -24,10 +24,10 @@ import type { ApplicationAnswer, ApplicationSettings } from '../../utils/databas function getApplicationStatusPresentation(statusValue: unknown) { const normalized = typeof statusValue === 'string' ? statusValue.trim().toLowerCase() : 'unknown'; const statusLabel = - normalized === 'pending' ? 'In Progress' : - normalized === 'approved' ? 'Accepted' : - normalized === 'denied' ? 'Denied' : - 'Unknown'; + normalized === 'pending' ? 'In Bearbeitung' : + normalized === 'approved' ? 'Angenommen' : + normalized === 'denied' ? 'Abgelehnt' : + 'Unbekannt'; const statusEmoji = normalized === 'pending' ? '🟡' : normalized === 'approved' ? '🟢' : @@ -41,15 +41,15 @@ export default { slashOnly: true, data: new SlashCommandBuilder() .setName("apply") - .setDescription("Manage role applications") + .setDescription("Bewerbungen für Rollen verwalten") .addSubcommand((subcommand) => subcommand .setName("submit") - .setDescription("Submit an application for a role") + .setDescription("Eine Bewerbung für eine Rolle einreichen") .addStringOption((option) => option .setName("application") - .setDescription("The application you want to submit") + .setDescription("Die Bewerbung, die du einreichen möchtest") .setRequired(true) .setAutocomplete(true), ), @@ -57,25 +57,25 @@ export default { .addSubcommand((subcommand) => subcommand .setName("status") - .setDescription("Check the status of your application") + .setDescription("Den Status deiner Bewerbung prüfen") .addStringOption((option) => option .setName("id") - .setDescription("Application ID (leave empty to see all)") + .setDescription("Bewerbungs-ID (leer lassen, um alle zu sehen)") .setRequired(false), ), ) .addSubcommand((subcommand) => subcommand .setName("list") - .setDescription("List available applications to apply for"), + .setDescription("Verfügbare Bewerbungen anzeigen"), ), category: "Community", execute: withErrorHandling(async (interaction: ChatInputCommandInteraction) => { if (!interaction.inGuild()) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This command can only be used in a server.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Dieser Command kann nur auf einem Server genutzt werden.' }); } const { options, guild, member } = interaction; @@ -101,7 +101,7 @@ export default { throw createError( 'Applications are disabled', ErrorTypes.CONFIGURATION, - 'Applications are currently disabled in this server.', + 'Bewerbungen sind auf diesem Server derzeit deaktiviert.', { guildId: guild!.id } ); } @@ -128,13 +128,13 @@ export async function handleApplicationModal(interaction: ModalSubmitInteraction const applicationRole = applicationRoles.find(appRole => appRole.roleId === roleId); if (!applicationRole) { - return await replyUserError(interaction, { type: ErrorTypes.CONFIGURATION, message: 'Application configuration not found.' }); + return await replyUserError(interaction, { type: ErrorTypes.CONFIGURATION, message: 'Bewerbungskonfiguration nicht gefunden.' }); } const role = interaction.guild!.roles.cache.get(roleId); if (!role) { - return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Role not found.' }); + return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Rolle nicht gefunden.' }); } const answers: ApplicationAnswer[] = []; @@ -166,10 +166,10 @@ export async function handleApplicationModal(interaction: ModalSubmitInteraction }); const embed = successEmbed( - 'Application Submitted', - `Your application for **${applicationRole.name}** has been submitted successfully!\n\n` + - `Application ID: \`${application.id}\`\n` + - `You can check the status with \`/apply status id:${application.id}\`` + 'Bewerbung eingereicht', + `Deine Bewerbung für **${applicationRole.name}** wurde erfolgreich eingereicht!\n\n` + + `Bewerbungs-ID: \`${application.id}\`\n` + + `Den Status prüfst du mit \`/apply status id:${application.id}\`` ); await InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"] }); @@ -187,15 +187,15 @@ export async function handleApplicationModal(interaction: ModalSubmitInteraction eventType: EVENT_TYPES.APPLICATION_SUBMIT, channelId: logChannelId, data: { - title: 'Application Submitted', + title: 'Bewerbung eingereicht', lines: [ - formatLogLine('Applicant', `<@${interaction.user.id}> (${interaction.user.tag})`), - formatLogLine('Application', applicationRole.name), - formatLogLine('Role', role.name), - formatLogLine('Application ID', `\`${application.id}\``), + formatLogLine('Bewerber', `<@${interaction.user.id}> (${interaction.user.tag})`), + formatLogLine('Bewerbung', applicationRole.name), + formatLogLine('Rolle', role.name), + formatLogLine('Bewerbungs-ID', `\`${application.id}\``), ], inlineFields: [ - { name: 'Status', value: '🟡 In Progress', inline: true }, + { name: 'Status', value: '🟡 In Bearbeitung', inline: true }, ], author: await resolveUserAuthor(interaction.client, interaction.user.id), }, @@ -230,26 +230,26 @@ async function handleList(interaction: ChatInputCommandInteraction) { const applicationRoles = await getApplicationRoles(interaction.client, interaction.guild!.id); if (applicationRoles.length === 0) { - return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'No applications are currently available.' }); + return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Derzeit sind keine Bewerbungen verfügbar.' }); } const embed = createEmbed({ - title: "Available Applications", - description: "Here are the roles you can apply for:" + title: "Verfügbare Bewerbungen", + description: "Auf diese Rollen kannst du dich bewerben:" }); applicationRoles.forEach((appRole, index) => { const role = interaction.guild!.roles.cache.get(appRole.roleId); embed.addFields({ name: `${index + 1}. ${appRole.name}`, - value: `**Role:** ${role ?`<@&${appRole.roleId}>`: 'Role not found'}\n` + - `**Apply with:** \`/apply submit application:"${appRole.name}"\``, + value: `**Rolle:** ${role ?`<@&${appRole.roleId}>`: 'Rolle nicht gefunden'}\n` + + `**Bewerben mit:** \`/apply submit application:"${appRole.name}"\``, inline: false }); }); embed.setFooter({ - text: "Use /apply submit application: to apply for any of these roles." + text: "Nutze /apply submit application:, um dich auf eine dieser Rollen zu bewerben." }); return InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); @@ -263,7 +263,7 @@ async function handleList(interaction: ChatInputCommandInteraction) { throw createError( 'Failed to load applications', ErrorTypes.DATABASE, - 'Failed to load applications. Please try again later.', + 'Bewerbungen konnten nicht geladen werden. Bitte versuch es später erneut.', { guildId: interaction.guild!.id } ); } @@ -280,7 +280,7 @@ async function handleSubmit(interaction: ChatInputCommandInteraction, settings: ); if (!applicationRole) { - return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Use `/apply list` to see available applications.' }); + return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Bewerbung nicht gefunden. Nutze `/apply list`, um die verfügbaren Bewerbungen zu sehen.' }); } const userApps = await getUserApplications( @@ -291,17 +291,17 @@ async function handleSubmit(interaction: ChatInputCommandInteraction, settings: const pendingApp = userApps.find((app) => app.status === "pending"); if (pendingApp) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You already have a pending application. Please wait for it to be reviewed.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Du hast bereits eine offene Bewerbung. Bitte warte, bis sie geprüft wurde.' }); } const role = interaction.guild!.roles.cache.get(applicationRole.roleId); if (!role) { - return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'The role for this application no longer exists.' }); + return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Die Rolle für diese Bewerbung existiert nicht mehr.' }); } const modal = new ModalBuilder() .setCustomId(`app_modal_${applicationRole.roleId}`) - .setTitle(`Application for ${applicationRole.name}`); + .setTitle(`Bewerbung: ${applicationRole.name}`.slice(0, 45)); let questions = settings.questions?.length ? settings.questions : getDefaultApplicationQuestions(); const roleSettings = await getApplicationRoleSettings(interaction.client, interaction.guild!.id, applicationRole.roleId); @@ -339,20 +339,20 @@ async function handleStatus(interaction: ChatInputCommandInteraction) { ); if (!application || application.userId !== interaction.user.id) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Application not found or you do not have permission to view it.' }); + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Bewerbung nicht gefunden oder du darfst sie nicht ansehen.' }); } const submittedAt = application?.createdAt ? new Date(application.createdAt) : null; const submittedAtDisplay = submittedAt && !Number.isNaN(submittedAt.getTime()) ? submittedAt.toLocaleString() - : 'Unknown date'; + : 'Unbekanntes Datum'; const statusView = getApplicationStatusPresentation(application.status); const embed = createEmbed({ - title: `Application #${application.id} - ${application.roleName || 'Unknown Role'}`, + title: `Bewerbung #${application.id} - ${application.roleName || 'Unbekannte Rolle'}`, description: - `**Application ID:** \`${application.id}\`\n` + + `**Bewerbungs-ID:** \`${application.id}\`\n` + `**Status:** ${statusView.statusEmoji} ${statusView.statusLabel}\n` + - `**Submitted:** ${submittedAtDisplay}` + `**Eingereicht:** ${submittedAtDisplay}` }); return InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"] }); @@ -364,7 +364,7 @@ async function handleStatus(interaction: ChatInputCommandInteraction) { ); if (applications.length === 0) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You have not submitted any applications yet.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Du hast noch keine Bewerbungen eingereicht.' }); } const recentApplications = applications @@ -372,29 +372,29 @@ async function handleStatus(interaction: ChatInputCommandInteraction) { .slice(0, 10); const embed = createEmbed({ - title: "Your Applications", - description: `Showing ${recentApplications.length} recent application(s).` + title: "Deine Bewerbungen", + description: `${recentApplications.length} aktuelle Bewerbung(en).` }); recentApplications.forEach((application) => { const submittedAt = application?.createdAt ? new Date(application.createdAt) : null; const submittedAtDisplay = submittedAt && !Number.isNaN(submittedAt.getTime()) ? submittedAt.toLocaleDateString() - : 'Unknown date'; + : 'Unbekanntes Datum'; const statusView = getApplicationStatusPresentation(application.status); embed.addFields({ - name: `${statusView.statusEmoji} ${application.roleName || 'Unknown Role'} (${statusView.statusLabel})`, + name: `${statusView.statusEmoji} ${application.roleName || 'Unbekannte Rolle'} (${statusView.statusLabel})`, value: `**ID:** \`${application.id}\`\n` + `**Status:** ${statusView.statusEmoji} ${statusView.statusLabel}\n` + - `**Submitted:** ${submittedAtDisplay}`, + `**Eingereicht:** ${submittedAtDisplay}`, inline: true, }); }); if (applications.length > recentApplications.length) { - embed.setFooter({ text: `Showing latest ${recentApplications.length} of ${applications.length} applications.` }); + embed.setFooter({ text: `Die neuesten ${recentApplications.length} von ${applications.length} Bewerbungen.` }); } return InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"] }); diff --git a/src/commands/Community/modules/app_dashboard.ts b/src/commands/Community/modules/app_dashboard.ts index 06781733b7..8463c98698 100644 --- a/src/commands/Community/modules/app_dashboard.ts +++ b/src/commands/Community/modules/app_dashboard.ts @@ -56,76 +56,76 @@ type ManagerRoles = string[] | undefined; async function buildDashboardEmbed(settings: ApplicationSettings, roles: ApplicationRole[], guild: Guild, client: NexoClient) { const guildConfig = await getGuildConfig(client, guild.id); const applicationsChannel = resolveLogChannel(guildConfig, 'applications') || settings.logChannelId; - const logChannel = applicationsChannel ? `<#${applicationsChannel}>` : '`Not set`'; + const logChannel = applicationsChannel ? `<#${applicationsChannel}>` : '`Nicht festgelegt`'; const managerRoles = settings.managerRoles as ManagerRoles; const managerRoleList = (managerRoles?.length ?? 0) > 0 ? managerRoles!.map(id => `<@&${id}>`).join(',') - : '`None configured`'; + : '`Keine eingerichtet`'; const roleList = roles.length > 0 ? roles.map(r => `<@&${r.roleId}> — ${r.name}`).join('\n') - : '`No application roles configured`'; + : '`Keine Bewerbungsrollen eingerichtet`'; const questionCount = settings.questions?.length ?? 0; const firstQ = settings.questions?.[0] ? `\`${settings.questions[0].length > 55 ? settings.questions[0].substring(0, 55) + '…' : settings.questions[0]}\`` - : '`Not set`'; + : '`Nicht festgelegt`'; return new EmbedBuilder() - .setTitle('Applications Dashboard') - .setDescription(`Manage application settings for **${guild.name}**.\nSelect an option below to modify a setting.`) + .setTitle('Bewerbungs-Dashboard') + .setDescription(`Verwalte die Bewerbungseinstellungen für **${guild.name}**.\nWähle unten eine Option, um eine Einstellung zu ändern.`) .setColor(getColor('info')) .addFields( - { name: 'Application Status', value: settings.enabled ? 'Enabled' : 'Disabled', inline: true }, - { name: 'Log Channel', value: logChannel, inline: true }, + { name: 'Bewerbungsstatus', value: settings.enabled ? 'Aktiviert' : 'Deaktiviert', inline: true }, + { name: 'Log-Kanal', value: logChannel, inline: true }, { name: '\u200B', value: '\u200B', inline: true }, - { name: 'Manager Roles', value: managerRoleList, inline: false }, - { name: 'Questions', value: `${questionCount} configured — first: ${firstQ}`, inline: false }, - { name: 'Application Roles', value: roleList, inline: false }, + { name: 'Manager-Rollen', value: managerRoleList, inline: false }, + { name: 'Fragen', value: `${questionCount} eingerichtet — erste: ${firstQ}`, inline: false }, + { name: 'Bewerbungsrollen', value: roleList, inline: false }, { - name: 'Retention', - value: `Pending: **${settings.pendingApplicationRetentionDays ?? 30}d** · Reviewed: **${settings.reviewedApplicationRetentionDays ?? 14}d**`, + name: 'Aufbewahrung', + value: `Offen: **${settings.pendingApplicationRetentionDays ?? 30} T.** · Geprüft: **${settings.reviewedApplicationRetentionDays ?? 14} T.**`, inline: false, }, ) - .setFooter({ text: 'Dashboard closes after 15 minutes of inactivity' }) + .setFooter({ text: 'Das Dashboard schließt sich nach 15 Minuten Inaktivität' }) .setTimestamp(); } function buildSelectMenu(guildId: string) { return new StringSelectMenuBuilder() .setCustomId(`app_cfg_${guildId}`) - .setPlaceholder('Select a setting to configure...') + .setPlaceholder('Einstellung zum Konfigurieren auswählen...') .addOptions( new StringSelectMenuOptionBuilder() - .setLabel('Log Channel') - .setDescription('Set the channel where new applications are logged') + .setLabel('Log-Kanal') + .setDescription('Kanal festlegen, in dem neue Bewerbungen geloggt werden') .setValue('log_channel') .setEmoji('📢'), new StringSelectMenuOptionBuilder() - .setLabel('Manager Roles') - .setDescription('Add or remove a role that can manage applications') + .setLabel('Manager-Rollen') + .setDescription('Rolle hinzufügen oder entfernen, die Bewerbungen verwalten darf') .setValue('manager_role') .setEmoji('🛡️'), new StringSelectMenuOptionBuilder() - .setLabel('Edit Questions') - .setDescription('Customise the questions shown on the application form') + .setLabel('Fragen bearbeiten') + .setDescription('Fragen im Bewerbungsformular anpassen') .setValue('questions') .setEmoji('📝'), new StringSelectMenuOptionBuilder() - .setLabel('Add Application Role') - .setDescription('Add a role that members can apply for') + .setLabel('Bewerbungsrolle hinzufügen') + .setDescription('Rolle hinzufügen, auf die sich Mitglieder bewerben können') .setValue('role_add') .setEmoji('➕'), new StringSelectMenuOptionBuilder() - .setLabel('Remove Application Role') - .setDescription('Remove a role from the applications list') + .setLabel('Bewerbungsrolle entfernen') + .setDescription('Rolle aus der Bewerbungsliste entfernen') .setValue('role_remove') .setEmoji('➖'), new StringSelectMenuOptionBuilder() - .setLabel('Retention Period') - .setDescription('Set how long pending and reviewed applications are kept') + .setLabel('Aufbewahrungsdauer') + .setDescription('Festlegen, wie lange offene und geprüfte Bewerbungen gespeichert werden') .setValue('retention') .setEmoji('🗑️'), ); @@ -136,7 +136,7 @@ function buildButtonRow(settings: ApplicationSettings, guildId: string, disabled return new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`app_cfg_toggle_${guildId}`) - .setLabel('Applications') + .setLabel('Bewerbungen') .setStyle(systemOn ? ButtonStyle.Success : ButtonStyle.Danger) .setDisabled(disabled), ); @@ -179,7 +179,7 @@ export default { throw new NexoBotError( 'Applications system not set up', ErrorTypes.CONFIGURATION, - 'The applications system has not been configured yet. Please run `/app-admin setup` to create your first application.', + 'Das Bewerbungssystem ist noch nicht eingerichtet. Nutze `/app-admin setup`, um deine erste Bewerbung zu erstellen.', ); } @@ -206,7 +206,7 @@ export default { throw new NexoBotError( `Applications dashboard failed: ${(error as Error).message}`, ErrorTypes.UNKNOWN, - 'Failed to open the applications dashboard.', + 'Das Bewerbungs-Dashboard konnte nicht geöffnet werden.', ); } }, @@ -215,20 +215,20 @@ export default { async function showApplicationSelector(interaction: AppDashboardInteraction, roles: ApplicationRole[], settings: ApplicationSettings, guildId: string, client: NexoClient) { const selectMenu = new StringSelectMenuBuilder() .setCustomId(`app_select_${guildId}`) - .setPlaceholder('Select an application to configure...') + .setPlaceholder('Bewerbung zum Konfigurieren auswählen...') .addOptions( roles.map(role => new StringSelectMenuOptionBuilder() .setLabel(role.name) - .setDescription(`Configure the ${role.name} application`) + .setDescription(`Bewerbung ${role.name} konfigurieren`) .setValue(role.roleId) .setEmoji('📋'), ), ); const embed = new EmbedBuilder() - .setTitle('Select Application') - .setDescription('Choose which application role you want to configure.') + .setTitle('Bewerbung auswählen') + .setDescription('Wähle, welche Bewerbungsrolle du konfigurieren möchtest.') .setColor(getColor('info')); await InteractionHelper.safeEditReply(interaction, { @@ -260,7 +260,7 @@ async function showApplicationSelector(interaction: AppDashboardInteraction, rol if (reason === 'time' && collected.size === 0) { replyUserError(interaction, { type: ErrorTypes.RATE_LIMIT, - message: 'No selection was made. The dashboard has closed.', + message: 'Es wurde nichts ausgewählt. Das Dashboard wurde geschlossen.', }).catch(() => {}); } }); @@ -291,55 +291,55 @@ async function showApplicationDashboard(rootInteraction: AppDashboardInteraction const logChannelDisplay = appLogChannelId ? `<#${appLogChannelId}>` - : '`Inherits global log channel`'; + : '`Übernimmt den globalen Log-Kanal`'; const questionsDisplay = questions.length > 0 ? questions.map((q, i) => `${i + 1}. \`${q.length > 60 ? q.substring(0, 60) + '…' : q}\``).join('\n') - : '`Inherits global questions`'; + : '`Übernimmt die globalen Fragen`'; const managerRoles = settings.managerRoles as ManagerRoles; const managerRolesDisplay = managerRoles && managerRoles.length > 0 ? managerRoles.map(id => `<@&${id}>`).join(',') - : '`None configured`'; + : '`Keine eingerichtet`'; const embed = new EmbedBuilder() - .setTitle('📋 Application Dashboard') - .setDescription(`Configuration for **${selectedRole.name}**`) + .setTitle('📋 Bewerbungs-Dashboard') + .setDescription(`Konfiguration für **${selectedRole.name}**`) .setColor(isEnabled ? getColor('success') : getColor('error')) .addFields( { - name: 'Role', + name: 'Rolle', value: roleObj ? roleObj.toString() : `<@&${selectedRole.roleId}>`, inline: true }, { - name: 'Application Status', - value: isEnabled ? '✅ **Enabled**' : '❌ **Disabled**', + name: 'Bewerbungsstatus', + value: isEnabled ? '✅ **Aktiviert**' : '❌ **Deaktiviert**', inline: true }, { name: '\u200B', value: '\u200B', inline: true }, { - name: 'Questions', + name: 'Fragen', value: questionsDisplay, inline: false }, { - name: 'Log Channel', + name: 'Log-Kanal', value: logChannelDisplay, inline: true }, { - name: 'Manager Roles', + name: 'Manager-Rollen', value: managerRolesDisplay, inline: true }, { - name: 'Retention Period', - value: `Pending: **${settings.pendingApplicationRetentionDays ?? 30}d** · Reviewed: **${settings.reviewedApplicationRetentionDays ?? 14}d**`, + name: 'Aufbewahrungsdauer', + value: `Offen: **${settings.pendingApplicationRetentionDays ?? 30} T.** · Geprüft: **${settings.reviewedApplicationRetentionDays ?? 14} T.**`, inline: false }, ) - .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) + .setFooter({ text: 'Das Dashboard schließt sich nach 10 Minuten Inaktivität' }) .setTimestamp(); const configMenu = buildApplicationSelectMenu(guildId, selectedRole.roleId); @@ -347,11 +347,11 @@ async function showApplicationDashboard(rootInteraction: AppDashboardInteraction const controlButtons = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`app_toggle_${selectedRole.roleId}`) - .setLabel(isEnabled ? 'Disable Application' : 'Enable Application') + .setLabel(isEnabled ? 'Bewerbung deaktivieren' : 'Bewerbung aktivieren') .setStyle(isEnabled ? ButtonStyle.Danger : ButtonStyle.Success), new ButtonBuilder() .setCustomId(`app_delete_${selectedRole.roleId}`) - .setLabel('Delete Application') + .setLabel('Bewerbung löschen') .setStyle(ButtonStyle.Danger) .setEmoji('🗑️'), ); @@ -415,8 +415,8 @@ function setupCollectors(interaction: AppDashboardInteraction, settings: Applica const errorMessage = error instanceof NexoBotError - ? error.userMessage || 'An error occurred while processing your selection.' - : 'An unexpected error occurred while updating the configuration.'; + ? error.userMessage || 'Beim Verarbeiten deiner Auswahl ist ein Fehler aufgetreten.' + : 'Beim Aktualisieren der Konfiguration ist ein unerwarteter Fehler aufgetreten.'; if (!selectInteraction.replied && !selectInteraction.deferred) { await safeDeferInteraction(selectInteraction); @@ -432,8 +432,8 @@ function setupCollectors(interaction: AppDashboardInteraction, settings: Applica collector.on('end', async (collected, reason) => { if (reason === 'time') { const timeoutEmbed = new EmbedBuilder() - .setTitle('\u23f0 Dashboard Timed Out') - .setDescription('This dashboard has been closed due to inactivity. Please run the command again to continue.') + .setTitle('\u23f0 Dashboard abgelaufen') + .setDescription('Dieses Dashboard wurde wegen Inaktivität geschlossen. Führe den Command erneut aus, um weiterzumachen.') .setColor(getColor('error')); await InteractionHelper.safeEditReply(interaction, { @@ -468,11 +468,11 @@ function setupCollectors(interaction: AppDashboardInteraction, settings: Applica await toggleInteraction.followUp({ embeds: [successEmbed( - wasEnabled ? '🔴 Applications Disabled' : '🟢 Applications Enabled', - `The applications system is now **${wasEnabled ? 'disabled' : 'enabled'}**.\n\n${ + wasEnabled ? '🔴 Bewerbungen deaktiviert' : '🟢 Bewerbungen aktiviert', + `Das Bewerbungssystem ist jetzt **${wasEnabled ? 'deaktiviert' : 'aktiviert'}**.\n\n${ wasEnabled - ? 'Members will no longer be able to apply for roles.' - : 'Members can now start applying for roles.' + ? 'Mitglieder können sich nicht mehr auf Rollen bewerben.' + : 'Mitglieder können sich jetzt auf Rollen bewerben.' }`, )], flags: MessageFlags.Ephemeral, @@ -482,7 +482,7 @@ function setupCollectors(interaction: AppDashboardInteraction, settings: Applica logger.error('Error toggling global application status:', error); await replyUserError(toggleInteraction, { type: ErrorTypes.UNKNOWN, - message: 'An error occurred while toggling the application status.', + message: 'Beim Umschalten des Bewerbungsstatus ist ein Fehler aufgetreten.', }); } }); @@ -490,8 +490,8 @@ function setupCollectors(interaction: AppDashboardInteraction, settings: Applica globalToggleCollector.on('end', async (collected, reason) => { if (reason === 'time') { const timeoutEmbed = new EmbedBuilder() - .setTitle('Configuration Timeout') - .setDescription('This dashboard session has timed out due to inactivity (10 minutes).\n\nTo continue configuring your applications, please run the command again.') + .setTitle('Konfiguration abgelaufen') + .setDescription('Diese Dashboard-Sitzung ist wegen Inaktivität abgelaufen (10 Minuten).\n\nFühre den Command erneut aus, um deine Bewerbungen weiter zu konfigurieren.') .setColor(getColor('warning')); await InteractionHelper.safeEditReply(interaction, { @@ -514,21 +514,21 @@ function setupCollectors(interaction: AppDashboardInteraction, settings: Applica btnCollector.on('collect', async btnInteraction => { const appRoleForDelete = roles.find(r => r.roleId === selectedRoleId); - const appNameForDelete = appRoleForDelete?.name ?? 'this application'; + const appNameForDelete = appRoleForDelete?.name ?? 'diese Bewerbung'; const confirmModal = new ModalBuilder() .setCustomId('app_delete_confirm') - .setTitle('Confirm Application Deletion'); + .setTitle('Löschen der Bewerbung bestätigen'); const deleteWarningText = new TextDisplayBuilder() - .setContent(`⚠️ You are about to permanently delete **${appNameForDelete}**. All stored applications and settings for this role will be removed and cannot be recovered.`); + .setContent(`⚠️ Du bist dabei, **${appNameForDelete}** endgültig zu löschen. Alle gespeicherten Bewerbungen und Einstellungen für diese Rolle werden entfernt und können nicht wiederhergestellt werden.`); const deleteCheckbox = new CheckboxBuilder() .setCustomId('confirm_delete') .setDefault(false); const deleteCheckboxLabel = new LabelBuilder() - .setLabel('I confirm — this cannot be undone') + .setLabel('Ich bestätige – nicht rückgängig zu machen') .setCheckboxComponent(deleteCheckbox); confirmModal @@ -541,7 +541,7 @@ function setupCollectors(interaction: AppDashboardInteraction, settings: Applica logger.error('Error showing delete confirmation modal:', error); await replyUserError(btnInteraction, { type: ErrorTypes.UNKNOWN, - message: 'Failed to show confirmation modal. Please try again.', + message: 'Das Bestätigungsfenster konnte nicht angezeigt werden. Bitte versuch es erneut.', }).catch(() => {}); return; } @@ -556,14 +556,14 @@ function setupCollectors(interaction: AppDashboardInteraction, settings: Applica if (!confirmSubmit) { await replyUserError(btnInteraction, { type: ErrorTypes.VALIDATION, - message: 'Application deletion was cancelled.', + message: 'Das Löschen der Bewerbung wurde abgebrochen.', }); return; } const confirmed = confirmSubmit.fields.getCheckbox('confirm_delete'); if (!confirmed) { - await replyUserError(confirmSubmit, { type: ErrorTypes.VALIDATION, message: 'You must tick the confirmation checkbox to delete the application.' }); + await replyUserError(confirmSubmit, { type: ErrorTypes.VALIDATION, message: 'Du musst das Bestätigungskästchen anhaken, um die Bewerbung zu löschen.' }); return; } @@ -575,7 +575,7 @@ function setupCollectors(interaction: AppDashboardInteraction, settings: Applica logger.error('Error confirming application deletion:', error); await replyUserError(btnInteraction, { type: ErrorTypes.UNKNOWN, - message: 'An error occurred while deleting the application.', + message: 'Beim Löschen der Bewerbung ist ein Fehler aufgetreten.', }); } }); @@ -583,8 +583,8 @@ function setupCollectors(interaction: AppDashboardInteraction, settings: Applica btnCollector.on('end', async (collected, reason) => { if (reason === 'time') { const timeoutEmbed = new EmbedBuilder() - .setTitle('Configuration Timeout') - .setDescription('This dashboard session has timed out due to inactivity (10 minutes).\n\nTo continue configuring your applications, please run the command again.') + .setTitle('Konfiguration abgelaufen') + .setDescription('Diese Dashboard-Sitzung ist wegen Inaktivität abgelaufen (10 Minuten).\n\nFühre den Command erneut aus, um deine Bewerbungen weiter zu konfigurieren.') .setColor(getColor('warning')); await InteractionHelper.safeEditReply(interaction, { @@ -612,7 +612,7 @@ function setupCollectors(interaction: AppDashboardInteraction, settings: Applica if (roleIndex === -1) { await replyUserError(toggleInteraction, { type: ErrorTypes.USER_INPUT, - message: 'Application role not found.', + message: 'Bewerbungsrolle nicht gefunden.', }); return; } @@ -628,11 +628,11 @@ function setupCollectors(interaction: AppDashboardInteraction, settings: Applica await toggleInteraction.followUp({ embeds: [successEmbed( - wasEnabled ? '🔴 Application Disabled' : '🟢 Application Enabled', - `The **${updatedRole.name}** application is now **${wasEnabled ? 'disabled' : 'enabled'}**.\n\n${ + wasEnabled ? '🔴 Bewerbung deaktiviert' : '🟢 Bewerbung aktiviert', + `Die Bewerbung **${updatedRole.name}** ist jetzt **${wasEnabled ? 'deaktiviert' : 'aktiviert'}**.\n\n${ wasEnabled - ? 'This application will no longer appear in `/apply submit` options.' - : 'This application will now appear in `/apply submit` options.' + ? 'Diese Bewerbung erscheint nicht mehr in den Optionen von `/apply submit`.' + : 'Diese Bewerbung erscheint jetzt in den Optionen von `/apply submit`.' }`, )], flags: MessageFlags.Ephemeral, @@ -642,7 +642,7 @@ function setupCollectors(interaction: AppDashboardInteraction, settings: Applica logger.error('Error toggling application status:', error); await replyUserError(toggleInteraction, { type: ErrorTypes.UNKNOWN, - message: 'An error occurred while toggling the application status.', + message: 'Beim Umschalten des Bewerbungsstatus ist ein Fehler aufgetreten.', }); } }); @@ -650,8 +650,8 @@ function setupCollectors(interaction: AppDashboardInteraction, settings: Applica toggleCollector.on('end', async (collected, reason) => { if (reason === 'time') { const timeoutEmbed = new EmbedBuilder() - .setTitle('Configuration Timeout') - .setDescription('This dashboard session has timed out due to inactivity (10 minutes).\n\nTo continue configuring your applications, please run the command again.') + .setTitle('Konfiguration abgelaufen') + .setDescription('Diese Dashboard-Sitzung ist wegen Inaktivität abgelaufen (10 Minuten).\n\nFühre den Command erneut aus, um deine Bewerbungen weiter zu konfigurieren.') .setColor(getColor('warning')); await InteractionHelper.safeEditReply(interaction, { @@ -666,26 +666,26 @@ function setupCollectors(interaction: AppDashboardInteraction, settings: Applica function buildApplicationSelectMenu(guildId: string, roleId: string) { return new StringSelectMenuBuilder() .setCustomId(`app_cfg_${roleId}`) - .setPlaceholder('Select a setting to configure...') + .setPlaceholder('Einstellung zum Konfigurieren auswählen...') .addOptions( new StringSelectMenuOptionBuilder() - .setLabel('Log Channel') - .setDescription('Set the channel where applications are logged') + .setLabel('Log-Kanal') + .setDescription('Kanal festlegen, in dem Bewerbungen geloggt werden') .setValue('log_channel') .setEmoji('📢'), new StringSelectMenuOptionBuilder() - .setLabel('Manager Roles') - .setDescription('Add or remove a role that can manage applications') + .setLabel('Manager-Rollen') + .setDescription('Rolle hinzufügen oder entfernen, die Bewerbungen verwalten darf') .setValue('manager_role') .setEmoji('🛡️'), new StringSelectMenuOptionBuilder() - .setLabel('Edit Questions') - .setDescription('Customise the questions shown on the application form') + .setLabel('Fragen bearbeiten') + .setDescription('Fragen im Bewerbungsformular anpassen') .setValue('questions') .setEmoji('📝'), new StringSelectMenuOptionBuilder() - .setLabel('Retention Period') - .setDescription('Set how long pending and reviewed applications are kept') + .setLabel('Aufbewahrungsdauer') + .setDescription('Festlegen, wie lange offene und geprüfte Bewerbungen gespeichert werden') .setValue('retention') .setEmoji('🗑️'), ); @@ -700,19 +700,19 @@ async function handleLogChannel(selectInteraction: StringSelectMenuInteraction, const modal = new ModalBuilder() .setCustomId(`app_cfg_log_channel_modal_${guildId}_${selectedRoleId || 'global'}`) - .setTitle('Configure Log Channel'); + .setTitle('Log-Kanal konfigurieren'); const channelSelect = new ChannelSelectMenuBuilder() .setCustomId('log_channel') - .setPlaceholder('Select a text channel...') + .setPlaceholder('Textkanal auswählen...') .setMinValues(1) .setMaxValues(1) .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement) .setRequired(true); const channelLabel = new LabelBuilder() - .setLabel('Log Channel') - .setDescription('Channel where new applications will be logged') + .setLabel('Log-Kanal') + .setDescription('Kanal, in dem neue Bewerbungen geloggt werden') .setChannelSelectMenuComponent(channelSelect); modal.addLabelComponents(channelLabel); @@ -739,7 +739,7 @@ async function handleLogChannel(selectInteraction: StringSelectMenuInteraction, } await modalSubmission.reply({ - embeds: [successEmbed('Log Channel Updated', `Application logs will now be sent to ${channel ?? `<#${channelId}>`}.\nYou can also manage this from \`/logging dashboard\`.`)], + embeds: [successEmbed('Log-Kanal aktualisiert', `Bewerbungs-Logs werden jetzt an ${channel ?? `<#${channelId}>`} gesendet.\nDu kannst das auch über \`/logging dashboard\` verwalten.`)], flags: MessageFlags.Ephemeral, }); @@ -749,7 +749,7 @@ async function handleLogChannel(selectInteraction: StringSelectMenuInteraction, logger.error('Error in log channel modal:', error); await replyUserError(selectInteraction, { type: ErrorTypes.UNKNOWN, - message: 'An error occurred while updating the log channel.', + message: 'Beim Aktualisieren des Log-Kanals ist ein Fehler aufgetreten.', }); } } @@ -757,18 +757,18 @@ async function handleLogChannel(selectInteraction: StringSelectMenuInteraction, async function handleManagerRole(selectInteraction: StringSelectMenuInteraction, rootInteraction: AppDashboardInteraction, settings: ApplicationSettings, roles: ApplicationRole[], guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId(`app_cfg_manager_role_modal_${guildId}`) - .setTitle('Configure Manager Roles'); + .setTitle('Manager-Rollen konfigurieren'); const roleSelect = new RoleSelectMenuBuilder() .setCustomId('manager_roles') - .setPlaceholder('Select roles to grant manager access...') + .setPlaceholder('Rollen für Manager-Zugriff auswählen...') .setMinValues(1) .setMaxValues(5) .setRequired(true); const roleLabel = new LabelBuilder() - .setLabel('Manager Roles') - .setDescription('Selected roles will be toggled on/off as manager roles') + .setLabel('Manager-Rollen') + .setDescription('Ausgewählte Rollen werden als Manager-Rollen an- bzw. abgeschaltet') .setRoleSelectMenuComponent(roleSelect); modal.addLabelComponents(roleLabel); @@ -798,10 +798,10 @@ async function handleManagerRole(selectInteraction: StringSelectMenuInteraction, const finalList = managerRoles.length > 0 ? managerRoles.map(id => `<@&${id}>`).join(',') - : '`None`'; + : '`Keine`'; await modalSubmission.reply({ - embeds: [successEmbed('Manager Roles Updated', `Current manager roles: ${finalList}`)], + embeds: [successEmbed('Manager-Rollen aktualisiert', `Aktuelle Manager-Rollen: ${finalList}`)], flags: MessageFlags.Ephemeral, }); @@ -811,7 +811,7 @@ async function handleManagerRole(selectInteraction: StringSelectMenuInteraction, logger.error('Error in manager role modal:', error); await replyUserError(selectInteraction, { type: ErrorTypes.UNKNOWN, - message: 'An error occurred while updating manager roles.', + message: 'Beim Aktualisieren der Manager-Rollen ist ein Fehler aufgetreten.', }); } } @@ -826,12 +826,12 @@ async function handleQuestions(selectInteraction: StringSelectMenuInteraction, r const modal = new ModalBuilder() .setCustomId('app_cfg_questions') - .setTitle('Edit Application Questions') + .setTitle('Bewerbungsfragen bearbeiten') .addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('q1') - .setLabel('Question 1 (required)') + .setLabel('Frage 1 (Pflicht)') .setStyle(TextInputStyle.Short) .setValue(currentQuestions[0] ?? '') .setMaxLength(100) @@ -841,7 +841,7 @@ async function handleQuestions(selectInteraction: StringSelectMenuInteraction, r new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('q2') - .setLabel('Question 2 (optional)') + .setLabel('Frage 2 (optional)') .setStyle(TextInputStyle.Short) .setValue(currentQuestions[1] ?? '') .setMaxLength(100) @@ -850,7 +850,7 @@ async function handleQuestions(selectInteraction: StringSelectMenuInteraction, r new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('q3') - .setLabel('Question 3 (optional)') + .setLabel('Frage 3 (optional)') .setStyle(TextInputStyle.Short) .setValue(currentQuestions[2] ?? '') .setMaxLength(100) @@ -859,7 +859,7 @@ async function handleQuestions(selectInteraction: StringSelectMenuInteraction, r new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('q4') - .setLabel('Question 4 (optional)') + .setLabel('Frage 4 (optional)') .setStyle(TextInputStyle.Short) .setValue(currentQuestions[3] ?? '') .setMaxLength(100) @@ -868,7 +868,7 @@ async function handleQuestions(selectInteraction: StringSelectMenuInteraction, r new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('q5') - .setLabel('Question 5 (optional)') + .setLabel('Frage 5 (optional)') .setStyle(TextInputStyle.Short) .setValue(currentQuestions[4] ?? '') .setMaxLength(100) @@ -893,7 +893,7 @@ async function handleQuestions(selectInteraction: StringSelectMenuInteraction, r .filter(Boolean); if (newQuestions.length === 0) { - await replyUserError(submitted, { type: ErrorTypes.USER_INPUT, message: 'At least one question is required.' }); + await replyUserError(submitted, { type: ErrorTypes.USER_INPUT, message: 'Mindestens eine Frage ist erforderlich.' }); return; } @@ -911,8 +911,8 @@ async function handleQuestions(selectInteraction: StringSelectMenuInteraction, r await submitted.reply({ embeds: [ successEmbed( - '✅ Questions Updated', - `${newQuestions.length} question${newQuestions.length !== 1 ? 's' : ''} saved.`, + '✅ Fragen aktualisiert', + `${newQuestions.length} Frage${newQuestions.length !== 1 ? 'n' : ''} gespeichert.`, ), ], flags: MessageFlags.Ephemeral, @@ -924,23 +924,23 @@ async function handleQuestions(selectInteraction: StringSelectMenuInteraction, r async function handleRoleAdd(selectInteraction: StringSelectMenuInteraction, rootInteraction: AppDashboardInteraction, settings: ApplicationSettings, roles: ApplicationRole[], guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId(`app_cfg_role_add_modal_${guildId}`) - .setTitle('Add Application Role'); + .setTitle('Bewerbungsrolle hinzufügen'); const roleSelect = new RoleSelectMenuBuilder() .setCustomId('application_role') - .setPlaceholder('Select the role members can apply for...') + .setPlaceholder('Rolle wählen, auf die sich Mitglieder bewerben können...') .setMinValues(1) .setMaxValues(1) .setRequired(true); const roleLabel = new LabelBuilder() - .setLabel('Application Role') - .setDescription('Select the Discord role members will be applying for') + .setLabel('Bewerbungsrolle') + .setDescription('Die Discord-Rolle, auf die sich Mitglieder bewerben') .setRoleSelectMenuComponent(roleSelect); const nameInput = new TextInputBuilder() .setCustomId('role_name') - .setLabel('Display name (leave blank to use role name)') + .setLabel('Anzeigename (leer = Rollenname)') .setStyle(TextInputStyle.Short) .setMaxLength(50) .setRequired(false); @@ -961,7 +961,7 @@ async function handleRoleAdd(selectInteraction: StringSelectMenuInteraction, roo 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} ist bereits eine Bewerbungsrolle.` }); return; } @@ -972,7 +972,7 @@ async function handleRoleAdd(selectInteraction: StringSelectMenuInteraction, roo }); await modalSubmission.reply({ - embeds: [successEmbed('Role Added', `${role ?? roleId} added as **${customName}**.`)], + embeds: [successEmbed('Rolle hinzugefügt', `${role ?? roleId} wurde als **${customName}** hinzugefügt.`)], flags: MessageFlags.Ephemeral, }); @@ -982,7 +982,7 @@ async function handleRoleAdd(selectInteraction: StringSelectMenuInteraction, roo logger.error('Error in role add modal:', error); await replyUserError(selectInteraction, { type: ErrorTypes.UNKNOWN, - message: 'An error occurred while adding the application role.', + message: 'Beim Hinzufügen der Bewerbungsrolle ist ein Fehler aufgetreten.', }); } } @@ -991,25 +991,25 @@ async function handleRoleRemove(selectInteraction: StringSelectMenuInteraction, if (roles.length === 0) { await replyUserError(selectInteraction, { type: ErrorTypes.USER_INPUT, - message: 'There are no application roles configured to remove.', + message: 'Es sind keine Bewerbungsrollen eingerichtet, die entfernt werden könnten.', }); return; } const modal = new ModalBuilder() .setCustomId(`app_cfg_role_remove_modal_${guildId}`) - .setTitle('Remove Application Role'); + .setTitle('Bewerbungsrolle entfernen'); const roleSelect = new RoleSelectMenuBuilder() .setCustomId('remove_role') - .setPlaceholder('Select the role to remove...') + .setPlaceholder('Zu entfernende Rolle auswählen...') .setMinValues(1) .setMaxValues(1) .setRequired(true); const roleLabel = new LabelBuilder() - .setLabel('Remove Application Role') - .setDescription('Select the role to remove from the applications list') + .setLabel('Bewerbungsrolle entfernen') + .setDescription('Rolle, die aus der Bewerbungsliste entfernt werden soll') .setRoleSelectMenuComponent(roleSelect); modal.addLabelComponents(roleLabel); @@ -1026,7 +1026,7 @@ async function handleRoleRemove(selectInteraction: StringSelectMenuInteraction, 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}> ist nicht in der Liste der Bewerbungsrollen.` }); return; } @@ -1034,7 +1034,7 @@ async function handleRoleRemove(selectInteraction: StringSelectMenuInteraction, await saveApplicationRoles(client, guildId, roles); await modalSubmission.reply({ - embeds: [successEmbed('Role Removed', `<@&${roleId}> has been removed from the application roles.`)], + embeds: [successEmbed('Rolle entfernt', `<@&${roleId}> wurde aus den Bewerbungsrollen entfernt.`)], flags: MessageFlags.Ephemeral, }); @@ -1044,7 +1044,7 @@ async function handleRoleRemove(selectInteraction: StringSelectMenuInteraction, logger.error('Error in role remove modal:', error); await replyUserError(selectInteraction, { type: ErrorTypes.UNKNOWN, - message: 'An error occurred while removing the application role.', + message: 'Beim Entfernen der Bewerbungsrolle ist ein Fehler aufgetreten.', }); } } @@ -1052,17 +1052,17 @@ async function handleRoleRemove(selectInteraction: StringSelectMenuInteraction, async function handleRetention(selectInteraction: StringSelectMenuInteraction, rootInteraction: AppDashboardInteraction, settings: ApplicationSettings, roles: ApplicationRole[], guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId('app_cfg_retention') - .setTitle('Application Retention Periods'); + .setTitle('Aufbewahrungsdauer für Bewerbungen'); const retentionInfo = new TextDisplayBuilder() .setContent( - '**Pending** — how long unanswered/in-progress applications are kept before being automatically removed.\n' + - '**Reviewed** — how long approved or denied applications are kept.\n' + - '-# Enter a whole number between 1 and 3650 (max 10 years).', + '**Offen** — wie lange unbeantwortete bzw. laufende Bewerbungen gespeichert werden, bevor sie automatisch entfernt werden.\n' + + '**Geprüft** — wie lange angenommene oder abgelehnte Bewerbungen gespeichert werden.\n' + + '-# Gib eine ganze Zahl zwischen 1 und 3650 ein (max. 10 Jahre).', ); const pendingLabel = new LabelBuilder() - .setLabel('Pending retention (days)') + .setLabel('Aufbewahrung offen (Tage)') .setTextInputComponent( new TextInputBuilder() .setCustomId('pending_days') @@ -1074,7 +1074,7 @@ async function handleRetention(selectInteraction: StringSelectMenuInteraction, r ); const reviewedLabel = new LabelBuilder() - .setLabel('Reviewed retention (days)') + .setLabel('Aufbewahrung geprüft (Tage)') .setTextInputComponent( new TextInputBuilder() .setCustomId('reviewed_days') @@ -1105,12 +1105,12 @@ async function handleRetention(selectInteraction: StringSelectMenuInteraction, r const reviewedDays = parseInt(submitted.fields.getTextInputValue('reviewed_days').trim(), 10); if (isNaN(pendingDays) || pendingDays < 1 || pendingDays > 3650) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Pending retention must be a whole number between **1** and **3650** days.' }); + await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Die Aufbewahrung für offene Bewerbungen muss eine ganze Zahl zwischen **1** und **3650** Tagen sein.' }); return; } if (isNaN(reviewedDays) || reviewedDays < 1 || reviewedDays > 3650) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Reviewed retention must be a whole number between **1** and **3650** days.' }); + await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Die Aufbewahrung für geprüfte Bewerbungen muss eine ganze Zahl zwischen **1** und **3650** Tagen sein.' }); return; } @@ -1121,8 +1121,8 @@ async function handleRetention(selectInteraction: StringSelectMenuInteraction, r await submitted.reply({ embeds: [ successEmbed( - '✅ Retention Updated', - `Pending applications will be kept for **${pendingDays} days**.\nReviewed applications will be kept for **${reviewedDays} days**.`, + '✅ Aufbewahrung aktualisiert', + `Offene Bewerbungen werden **${pendingDays} Tage** gespeichert.\nGeprüfte Bewerbungen werden **${reviewedDays} Tage** gespeichert.`, ), ], flags: MessageFlags.Ephemeral, @@ -1136,7 +1136,7 @@ async function handleDeleteApplication(confirmSubmit: ModalSubmitInteraction, se const roleIndex = roles.findIndex(r => r.roleId === selectedRoleId); if (roleIndex === -1) { - await replyUserError(confirmSubmit, { type: ErrorTypes.USER_INPUT, message: 'Application role not found.' }); + await replyUserError(confirmSubmit, { type: ErrorTypes.USER_INPUT, message: 'Bewerbungsrolle nicht gefunden.' }); return; } @@ -1158,9 +1158,9 @@ async function handleDeleteApplication(confirmSubmit: ModalSubmitInteraction, se await confirmSubmit.reply({ embeds: [ successEmbed( - '🗑️ Application Deleted', - `The application for <@&${selectedRoleId}> (**${deletedRole.name}**) has been permanently deleted.\n\n` + - `Deleted: **${applicationsToDelete.length}** application${applicationsToDelete.length !== 1 ? 's' : ''}`, + '🗑️ Bewerbung gelöscht', + `Die Bewerbung für <@&${selectedRoleId}> (**${deletedRole.name}**) wurde endgültig gelöscht.\n\n` + + `Gelöscht: **${applicationsToDelete.length}** Bewerbung${applicationsToDelete.length !== 1 ? 'en' : ''}`, ), ], flags: MessageFlags.Ephemeral, @@ -1168,6 +1168,6 @@ async function handleDeleteApplication(confirmSubmit: ModalSubmitInteraction, se } catch (error) { logger.error('Error in handleDeleteApplication:', error); - await replyUserError(confirmSubmit, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while deleting the application. Please try again.' }); + await replyUserError(confirmSubmit, { type: ErrorTypes.UNKNOWN, message: 'Beim Löschen der Bewerbung ist ein Fehler aufgetreten. Bitte versuch es erneut.' }); } } \ No newline at end of file diff --git a/src/commands/Core/bot.ts b/src/commands/Core/bot.ts index a0a7c235f1..ce2c8fcf3f 100644 --- a/src/commands/Core/bot.ts +++ b/src/commands/Core/bot.ts @@ -6,7 +6,7 @@ import uptimeCommand from './modules/uptime.js'; export default createRouterCommand({ name: 'bot', - description: 'Bot status and information', + description: 'Status und Infos zum Bot', category: 'Core', routes: [ { legacyName: 'ping', module: pingCommand }, diff --git a/src/commands/Core/commands.ts b/src/commands/Core/commands.ts index ddb9fdc048..eae6fde6da 100644 --- a/src/commands/Core/commands.ts +++ b/src/commands/Core/commands.ts @@ -40,7 +40,7 @@ function buildCategoryChoices(client: NexoClient) { async function ensureManageGuild(interaction: ChatInputCommandInteraction) { if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { - await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the **Manage Server** permission to manage commands.' }); + await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Du brauchst die Berechtigung **Server verwalten**, um Commands zu verwalten.' }); return false; } @@ -50,32 +50,32 @@ async function ensureManageGuild(interaction: ChatInputCommandInteraction) { export default { data: new SlashCommandBuilder() .setName('commands') - .setDescription('Enable or disable bot commands and categories for this server') + .setDescription('Commands und Kategorien des Bots für diesen Server aktivieren oder deaktivieren') .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .setDMPermission(false) .addSubcommand((subcommand) => subcommand .setName('dashboard') - .setDescription('Open the interactive command access dashboard'), + .setDescription('Öffnet das interaktive Dashboard für Command-Zugriff'), ) .addSubcommand((subcommand) => subcommand .setName('disable') - .setDescription('Disable a command or entire category') + .setDescription('Einen Command oder eine ganze Kategorie deaktivieren') .addStringOption((option) => option .setName('scope') - .setDescription('Disable a single command or a whole category') + .setDescription('Einen einzelnen Command oder eine ganze Kategorie deaktivieren') .setRequired(true) .addChoices( - { name: 'Category', value: 'category' }, + { name: 'Kategorie', value: 'category' }, { name: 'Command', value: 'command' }, ), ) .addStringOption((option) => option .setName('target') - .setDescription('Category or command name') + .setDescription('Name der Kategorie oder des Commands') .setRequired(true) .setAutocomplete(true), ), @@ -83,21 +83,21 @@ export default { .addSubcommand((subcommand) => subcommand .setName('enable') - .setDescription('Enable a command or entire category') + .setDescription('Einen Command oder eine ganze Kategorie aktivieren') .addStringOption((option) => option .setName('scope') - .setDescription('Enable a single command or a whole category') + .setDescription('Einen einzelnen Command oder eine ganze Kategorie aktivieren') .setRequired(true) .addChoices( - { name: 'Category', value: 'category' }, + { name: 'Kategorie', value: 'category' }, { name: 'Command', value: 'command' }, ), ) .addStringOption((option) => option .setName('target') - .setDescription('Category or command name') + .setDescription('Name der Kategorie oder des Commands') .setRequired(true) .setAutocomplete(true), ), @@ -198,7 +198,7 @@ export default { }); await replyUserError(componentInteraction, { type: ErrorTypes.UNKNOWN, - message: (error as Error).message || 'Failed to update command access.', + message: (error as Error).message || 'Command-Zugriff konnte nicht aktualisiert werden.', }).catch(() => {}); } }); @@ -229,7 +229,7 @@ export default { 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.` }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `Keine Kategorie passt zu \`${target}\`. Nutze \`/commands dashboard\`, um die Kategorien zu durchsuchen.` }); } if (isDisable) { @@ -237,8 +237,8 @@ export default { return InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( - 'Category Disabled', - `All **${category.displayName}** commands are now disabled.\nProtected commands remain available.`, + 'Kategorie deaktiviert', + `Alle Commands aus **${category.displayName}** sind jetzt deaktiviert.\nGeschützte Commands bleiben verfügbar.`, ), ], }); @@ -246,7 +246,7 @@ export default { 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).`)], + embeds: [successEmbed('Kategorie aktiviert', `Die Commands aus **${category.displayName}** sind jetzt aktiviert (außer einzeln deaktivierte Commands).`)], }); } @@ -254,13 +254,13 @@ export default { if (isDisable) { const result = await disableCommand(client, interaction.guildId!, commandName); return InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed('Command Disabled', `\`/${result.commandName}\` is now disabled in this server.`)], + embeds: [successEmbed('Command deaktiviert', `\`/${result.commandName}\` ist auf diesem Server jetzt deaktiviert.`)], }); } const result = await enableCommand(client, interaction.guildId!, commandName); return InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed('Command Enabled', `\`/${result.commandName}\` is now enabled in this server.`)], + embeds: [successEmbed('Command aktiviert', `\`/${result.commandName}\` ist auf diesem Server jetzt aktiviert.`)], }); }, } satisfies Command; diff --git a/src/commands/Core/configWizard.ts b/src/commands/Core/configWizard.ts index 95e5e0234b..c466600500 100644 --- a/src/commands/Core/configWizard.ts +++ b/src/commands/Core/configWizard.ts @@ -49,17 +49,17 @@ const WIZARD_BUTTON_ID = 'config_wizard'; const activeWizardSessions = new Set(); const DM_DISABLED_HELP = [ - '1. Right-click this server\'s name (mobile: tap the server name at the top).', - '2. Open **Privacy Settings**.', - '3. Turn on **Allow direct messages from server members**.', - '4. Click **Start Setup Wizard** again.', + '1. Rechtsklick auf den Servernamen (mobil: oben auf den Servernamen tippen).', + '2. Öffne die **Privatsphäre-Einstellungen**.', + '3. Aktiviere **Direktnachrichten von Servermitgliedern erlauben**.', + '4. Klicke erneut auf **Einrichtung starten**.', ].join('\n'); async function notifyWizardStarted(buttonInteraction: ButtonInteraction) { await buttonInteraction.followUp({ embeds: [infoEmbed( - 'Setup Wizard Started', - 'Check your DMs — I sent you the first setup question there.\n\nAnswer each question in that DM. Type `skip` to keep the current value.', + 'Einrichtung gestartet', + 'Schau in deine DMs — dort findest du die erste Frage zur Einrichtung.\n\nBeantworte jede Frage in dieser DM. Schreib `skip`, um den aktuellen Wert zu behalten.', )], flags: MessageFlags.Ephemeral, }).catch(() => {}); @@ -68,13 +68,13 @@ async function notifyWizardStarted(buttonInteraction: ButtonInteraction) { async function notifyWizardDmBlocked(buttonInteraction: ButtonInteraction) { await replyUserError(buttonInteraction, { type: ErrorTypes.USER_INPUT, - message: `I couldn't send you a DM. Enable DMs from this server, then try again.\n\n${DM_DISABLED_HELP}`, + message: `Ich konnte dir keine DM schicken. Erlaube DMs von diesem Server und versuch es dann erneut.\n\n${DM_DISABLED_HELP}`, }).catch(() => {}); } function formatChannelMention(guild: Guild, channelId: string | null | undefined) { if (!channelId) { - return '`Not set`'; + return '`Nicht festgelegt`'; } const channel = guild.channels.cache.get(channelId); return channel ? `<#${channelId}>` : `#${channelId}`; @@ -82,7 +82,7 @@ function formatChannelMention(guild: Guild, channelId: string | null | undefined function formatRoleMention(guild: Guild, roleId: string | null | undefined) { if (!roleId) { - return '`Not set`'; + return '`Nicht festgelegt`'; } const role = guild.roles.cache.get(roleId); return role ? `<@&${roleId}>` : `@${roleId}`; @@ -91,10 +91,10 @@ function formatRoleMention(guild: Guild, roleId: string | null | undefined) { function getBotPresenceText() { const activity = botConfig.presence?.activities?.[0]; if (!activity?.name) { - return '`Not configured`'; + return '`Nicht konfiguriert`'; } - const typeLabels = ['Playing', 'Streaming', 'Listening to', 'Watching', '', 'Competing in']; + const typeLabels = ['Spielt', 'Streamt', 'Hört', 'Schaut', '', 'Tritt an in']; const typeLabel = typeLabels[activity.type]; if (!typeLabel) { return activity.name; @@ -106,8 +106,8 @@ function getBotPresenceText() { function getThemeColorLines() { const colors = botConfig.embeds.colors; return [ - `🎨 Primary \`${colors.primary}\` · Success \`${colors.success}\``, - `⚠️ Warning \`${colors.warning}\` · Error \`${colors.error}\``, + `🎨 Primär \`${colors.primary}\` · Erfolg \`${colors.success}\``, + `⚠️ Warnung \`${colors.warning}\` · Fehler \`${colors.error}\``, ].join('\n'); } @@ -115,49 +115,49 @@ function buildDashboardEmbed(config: GuildConfig, guild: Guild) { const setupDone = config.setupWizardCompleted; return createEmbed({ - title: '⚙️ Server Configuration', - description: `Core settings for **${guild.name}**. Pick an option below or run the setup wizard.`, + title: '⚙️ Server-Konfiguration', + description: `Grundeinstellungen für **${guild.name}**. Wähle unten eine Option oder starte die Einrichtung.`, color: 'info', fields: [ { - name: '⌨️ Server Prefix', + name: '⌨️ Server-Prefix', value: `\`${config.prefix || getCommandPrefix()}\``, inline: true, }, { - name: '🛡️ Moderator Role', + name: '🛡️ Moderator-Rolle', value: formatRoleMention(guild, config.modRole), inline: true, }, { - name: '📋 Log Channel', + name: '📋 Log-Kanal', value: formatChannelMention(guild, config.logging?.channels?.audit), inline: true, }, { - name: '💚 Bot Status', + name: '💚 Bot-Status', value: getBotPresenceText(), inline: false, }, { - name: '🎨 Embed Theme', - value: `${getThemeColorLines()}\n-# Colors are set in bot config and apply globally.`, + name: '🎨 Embed-Design', + value: `${getThemeColorLines()}\n-# Die Farben werden in der Bot-Konfiguration festgelegt und gelten global.`, inline: false, }, { - name: '⚡ Command Access', - value: 'Use `/commands dashboard` to enable or disable commands and subcommands.', + name: '⚡ Command-Zugriff', + value: 'Mit `/commands dashboard` aktivierst oder deaktivierst du Commands und Subcommands.', inline: false, }, { - name: `${setupDone ? '✅' : '📝'} Setup`, + name: `${setupDone ? '✅' : '📝'} Einrichtung`, value: setupDone - ? 'Setup wizard completed — re-run anytime to update settings.' - : 'Run the setup wizard to configure your server quickly.', + ? 'Einrichtung abgeschlossen — du kannst sie jederzeit erneut starten, um Einstellungen zu ändern.' + : 'Starte die Einrichtung, um deinen Server schnell zu konfigurieren.', inline: false, }, ], - footer: 'Dashboard closes after 10 minutes of inactivity', + footer: 'Das Dashboard schließt sich nach 10 Minuten Inaktivität', }); } @@ -165,21 +165,21 @@ function buildSettingsSelect(guildId: string) { return new ActionRowBuilder().addComponents( new StringSelectMenuBuilder() .setCustomId(`${DASHBOARD_CUSTOM_ID}:${guildId}`) - .setPlaceholder('⚙️ Select a setting to edit...') + .setPlaceholder('⚙️ Einstellung zum Bearbeiten auswählen...') .addOptions( new StringSelectMenuOptionBuilder() - .setLabel('Server Prefix') - .setDescription('Change the text command prefix') + .setLabel('Server-Prefix') + .setDescription('Prefix für Text-Commands ändern') .setValue('prefix') .setEmoji('⌨️'), new StringSelectMenuOptionBuilder() - .setLabel('Moderator Role') - .setDescription('Role used for moderation commands') + .setLabel('Moderator-Rolle') + .setDescription('Rolle für Moderations-Commands') .setValue('modRole') .setEmoji('🛡️'), new StringSelectMenuOptionBuilder() - .setLabel('Log Channel') - .setDescription('Channel for system log messages') + .setLabel('Log-Kanal') + .setDescription('Kanal für System-Log-Nachrichten') .setValue('logChannelId') .setEmoji('📋'), ), @@ -190,7 +190,7 @@ function buildButtonRow(config: GuildConfig, guildId: string) { return new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`${WIZARD_BUTTON_ID}:${guildId}`) - .setLabel(config.setupWizardCompleted ? 'Re-run Setup Wizard' : 'Start Setup Wizard') + .setLabel(config.setupWizardCompleted ? 'Einrichtung erneut starten' : 'Einrichtung starten') .setEmoji('📝') .setStyle(config.setupWizardCompleted ? ButtonStyle.Secondary : ButtonStyle.Success), ); @@ -214,7 +214,7 @@ function extractId(value: unknown) { async function askQuestion(dmChannel: DMChannel, userId: string, prompt: string, stepNumber: number, totalSteps: number): Promise { await dmChannel.send({ embeds: [createEmbed({ - title: `Setup Question ${stepNumber}/${totalSteps}`, + title: `Einrichtung – Frage ${stepNumber}/${totalSteps}`, description: prompt, color: 'primary', })], @@ -228,7 +228,7 @@ async function askQuestion(dmChannel: DMChannel, userId: string, prompt: string, if (!collected || !collected.size) { await dmChannel.send({ - embeds: [buildUserErrorEmbed(ErrorTypes.RATE_LIMIT, 'You did not answer in time. Run the setup wizard again when ready.')], + embeds: [buildUserErrorEmbed(ErrorTypes.RATE_LIMIT, 'Du hast nicht rechtzeitig geantwortet. Starte die Einrichtung erneut, wenn du so weit bist.')], }); return null; } @@ -236,7 +236,7 @@ async function askQuestion(dmChannel: DMChannel, userId: string, prompt: string, const answer = collected.first()!.content.trim(); if (answer.toLowerCase() === 'cancel') { await dmChannel.send({ - embeds: [infoEmbed('Setup Cancelled', 'Setup wizard stopped. Your saved answers are still applied.')], + embeds: [infoEmbed('Einrichtung abgebrochen', 'Die Einrichtung wurde beendet. Deine bereits gespeicherten Antworten bleiben übernommen.')], }); return { cancelled: true }; } @@ -246,32 +246,32 @@ async function askQuestion(dmChannel: DMChannel, userId: string, prompt: string, function formatSavedAck(key: string, value: string | null, guild: Guild) { if (key === 'prefix') { - return `Server prefix saved as \`${value}\`.`; + return `Server-Prefix gespeichert: \`${value}\`.`; } if (key === 'logChannelId') { if (value === null) { - return 'Log channel cleared.'; + return 'Log-Kanal entfernt.'; } const channel = guild.channels.cache.get(value); - return `Log channel saved as ${channel ?? `<#${value}>`}.`; + return `Log-Kanal gespeichert: ${channel ?? `<#${value}>`}.`; } if (key === 'modRole') { if (value === null) { - return 'Moderator role cleared.'; + return 'Moderator-Rolle entfernt.'; } const role = guild.roles.cache.get(value); - return `Moderator role saved as ${role ?? `<@&${value}>`}.`; + return `Moderator-Rolle gespeichert: ${role ?? `<@&${value}>`}.`; } - return 'Setting saved.'; + return 'Einstellung gespeichert.'; } async function validateGuildChannelId(guild: Guild, channelId: string) { const channel = guild.channels.cache.get(channelId) ?? await guild.channels.fetch(channelId).catch(() => null); if (!channel || !channel.isTextBased()) { - throw new Error('That channel was not found in this server or is not a text channel.'); + throw new Error('Dieser Kanal wurde auf diesem Server nicht gefunden oder ist kein Textkanal.'); } return channel.id; } @@ -279,7 +279,7 @@ async function validateGuildChannelId(guild: Guild, channelId: string) { async function validateGuildRoleId(guild: Guild, roleId: string) { const role = guild.roles.cache.get(roleId) ?? await guild.roles.fetch(roleId).catch(() => null); if (!role) { - throw new Error('That role was not found in this server.'); + throw new Error('Diese Rolle wurde auf diesem Server nicht gefunden.'); } return role.id; } @@ -295,7 +295,7 @@ async function runSetupWizard(buttonInteraction: ButtonInteraction, config: Guil if (activeWizardSessions.has(user.id)) { await buttonInteraction.followUp({ - embeds: [warningEmbed('Setup Already Running', 'You already have a setup wizard open in your DMs. Reply there to continue, or type `cancel` to stop it.')], + embeds: [warningEmbed('Einrichtung läuft bereits', 'Du hast bereits eine Einrichtung in deinen DMs offen. Antworte dort, um weiterzumachen, oder schreib `cancel`, um sie zu beenden.')], flags: MessageFlags.Ephemeral, }).catch(() => {}); return; @@ -320,40 +320,40 @@ async function runSetupWizard(buttonInteraction: ButtonInteraction, config: Guil const prompts: WizardPrompt[] = [ { key: 'prefix', - skipMessage: 'Keeping the current server prefix.', - question: 'What command prefix should this server use?\nCurrent: `' + (config.prefix || getCommandPrefix()) + '`\nReply `skip` to keep it, or `cancel` to stop.', + skipMessage: 'Der aktuelle Server-Prefix bleibt.', + question: 'Welchen Command-Prefix soll dieser Server nutzen?\nAktuell: `' + (config.prefix || getCommandPrefix()) + '`\nAntworte mit `skip`, um ihn zu behalten, oder `cancel`, um abzubrechen.', parse: async (answer) => { const normalized = answer.trim(); if (normalized.toLowerCase() === 'skip') return undefined; if (/\s/.test(normalized) || normalized.length < 1 || normalized.length > 10) { - throw new Error('Prefix must be 1-10 characters with no spaces.'); + throw new Error('Der Prefix muss 1–10 Zeichen lang sein und darf keine Leerzeichen enthalten.'); } return normalized; }, }, { key: 'logChannelId', - skipMessage: 'Keeping the current log channel.', - question: 'Which channel should receive bot logs?\nSend a channel mention, channel ID, `none` to clear, `skip` to keep the current value, or `cancel` to stop.', + skipMessage: 'Der aktuelle Log-Kanal bleibt.', + question: 'In welchen Kanal sollen die Bot-Logs gehen?\nSchick eine Kanal-Erwähnung oder Kanal-ID, `none` zum Entfernen, `skip` zum Behalten oder `cancel` zum Abbrechen.', parse: async (answer) => { const normalized = answer.trim(); if (normalized.toLowerCase() === 'skip') return undefined; if (normalized.toLowerCase() === 'none') return null; const id = extractId(normalized); - if (!id) throw new Error('Provide a valid channel mention or ID from this server.'); + if (!id) throw new Error('Gib eine gültige Kanal-Erwähnung oder Kanal-ID von diesem Server an.'); return validateGuildChannelId(guild, id); }, }, { key: 'modRole', - skipMessage: 'Keeping the current moderator role.', - question: 'What role should moderators have?\nSend a role mention, role ID, `none` to clear, `skip` to keep the current value, or `cancel` to stop.', + skipMessage: 'Die aktuelle Moderator-Rolle bleibt.', + question: 'Welche Rolle sollen Moderatoren haben?\nSchick eine Rollen-Erwähnung oder Rollen-ID, `none` zum Entfernen, `skip` zum Behalten oder `cancel` zum Abbrechen.', parse: async (answer) => { const normalized = answer.trim(); if (normalized.toLowerCase() === 'skip') return undefined; if (normalized.toLowerCase() === 'none') return null; const id = extractId(normalized); - if (!id) throw new Error('Provide a valid role mention or ID from this server.'); + if (!id) throw new Error('Gib eine gültige Rollen-Erwähnung oder Rollen-ID von diesem Server an.'); return validateGuildRoleId(guild, id); }, }, @@ -367,8 +367,8 @@ async function runSetupWizard(buttonInteraction: ButtonInteraction, config: Guil try { await dmChannel.send({ embeds: [createEmbed({ - title: '📝 Setup Wizard', - description: 'Answer each question in this DM.\n\n• Type `skip` to keep the current value\n• Type `cancel` to stop the wizard', + title: '📝 Einrichtung', + description: 'Beantworte jede Frage in dieser DM.\n\n• Schreib `skip`, um den aktuellen Wert zu behalten\n• Schreib `cancel`, um die Einrichtung abzubrechen', color: 'info', })], }); @@ -410,13 +410,13 @@ async function runSetupWizard(buttonInteraction: ButtonInteraction, config: Guil if (value === undefined) { await dmChannel.send({ - embeds: [infoEmbed('Skipped', prompt.skipMessage)], + embeds: [infoEmbed('Übersprungen', prompt.skipMessage)], }); } else { await ConfigService.updateSetting(client, guild.id, prompt.key, value, user.id); changes[prompt.key] = value; await dmChannel.send({ - embeds: [successEmbed('Saved', formatSavedAck(prompt.key, value, guild))], + embeds: [successEmbed('Gespeichert', formatSavedAck(prompt.key, value, guild))], }); try { @@ -431,7 +431,7 @@ async function runSetupWizard(buttonInteraction: ButtonInteraction, config: Guil } catch (error) { errors.push(`• ${prompt.key}: ${(error as Error).message}`); await dmChannel.send({ - embeds: [buildUserErrorEmbed(ErrorTypes.VALIDATION, `${(error as Error).message}\n\nPlease reply again with a valid answer, \`skip\`, or \`cancel\`.`)], + embeds: [buildUserErrorEmbed(ErrorTypes.VALIDATION, `${(error as Error).message}\n\nBitte antworte erneut mit einer gültigen Antwort, \`skip\` oder \`cancel\`.`)], }); } } @@ -450,16 +450,16 @@ async function runSetupWizard(buttonInteraction: ButtonInteraction, config: Guil } const summaryTitle = wizardCancelled - ? (Object.keys(changes).length > 0 ? 'Setup Stopped' : 'Setup Cancelled') - : (errors.length > 0 ? 'Setup Complete' : 'Setup Complete'); + ? (Object.keys(changes).length > 0 ? 'Einrichtung beendet' : 'Einrichtung abgebrochen') + : (errors.length > 0 ? 'Einrichtung abgeschlossen' : 'Einrichtung abgeschlossen'); const summaryBody = wizardCancelled ? (Object.keys(changes).length > 0 - ? `Setup stopped early. Saved **${Object.keys(changes).length}** setting(s) before stopping.` - : 'Setup wizard stopped before any changes were saved.') + ? `Einrichtung vorzeitig beendet. Bis dahin wurden **${Object.keys(changes).length}** Einstellung(en) gespeichert.` + : 'Die Einrichtung wurde beendet, bevor Änderungen gespeichert wurden.') : (Object.keys(changes).length > 0 - ? `Updated **${Object.keys(changes).length}** setting(s).${errors.length > 0 ? ' Some answers needed retries.' : ''}` - : 'No changes were applied.'); + ? `**${Object.keys(changes).length}** Einstellung(en) aktualisiert.${errors.length > 0 ? ' Einige Antworten mussten wiederholt werden.' : ''}` + : 'Es wurden keine Änderungen übernommen.'); const summaryEmbed = createEmbed({ title: wizardCancelled ? `⚠️ ${summaryTitle}` : `✅ ${summaryTitle}`, @@ -469,7 +469,7 @@ async function runSetupWizard(buttonInteraction: ButtonInteraction, config: Guil if (errors.length > 0) { const uniqueErrors = [...new Set(errors)]; - summaryEmbed.addFields({ name: 'Issues', value: uniqueErrors.join('\n').slice(0, 1024) }); + summaryEmbed.addFields({ name: 'Probleme', value: uniqueErrors.join('\n').slice(0, 1024) }); } await dmChannel.send({ embeds: [summaryEmbed] }); @@ -491,19 +491,19 @@ async function showSettingModal(selectInteraction: StringSelectMenuInteraction, if (setting === 'logChannelId') { const modal = new ModalBuilder() .setCustomId(modalCustomId) - .setTitle('📋 Update Log Channel'); + .setTitle('📋 Log-Kanal ändern'); const channelSelect = new ChannelSelectMenuBuilder() .setCustomId('log_channel') - .setPlaceholder('Select a text channel...') + .setPlaceholder('Textkanal auswählen...') .setMinValues(1) .setMaxValues(1) .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement) .setRequired(true); const channelLabel = new LabelBuilder() - .setLabel('Log Channel') - .setDescription('Channel where system log messages will be sent') + .setLabel('Log-Kanal') + .setDescription('Kanal, in den System-Log-Nachrichten gesendet werden') .setChannelSelectMenuComponent(channelSelect); modal.addLabelComponents(channelLabel); @@ -514,18 +514,18 @@ async function showSettingModal(selectInteraction: StringSelectMenuInteraction, if (setting === 'modRole') { const modal = new ModalBuilder() .setCustomId(modalCustomId) - .setTitle('🛡️ Update Moderator Role'); + .setTitle('🛡️ Moderator-Rolle ändern'); const roleSelect = new RoleSelectMenuBuilder() .setCustomId('mod_role') - .setPlaceholder('Select a moderator role...') + .setPlaceholder('Moderator-Rolle auswählen...') .setMinValues(1) .setMaxValues(1) .setRequired(true); const roleLabel = new LabelBuilder() - .setLabel('Moderator Role') - .setDescription('Role used for moderation commands') + .setLabel('Moderator-Rolle') + .setDescription('Rolle für Moderations-Commands') .setRoleSelectMenuComponent(roleSelect); modal.addLabelComponents(roleLabel); @@ -535,11 +535,11 @@ async function showSettingModal(selectInteraction: StringSelectMenuInteraction, const modal = new ModalBuilder() .setCustomId(modalCustomId) - .setTitle('Update Server Prefix'); + .setTitle('Server-Prefix ändern'); const textInput = new TextInputBuilder() .setCustomId('value') - .setLabel('New prefix (1-10 characters, no spaces)') + .setLabel('Neuer Prefix (1–10 Zeichen, ohne Leerzeichen)') .setStyle(TextInputStyle.Short) .setRequired(true) .setMinLength(1) @@ -553,7 +553,7 @@ function resolveSettingModalValue(setting: string, submitted: ModalSubmitInterac if (setting === 'logChannelId') { const channelId = (submitted.fields.getField('log_channel') as SelectMenuModalData)?.values?.[0]; if (!channelId) { - throw new Error('Please select a log channel.'); + throw new Error('Bitte wähle einen Log-Kanal aus.'); } return channelId; } @@ -561,14 +561,14 @@ function resolveSettingModalValue(setting: string, submitted: ModalSubmitInterac if (setting === 'modRole') { const roleId = (submitted.fields.getField('mod_role') as SelectMenuModalData)?.values?.[0]; if (!roleId) { - throw new Error('Please select a moderator role.'); + throw new Error('Bitte wähle eine Moderator-Rolle aus.'); } return roleId; } const prefix = submitted.fields.getTextInputValue('value')?.trim(); if (!prefix || prefix.length < 1 || prefix.length > 10 || /\s/.test(prefix)) { - throw new Error('Prefix must be 1-10 characters with no spaces.'); + throw new Error('Der Prefix muss 1–10 Zeichen lang sein und darf keine Leerzeichen enthalten.'); } return prefix; } @@ -576,15 +576,15 @@ function resolveSettingModalValue(setting: string, submitted: ModalSubmitInterac function buildSettingSuccessMessage(setting: string, value: string, guild: Guild) { if (setting === 'logChannelId') { const channel = guild.channels.cache.get(value); - return `Log channel set to ${channel ?? `<#${value}>`}.`; + return `Log-Kanal festgelegt: ${channel ?? `<#${value}>`}.`; } if (setting === 'modRole') { const role = guild.roles.cache.get(value); - return `Moderator role set to ${role ?? `<@&${value}>`}.`; + return `Moderator-Rolle festgelegt: ${role ?? `<@&${value}>`}.`; } - return `Server prefix set to \`${value}\`.`; + return `Server-Prefix festgelegt: \`${value}\`.`; } async function handleSettingModalSubmit(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, setting: string, guildId: string, client: NexoClient) { @@ -608,7 +608,7 @@ async function handleSettingModalSubmit(selectInteraction: StringSelectMenuInter await ConfigService.updateSetting(client, guildId, setting, value, submitted.user.id); await submitted.reply({ - embeds: [successEmbed('Configuration Updated', buildSettingSuccessMessage(setting, value, submitted.guild!))], + embeds: [successEmbed('Konfiguration aktualisiert', buildSettingSuccessMessage(setting, value, submitted.guild!))], flags: MessageFlags.Ephemeral, }); @@ -618,7 +618,7 @@ async function handleSettingModalSubmit(selectInteraction: StringSelectMenuInter logger.error('Config wizard modal submit error:', error); await replyUserError(submitted, { type: ErrorTypes.CONFIGURATION, - message: (error as Error).message || 'Please try again.', + message: (error as Error).message || 'Bitte versuch es erneut.', }).catch(() => {}); } } @@ -627,7 +627,7 @@ export default { slashOnly: true, data: new SlashCommandBuilder() .setName('configwizard') - .setDescription('Open the server configuration dashboard and setup wizard') + .setDescription('Öffnet das Konfigurations-Dashboard und die Einrichtung des Servers') .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .setDMPermission(false), category: 'Core', @@ -642,7 +642,7 @@ export default { if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { return replyUserError(interaction, { type: ErrorTypes.PERMISSION, - message: 'You need the **Manage Server** permission to use this command.', + message: 'Du brauchst die Berechtigung **Server verwalten**, um diesen Command zu nutzen.', }); } @@ -693,7 +693,7 @@ export default { logger.error('Config dashboard interaction error:', error); await replyUserError(componentInteraction, { type: ErrorTypes.UNKNOWN, - message: 'Failed to process your selection. Please try again.', + message: 'Deine Auswahl konnte nicht verarbeitet werden. Bitte versuch es erneut.', }).catch(() => {}); } }); @@ -701,7 +701,7 @@ export default { logger.error('Config command error:', error); await replyUserError(interaction, { type: ErrorTypes.CONFIGURATION, - message: 'Failed to open configuration dashboard. Please try again.', + message: 'Das Konfigurations-Dashboard konnte nicht geöffnet werden. Bitte versuch es erneut.', }); } }, diff --git a/src/commands/Core/help.ts b/src/commands/Core/help.ts index 587b7df7ef..c658d7d7c8 100644 --- a/src/commands/Core/help.ts +++ b/src/commands/Core/help.ts @@ -8,6 +8,7 @@ import type { Command, NexoClient } from '../../types/index.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { createEmbed } from "../../utils/embeds.js"; +import { formatCategoryName, getCategoryIcon } from "../../config/commands/commandCategories.js"; import { createSelectMenu, } from "../../utils/components.js"; @@ -23,34 +24,6 @@ const ALL_COMMANDS_ID = "help-all-commands"; const BUG_REPORT_BUTTON_ID = "help-bug-report"; const HELP_MENU_TIMEOUT_MS = 5 * 60 * 1000; -const CATEGORY_ICONS: Record = { - Core: "ℹ️", - Moderation: "🛡️", - Economy: "💰", - Music: "🎵", - Fun: "🎮", - Leveling: "📊", - Utility: "🔧", - Ticket: "🎫", - Welcome: "👋", - Giveaway: "🎉", - Counter: "🔢", - Tools: "🛠️", - Search: "🔍", - "Reaction Roles": "🎭", - Community: "👥", - Birthday: "🎂", - "Join To Create": "🔌", - Verification: "✅", -}; - -function formatCategoryName(rawCategory: string) { - return rawCategory - .replace(/_/g, '') - .replace(/([a-z])([A-Z])/g, '$1 $2') - .replace(/\b\w/g, (char) => char.toUpperCase()); -} - export async function createInitialHelpMenu(client: NexoClient) { const commandsPath = path.join(__dirname, "../../commands"); const categoryDirs = ( @@ -62,67 +35,68 @@ export async function createInitialHelpMenu(client: NexoClient) { const options = [ { - label: "📋 All Commands", - description: "Browse every available command in a single list", + label: "📋 Alle Commands", + description: "Alle verfügbaren Commands in einer Liste durchsuchen", value: ALL_COMMANDS_ID, }, ...categoryDirs.map((category) => { const categoryName = formatCategoryName(category); - const icon = CATEGORY_ICONS[categoryName] || "🔍"; + const icon = getCategoryIcon(category); return { label: `${icon} ${categoryName}`, - description: `View commands in the ${categoryName} category`, + description: `Commands der Kategorie ${categoryName} anzeigen`, value: category, }; }), ]; - const botName = client?.user?.username || "Bot"; + const botName = client?.user?.username || "Nexo"; const embed = createEmbed({ - title: `📖 ${botName} Help`, - description: 'Set up your server, pick what to enable, then browse commands below.', + title: `📖 ${botName} – Hilfe`, + description: 'Richte deinen Server ein, wähle aus, was aktiv sein soll, und stöbere dann unten durch die Commands.', color: 'primary', thumbnail: client.user?.displayAvatarURL?.({ size: 1024 }), fields: [ { - name: '🚀 Getting Started', + name: '🚀 Erste Schritte', value: [ - '**1. Launch setup** — Run `/configwizard` to configure prefix, mod role, and logs.', - '**2. Enable systems** — Use `/commands dashboard` to turn categories on or off.', '**3. Browse commands** — Use the menu below to view categories and commands.', + '**1. Einrichtung starten** — Mit `/configwizard` legst du Prefix, Moderator-Rolle und Logs fest.', + '**2. Systeme aktivieren** — Mit `/commands dashboard` schaltest du Kategorien an oder aus.', + '**3. Commands entdecken** — Im Menü unten findest du alle Kategorien und Commands.', ].join('\n'), inline: false, }, { - name: 'ℹ️ How It Works', + name: 'ℹ️ So funktioniert’s', value: [ - '• Dashboard commands manage each feature visually', - '• Settings are saved per server', - '• Slash commands and prefixes both work once enabled', + '• Dashboard-Commands verwalten jede Funktion übersichtlich', + '• Einstellungen werden pro Server gespeichert', + '• Slash-Commands und Prefix-Commands funktionieren, sobald sie aktiviert sind', ].join('\n'), inline: false, }, { name: '\u200B', - value: `-# ${botName} is [open source](https://github.com/Robertwlt03/NexoBot)`, + value: `-# ${botName} ist [Open Source](https://github.com/Robertwlt03/NexoBot)`, inline: false, }, ], }); embed.setFooter({ - text: "Made with ❤️" + text: "Ein Bot. Alle Funktionen. · Mit ❤️ gemacht" }); embed.setTimestamp(); const bugReportButton = new ButtonBuilder() .setCustomId(BUG_REPORT_BUTTON_ID) - .setLabel("Report Bug") + .setLabel("Bug melden") .setStyle(ButtonStyle.Danger); const selectRow = createSelectMenu( CATEGORY_SELECT_ID, - "Select to view the commands", + "Wähle eine Kategorie, um die Commands zu sehen", options, ); @@ -140,7 +114,7 @@ export default { slashOnly: true, data: new SlashCommandBuilder() .setName("help") - .setDescription("Displays the help menu with all available commands"), + .setDescription("Zeigt das Hilfemenü mit allen verfügbaren Commands"), async execute(interaction, guildConfig, client) { @@ -161,8 +135,8 @@ export default { } const closedEmbed = createEmbed({ - title: "Help menu closed", - description: "Help menu has been closed, use /help again.", + title: "Hilfemenü geschlossen", + description: "Das Hilfemenü wurde geschlossen. Nutze /help erneut.", color: "secondary", }); diff --git a/src/commands/Core/modules/commands_dashboard.ts b/src/commands/Core/modules/commands_dashboard.ts index 05839ab08e..5f755e9e3f 100644 --- a/src/commands/Core/modules/commands_dashboard.ts +++ b/src/commands/Core/modules/commands_dashboard.ts @@ -93,22 +93,22 @@ export function buildOverviewEmbed(snapshot: CommandAccessSnapshot, guild: Guild const categoryLines = snapshot.categories.map((category) => { const icon = getCategoryStatus(category); - const subcommandNote = category.commands.some((c) => c.isSubcommand) ? ' · incl. subcommands' : ''; + const subcommandNote = category.commands.some((c) => c.isSubcommand) ? ' · inkl. Subcommands' : ''; return `${icon} ${category.icon} **${category.displayName}** — ${category.enabledCount}/${category.totalCount}${subcommandNote}`; }); const fields: APIEmbedField[] = [ { - name: '📊 Summary', + name: '📊 Übersicht', value: [ - `**${snapshot.enabledTotal}/${snapshot.totalCommands}** entries enabled`, - `${STATUS.enabled} ${fullyEnabled} fully on · ${STATUS.partial} ${partial} partial · ${STATUS.disabled} ${disabled} off`, + `**${snapshot.enabledTotal}/${snapshot.totalCommands}** Einträge aktiviert`, + `${STATUS.enabled} ${fullyEnabled} komplett an · ${STATUS.partial} ${partial} teilweise · ${STATUS.disabled} ${disabled} aus`, ].join('\n'), inline: false, }, { - name: '🔑 Legend', - value: `${STATUS.enabled} All enabled · ${STATUS.partial} Some disabled · ${STATUS.disabled} Category off`, + name: '🔑 Legende', + value: `${STATUS.enabled} Alle aktiviert · ${STATUS.partial} Teilweise deaktiviert · ${STATUS.disabled} Kategorie aus`, inline: false, }, ]; @@ -116,37 +116,37 @@ export function buildOverviewEmbed(snapshot: CommandAccessSnapshot, guild: Guild const chunks = chunkLines(categoryLines); chunks.forEach((chunk, index) => { fields.push({ - name: index === 0 ? '📁 Categories' : '📁 Categories (cont.)', + name: index === 0 ? '📁 Kategorien' : '📁 Kategorien (Forts.)', value: chunk, inline: false, }); }); fields.push({ - name: 'How to Use', + name: 'So funktioniert’s', value: [ - '• Select a category below to manage commands and subcommands', - '• `/commands disable` — turn off a category or specific command', - '• `/commands enable` — turn something back on', + '• Wähle unten eine Kategorie, um Commands und Subcommands zu verwalten', + '• `/commands disable` — eine Kategorie oder einen Command deaktivieren', + '• `/commands enable` — etwas wieder aktivieren', ].join('\n'), }); return createEmbed({ - title: '⚙️ Command Access', - description: `Manage slash and prefix commands for **${guild.name}**. Subcommands (e.g. \`birthday list\`) are listed separately.`, + title: '⚙️ Command-Zugriff', + description: `Verwalte Slash- und Prefix-Commands für **${guild.name}**. Subcommands (z. B. \`birthday list\`) werden einzeln aufgeführt.`, color: 'info', fields, - footer: '🔒 commands & configwizard always stay available', + footer: '🔒 commands & configwizard bleiben immer verfügbar', }); } export function buildCategoryEmbed(category: CommandAccessCategory, guild: Guild) { const statusIcon = getCategoryStatus(category); const statusText = category.categoryDisabled - ? 'Category disabled' + ? 'Kategorie deaktiviert' : category.disabledCount === 0 - ? 'All entries enabled' - : `${category.disabledCount} of ${category.totalCount} disabled`; + ? 'Alle Einträge aktiviert' + : `${category.disabledCount} von ${category.totalCount} deaktiviert`; const commandLines = category.commands.map((command) => { const enabled = category.enabledCommands.includes(command.name); @@ -162,8 +162,8 @@ export function buildCategoryEmbed(category: CommandAccessCategory, guild: Guild inline: true, }, { - name: '📈 Count', - value: `${category.enabledCount}/${category.totalCount} enabled`, + name: '📈 Anzahl', + value: `${category.enabledCount}/${category.totalCount} aktiviert`, inline: true, }, ]; @@ -171,27 +171,27 @@ export function buildCategoryEmbed(category: CommandAccessCategory, guild: Guild const chunks = chunkLines(commandLines); chunks.forEach((chunk, index) => { fields.push({ - name: index === 0 ? '📋 Commands & Subcommands' : '📋 (cont.)', + name: index === 0 ? '📋 Commands & Subcommands' : '📋 (Forts.)', value: chunk, inline: false, }); }); fields.push({ - name: 'How to Use', + name: 'So funktioniert’s', value: [ - '• Use the dropdown to toggle individual commands or subcommands', - '• **Disable All** turns off the whole category', - '• **Clear Overrides** re-enables individually disabled entries', + '• Über das Dropdown schaltest du einzelne Commands oder Subcommands um', + '• **Alle deaktivieren** schaltet die ganze Kategorie aus', + '• **Ausnahmen löschen** aktiviert einzeln deaktivierte Einträge wieder', ].join('\n'), }); return createEmbed({ title: `${category.icon} ${category.displayName}`, - description: `Command access for **${guild.name}**.`, + description: `Command-Zugriff für **${guild.name}**.`, color: category.categoryDisabled ? 'error' : category.disabledCount > 0 ? 'warning' : 'success', fields, - footer: '🔒 Protected entries cannot be disabled', + footer: '🔒 Geschützte Einträge können nicht deaktiviert werden', }); } @@ -200,7 +200,7 @@ export function buildOverviewComponents(guildId: string, snapshot: CommandAccess const status = getCategoryStatus(category); return new StringSelectMenuOptionBuilder() .setLabel(`${category.displayName}`.slice(0, 100)) - .setDescription(`${status} ${category.enabledCount}/${category.totalCount} enabled`.slice(0, 100)) + .setDescription(`${status} ${category.enabledCount}/${category.totalCount} aktiviert`.slice(0, 100)) .setValue(category.key) .setEmoji(category.icon); }); @@ -209,13 +209,13 @@ export function buildOverviewComponents(guildId: string, snapshot: CommandAccess new ActionRowBuilder().addComponents( new StringSelectMenuBuilder() .setCustomId(customId(DASHBOARD_CATEGORY_SELECT, guildId)) - .setPlaceholder('📁 Select a category...') + .setPlaceholder('📁 Kategorie auswählen...') .addOptions(categoryOptions), ), new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(customId(DASHBOARD_REFRESH, guildId)) - .setLabel('Refresh') + .setLabel('Aktualisieren') .setEmoji('🔄') .setStyle(ButtonStyle.Secondary), ), @@ -232,7 +232,7 @@ export function buildCategoryComponents(guildId: string, category: CommandAccess return new StringSelectMenuOptionBuilder() .setLabel(label) - .setDescription((enabled ? '🟢 Enabled — click to disable' : '🔴 Disabled — click to enable').slice(0, 100)) + .setDescription((enabled ? '🟢 Aktiviert — klicken zum Deaktivieren' : '🔴 Deaktiviert — klicken zum Aktivieren').slice(0, 100)) .setValue(command.name); }); @@ -240,27 +240,27 @@ export function buildCategoryComponents(guildId: string, category: CommandAccess new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(customId(DASHBOARD_HOME, guildId)) - .setLabel('Back') + .setLabel('Zurück') .setEmoji('◀️') .setStyle(ButtonStyle.Secondary), new ButtonBuilder() .setCustomId(customId(DASHBOARD_TOGGLE_CATEGORY, guildId, category.key)) - .setLabel(category.categoryDisabled ? 'Enable Category' : 'Disable Category') + .setLabel(category.categoryDisabled ? 'Kategorie aktivieren' : 'Kategorie deaktivieren') .setEmoji(category.categoryDisabled ? '🟢' : '🔴') .setStyle(category.categoryDisabled ? ButtonStyle.Success : ButtonStyle.Danger), new ButtonBuilder() .setCustomId(customId(DASHBOARD_ENABLE_ALL, guildId, category.key)) - .setLabel('Enable All') + .setLabel('Alle aktivieren') .setEmoji('✅') .setStyle(ButtonStyle.Success), new ButtonBuilder() .setCustomId(customId(DASHBOARD_DISABLE_ALL, guildId, category.key)) - .setLabel('Disable All') + .setLabel('Alle deaktivieren') .setEmoji('⛔') .setStyle(ButtonStyle.Danger), new ButtonBuilder() .setCustomId(customId(DASHBOARD_RESET_COMMANDS, guildId, category.key)) - .setLabel('Clear Overrides') + .setLabel('Ausnahmen löschen') .setEmoji('🧹') .setStyle(ButtonStyle.Secondary), ), @@ -271,7 +271,7 @@ export function buildCategoryComponents(guildId: string, category: CommandAccess new ActionRowBuilder().addComponents( new StringSelectMenuBuilder() .setCustomId(customId(DASHBOARD_COMMAND_SELECT, guildId, category.key)) - .setPlaceholder('Toggle a command or subcommand...') + .setPlaceholder('Command oder Subcommand umschalten...') .addOptions(commandOptions), ), ); @@ -314,7 +314,7 @@ export async function handleDashboardComponent(interaction: MessageComponentInte if (guildId !== interaction.guildId) { return interaction.reply({ - content: 'This dashboard belongs to another server.', + content: 'Dieses Dashboard gehört zu einem anderen Server.', ephemeral: true, }); } @@ -388,7 +388,7 @@ export async function handleDashboardComponent(interaction: MessageComponentInte return interaction.editReply({ embeds: [view.embed], components: view.components }); } - return interaction.editReply({ content: 'Unknown dashboard action.', embeds: [], components: [] }); + return interaction.editReply({ content: 'Unbekannte Dashboard-Aktion.', embeds: [], components: [] }); } export function isCommandAccessCustomId(customIdValue: string) { diff --git a/src/commands/Core/modules/ping.ts b/src/commands/Core/modules/ping.ts index d4caa20859..b352adc3b3 100644 --- a/src/commands/Core/modules/ping.ts +++ b/src/commands/Core/modules/ping.ts @@ -12,19 +12,19 @@ import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("ping") - .setDescription("Checks the bot's latency and API speed"), + .setDescription("Prüft die Latenz des Bots und die API-Geschwindigkeit"), async prefixExecute(interaction: PingInteraction) { try { const startTime = Date.now(); - const pingingMessage = await interaction.reply({ content: 'Pinging...' }); + const pingingMessage = await interaction.reply({ content: 'Pinge...' }); const latency = Date.now() - startTime; const apiLatency = Math.max(0, Math.round(interaction.client.ws.ping)); const embed = createEmbed({ title: 'Pong!', description: null }).addFields( - { name: 'Bot Latency', value: `${latency}ms`, inline: true }, - { name: 'API Latency', value: `${apiLatency}ms`, inline: true }, + { name: 'Bot-Latenz', value: `${latency}ms`, inline: true }, + { name: 'API-Latenz', value: `${apiLatency}ms`, inline: true }, ); await pingingMessage.edit({ content: null, embeds: [embed] }); @@ -32,7 +32,7 @@ export default { logger.error('Ping prefix command error:', error); if (!interaction.replied && !interaction._replyMessage) { await (interaction.channel as GuildTextBasedChannel).send({ - embeds: [createEmbed({ title: 'System Error', description: 'Could not determine latency at this time.', color: 'error' })], + embeds: [createEmbed({ title: 'Systemfehler', description: 'Die Latenz konnte gerade nicht ermittelt werden.', color: 'error' })], }).catch(() => {}); } } @@ -54,7 +54,7 @@ export default { try { await InteractionHelper.safeEditReply(interaction, { - content: "Pinging...", + content: "Pinge...", }); const startTime = interaction._commandStartTime || interaction.createdTimestamp; @@ -64,8 +64,8 @@ export default { logger.info(`execute - calculated latency: ${latency}ms, apiLatency: ${apiLatency}ms`); const embed = createEmbed({ title: "Pong!", description: null }).addFields( - { name: "Bot Latency", value: `${latency}ms`, inline: true }, - { name: "API Latency", value: `${apiLatency}ms`, inline: true }, + { name: "Bot-Latenz", value: `${latency}ms`, inline: true }, + { name: "API-Latenz", value: `${apiLatency}ms`, inline: true }, ); await InteractionHelper.safeEditReply(interaction, { @@ -76,7 +76,7 @@ export default { logger.error('Ping command error:', error); try { return await InteractionHelper.safeReply(interaction, { - embeds: [createEmbed({ title: 'System Error', description: 'Could not determine latency at this time.', color: 'error' })], + embeds: [createEmbed({ title: 'Systemfehler', description: 'Die Latenz konnte gerade nicht ermittelt werden.', color: 'error' })], flags: MessageFlags.Ephemeral, }); } catch (replyError) { diff --git a/src/commands/Core/modules/stats.ts b/src/commands/Core/modules/stats.ts index 37579cdb90..deadc102b8 100644 --- a/src/commands/Core/modules/stats.ts +++ b/src/commands/Core/modules/stats.ts @@ -7,7 +7,7 @@ import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("stats") - .setDescription("View bot statistics"), + .setDescription("Statistiken des Bots anzeigen"), async execute(interaction) { try { @@ -20,13 +20,13 @@ export default { ); const nodeVersion = process.version; - const embed = createEmbed({ title: "System Statistics", description: "Real-time performance metrics." }).addFields( + const embed = createEmbed({ title: "Systemstatistiken", description: "Leistungswerte in Echtzeit." }).addFields( { name: "Servers", value: `${totalGuilds}`, inline: true }, - { name: "Users", value: `${totalMembers}`, inline: true }, + { name: "Nutzer", value: `${totalMembers}`, inline: true }, { name: "Node.js", value: `${nodeVersion}`, inline: true }, { name: "Discord.js", value: `v${version}`, inline: true }, { - name: "Memory Usage", + name: "Speicherverbrauch", value: `${(process.memoryUsage().heapUsed / 1024 / 1024).toFixed(2)} MB`, inline: true, }, @@ -36,7 +36,7 @@ export default { } catch (error) { logger.error('Stats command error:', error); return InteractionHelper.safeEditReply(interaction, { - embeds: [createEmbed({ title: 'System Error', description: 'Could not fetch system statistics.', color: 'error' })], + embeds: [createEmbed({ title: 'Systemfehler', description: 'Systemstatistiken konnten nicht abgerufen werden.', color: 'error' })], flags: MessageFlags.Ephemeral, }); } diff --git a/src/commands/Core/modules/support.ts b/src/commands/Core/modules/support.ts index b599582c0f..ca8de89791 100644 --- a/src/commands/Core/modules/support.ts +++ b/src/commands/Core/modules/support.ts @@ -8,12 +8,12 @@ const SUPPORT_URL = "https://github.com/Robertwlt03/NexoBot/issues"; export default { data: new SlashCommandBuilder() .setName("support") - .setDescription("Get help and report issues on GitHub"), + .setDescription("Hilfe bekommen und Probleme auf GitHub melden"), async execute(interaction) { try { const supportButton = new ButtonBuilder() - .setLabel("Open GitHub Issues") + .setLabel("GitHub-Issues öffnen") .setStyle(ButtonStyle.Link) .setURL(SUPPORT_URL); @@ -21,7 +21,7 @@ export default { await InteractionHelper.safeReply(interaction, { embeds: [ - createEmbed({ title: "Need Help?", description: "Need help, found a bug or have a feature idea? Open an issue on GitHub." }), + createEmbed({ title: "Brauchst du Hilfe?", description: "Du brauchst Hilfe, hast einen Bug gefunden oder eine Idee für ein Feature? Eröffne ein Issue auf GitHub." }), ], components: [actionRow], flags: MessageFlags.Ephemeral, @@ -31,7 +31,7 @@ export default { try { return await InteractionHelper.safeReply(interaction, { - embeds: [createEmbed({ title: 'System Error', description: 'Could not display support information.', color: 'error' })], + embeds: [createEmbed({ title: 'Systemfehler', description: 'Support-Infos konnten nicht angezeigt werden.', color: 'error' })], flags: MessageFlags.Ephemeral, }); } catch (replyError) { diff --git a/src/commands/Core/modules/uptime.ts b/src/commands/Core/modules/uptime.ts index c884b200a9..0af5e3e007 100644 --- a/src/commands/Core/modules/uptime.ts +++ b/src/commands/Core/modules/uptime.ts @@ -7,7 +7,7 @@ import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("uptime") - .setDescription("Check how long the bot has been online"), + .setDescription("Zeigt, wie lange der Bot schon online ist"), async execute(interaction) { try { @@ -25,7 +25,7 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [createEmbed({ - title: "System Uptime", + title: "System-Uptime", description: `\`\`\`${uptimeStr}\`\`\`` })], }); @@ -34,7 +34,7 @@ export default { try { return await InteractionHelper.safeEditReply(interaction, { - embeds: [createEmbed({ title: 'System Error', description: 'Could not compute uptime.', color: 'error' })], + embeds: [createEmbed({ title: 'Systemfehler', description: 'Uptime konnte nicht berechnet werden.', color: 'error' })], flags: MessageFlags.Ephemeral, }); } catch (replyError) { diff --git a/src/commands/Economy/eco.ts b/src/commands/Economy/eco.ts index e88b2b842f..61ada2beb5 100644 --- a/src/commands/Economy/eco.ts +++ b/src/commands/Economy/eco.ts @@ -19,7 +19,7 @@ import workCommand from './modules/work.js'; export default createRouterCommand({ name: 'eco', - description: 'Economy: balance, work, shop and more', + description: 'Wirtschaft: Kontostand, Arbeiten, Shop und mehr', category: 'Economy', routes: [ { legacyName: 'balance', module: balanceCommand }, diff --git a/src/commands/Economy/economy.ts b/src/commands/Economy/economy.ts index 2c67984c86..f750112280 100644 --- a/src/commands/Economy/economy.ts +++ b/src/commands/Economy/economy.ts @@ -5,7 +5,7 @@ import shopConfigCommand from './modules/shop-config.js'; export default createRouterCommand({ name: 'economy', - description: 'Configure the economy system', + description: 'Wirtschaftssystem konfigurieren', category: 'Economy', defaultMemberPermissions: PermissionFlagsBits.ManageGuild, routes: [ diff --git a/src/commands/Economy/modules/balance.ts b/src/commands/Economy/modules/balance.ts index 622e24bd73..546fd85944 100644 --- a/src/commands/Economy/modules/balance.ts +++ b/src/commands/Economy/modules/balance.ts @@ -9,11 +9,11 @@ import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName('balance') - .setDescription("Check your or someone else's balance") + .setDescription("Zeige deinen Kontostand oder den eines anderen Nutzers") .addUserOption(option => option .setName('user') - .setDescription('User to check balance for') + .setDescription('Nutzer, dessen Kontostand du sehen möchtest') .setRequired(false) ), @@ -33,7 +33,7 @@ export default { throw createError( "Bot user queried for balance", ErrorTypes.VALIDATION, - "Bots don't have an economy balance." + "Bots haben keinen Kontostand." ); } @@ -45,7 +45,7 @@ export default { throw createError( "Failed to load economy data", ErrorTypes.DATABASE, - "Failed to load economy data. Please try again later.", + "Die Wirtschaftsdaten konnten nicht geladen werden. Bitte versuch es später erneut.", { userId: targetUser.id, guildId } ); } @@ -56,12 +56,12 @@ export default { const bank = typeof userData.bank === 'number' ? userData.bank : 0; const embed = createEmbed({ - title: `${targetUser.username}'s Balance`, - description: `Here is the current financial status for ${targetUser.username}.`, + title: `Kontostand von ${targetUser.username}`, + description: `Hier ist der aktuelle Kontostand von ${targetUser.username}.`, }) .addFields( { - name: "💵 Cash", + name: "💵 Geldbörse", value: `$${wallet.toLocaleString()}`, inline: true, }, @@ -71,13 +71,13 @@ export default { inline: true, }, { - name: "💰 Total", + name: "💰 Gesamt", value: `$${(wallet + bank).toLocaleString()}`, inline: true, } ) .setFooter({ - text: `Requested by ${interaction.user.tag}`, + text: `Angefragt von ${interaction.user.tag}`, iconURL: interaction.user.displayAvatarURL(), }); diff --git a/src/commands/Economy/modules/beg.ts b/src/commands/Economy/modules/beg.ts index 3dd8588b91..c25e64319f 100644 --- a/src/commands/Economy/modules/beg.ts +++ b/src/commands/Economy/modules/beg.ts @@ -15,7 +15,7 @@ const SUCCESS_CHANCE = 0.7; export default { data: new SlashCommandBuilder() .setName('beg') - .setDescription('Beg for a small amount of money'), + .setDescription('Bettle um ein bisschen Geld'), execute: withErrorHandling(async (interaction, config, client) => { const deferred = await InteractionHelper.safeDefer(interaction); @@ -30,7 +30,7 @@ export default { throw createError( "Failed to load economy data", ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", + "Deine Wirtschaftsdaten konnten nicht geladen werden. Bitte versuch es später erneut.", { userId, guildId } ); } @@ -43,12 +43,12 @@ export default { const seconds = Math.floor((remainingTime % 60000) / 1000); let timeMessage = - minutes > 0 ? `${minutes} minute(s)` : `${seconds} second(s)`; + minutes > 0 ? `${minutes} Minute(n)` : `${seconds} Sekunde(n)`; throw createError( "Beg cooldown active", ErrorTypes.RATE_LIMIT, - `You are tired from begging! Try again in **${timeMessage}**.`, + `Du bist müde vom Betteln! Versuch es in **${timeMessage}** erneut.`, { remainingTime, minutes, seconds, cooldownType: 'beg' } ); } @@ -65,28 +65,28 @@ export default { newCash += amountWon; const successMessages = [ - `A kind stranger drops **$${amountWon.toLocaleString()}** into your cup.`, - `You spotted an unattended wallet! You grab **$${amountWon.toLocaleString()}** and run.`, - `Someone took pity on you and gave you **$${amountWon.toLocaleString()}**!`, - `You found **$${amountWon.toLocaleString()}** under a park bench.`, + `Ein netter Fremder wirft **$${amountWon.toLocaleString()}** in deinen Becher.`, + `Du entdeckst eine herrenlose Geldbörse! Du schnappst dir **$${amountWon.toLocaleString()}** und rennst los.`, + `Jemand hatte Mitleid und hat dir **$${amountWon.toLocaleString()}** gegeben!`, + `Du hast **$${amountWon.toLocaleString()}** unter einer Parkbank gefunden.`, ]; replyEmbed = successEmbed( - 'Begging Successful', + 'Betteln erfolgreich', successMessages[ Math.floor(Math.random() * successMessages.length) ] ); } else { const failMessages = [ - "The police chased you off. You got nothing.", - "Someone yelled, 'Get a job!' and walked past.", - "A squirrel stole the single coin you had.", - "You tried to beg, but you were too embarrassed and gave up.", + "Die Polizei hat dich verjagt. Du hast nichts bekommen.", + "Jemand rief „Such dir einen Job!“ und ging weiter.", + "Ein Eichhörnchen hat deine einzige Münze geklaut.", + "Du wolltest betteln, aber es war dir zu peinlich und du hast aufgegeben.", ]; replyEmbed = warningEmbed( - 'Insufficient Funds', + 'Leider nichts bekommen', failMessages[Math.floor(Math.random() * failMessages.length)] ); } diff --git a/src/commands/Economy/modules/buy.ts b/src/commands/Economy/modules/buy.ts index 3c784391ca..c1cc332462 100644 --- a/src/commands/Economy/modules/buy.ts +++ b/src/commands/Economy/modules/buy.ts @@ -12,17 +12,17 @@ const SHOP_ITEMS = shopItems; export default { data: new SlashCommandBuilder() .setName('buy') - .setDescription('Buy an item from the shop') + .setDescription('Kauf ein Item im Shop') .addStringOption(option => option .setName('item_id') - .setDescription('ID of the item to buy') + .setDescription('ID des Items, das du kaufen möchtest') .setRequired(true) ) .addIntegerOption(option => option .setName('quantity') - .setDescription('Quantity to buy (default: 1)') + .setDescription('Anzahl (Standard: 1)') .setRequired(false) .setMinValue(1) .setMaxValue(10) @@ -43,7 +43,7 @@ export default { throw createError( `Item ${itemId} not found`, ErrorTypes.VALIDATION, - `The item ID \`${itemId}\` does not exist in the shop.`, + `Die Item-ID \`${itemId}\` gibt es im Shop nicht.`, { itemId } ); } @@ -52,7 +52,7 @@ export default { throw createError( "Invalid quantity", ErrorTypes.VALIDATION, - "You must purchase a quantity of 1 or more.", + "Du musst mindestens 1 Stück kaufen.", { quantity } ); } @@ -68,7 +68,7 @@ export default { throw createError( "Insufficient funds", ErrorTypes.VALIDATION, - `You need **$${totalCost.toLocaleString()}** to purchase ${quantity}x **${item.name}**, but you only have **$${userData.wallet.toLocaleString()}** in cash.`, + `Du brauchst **$${totalCost.toLocaleString()}** für ${quantity}x **${item.name}**, hast aber nur **$${userData.wallet.toLocaleString()}** in deiner Geldbörse.`, { required: totalCost, current: userData.wallet, itemId, quantity } ); } @@ -78,7 +78,7 @@ export default { throw createError( "Premium role not configured", ErrorTypes.CONFIGURATION, - "The **Premium Shop Role** has not been configured by a server administrator yet.", + "Die **Premium-Shop-Rolle** wurde noch nicht von einem Server-Admin eingerichtet.", { itemId } ); } @@ -86,7 +86,7 @@ export default { throw createError( "Role already owned", ErrorTypes.VALIDATION, - `You already have the **${item.name}** role.`, + `Du hast die Rolle **${item.name}** bereits.`, { itemId, roleId: PREMIUM_ROLE_ID } ); } @@ -94,7 +94,7 @@ export default { throw createError( "Invalid quantity for role", ErrorTypes.VALIDATION, - `You can only purchase the **${item.name}** role once.`, + `Du kannst die Rolle **${item.name}** nur einmal kaufen.`, { itemId, quantity } ); } @@ -102,7 +102,7 @@ export default { userData.wallet -= totalCost; - let successDescription = `You successfully purchased ${quantity}x **${item.name}** for **$${totalCost.toLocaleString()}**!`; + let successDescription = `Du hast ${quantity}x **${item.name}** für **$${totalCost.toLocaleString()}** gekauft!`; if (item.type === "role" && itemId === "premium_role") { const member = interaction.member as GuildMember; @@ -113,7 +113,7 @@ export default { throw createError( "Role not found", ErrorTypes.CONFIGURATION, - "The configured premium role no longer exists in this guild.", + "Die eingerichtete Premium-Rolle existiert auf diesem Server nicht mehr.", { roleId: PREMIUM_ROLE_ID } ); } @@ -121,37 +121,37 @@ export default { try { await member.roles.add( role, - `Purchased role: ${item.name}`, + `Rolle gekauft: ${item.name}`, ); - successDescription += `\n\n**👑 The role ${role.toString()} has been granted to you!**`; + successDescription += `\n\n**👑 Du hast die Rolle ${role.toString()} erhalten!**`; } catch (roleError) { userData.wallet += totalCost; await setEconomyData(client, guildId, userId, userData); throw createError( "Role assignment failed", ErrorTypes.DISCORD_API, - "Successfully deducted money, but failed to grant the role. Your cash has been refunded.", + "Das Geld wurde abgebucht, aber die Rolle konnte nicht vergeben werden. Du hast dein Geld zurückerhalten.", { roleId: PREMIUM_ROLE_ID, originalError: roleError instanceof Error ? roleError.message : String(roleError) } ); } } else if (item.type === "upgrade") { userData.upgrades![itemId] = true; - successDescription += `\n\n**✨ Your upgrade is now active!**`; + successDescription += `\n\n**✨ Dein Upgrade ist jetzt aktiv!**`; } 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!**`; + successDescription += `\n\n**🛠️ ${item.name} wurde deinem Inventar hinzugefügt!**`; } } await setEconomyData(client, guildId, userId, userData); const embed = successEmbed( - "💰 Purchase Successful", + "💰 Kauf erfolgreich", successDescription, ).addFields({ - name: "New Balance", + name: "Neuer Kontostand", value: `$${userData.wallet.toLocaleString()}`, inline: true, }); diff --git a/src/commands/Economy/modules/crime.ts b/src/commands/Economy/modules/crime.ts index 9102427f8a..91786ea101 100644 --- a/src/commands/Economy/modules/crime.ts +++ b/src/commands/Economy/modules/crime.ts @@ -11,28 +11,28 @@ const JAIL_TIME = 2 * 60 * 60 * 1000; const FINE_RATE = 0.2; const CRIME_TYPES = [ - { name: "Pickpocketing", min: 100, max: 500, risk: 0.3 }, - { name: "Burglary", min: 300, max: 1000, risk: 0.4 }, - { name: "Bank Heist", min: 1000, max: 5000, risk: 0.6 }, - { name: "Art Theft", min: 2000, max: 10000, risk: 0.7 }, - { name: "Cybercrime", min: 5000, max: 20000, risk: 0.8 }, + { name: "Pickpocketing", label: "Taschendiebstahl", min: 100, max: 500, risk: 0.3 }, + { name: "Burglary", label: "Einbruch", min: 300, max: 1000, risk: 0.4 }, + { name: "Bank Heist", label: "Banküberfall", min: 1000, max: 5000, risk: 0.6 }, + { name: "Art Theft", label: "Kunstraub", min: 2000, max: 10000, risk: 0.7 }, + { name: "Cybercrime", label: "Cyberkriminalität", min: 5000, max: 20000, risk: 0.8 }, ]; export default { data: new SlashCommandBuilder() .setName('crime') - .setDescription('Commit a crime to earn money (risky)') + .setDescription('Begehe ein Verbrechen, um Geld zu verdienen (riskant)') .addStringOption(option => option .setName('type') - .setDescription('Type of crime to commit') + .setDescription('Art des Verbrechens') .setRequired(true) .addChoices( - { name: 'Pickpocketing', value: 'pickpocketing' }, - { name: 'Burglary', value: 'burglary' }, - { name: 'Bank Heist', value: 'bank-heist' }, - { name: 'Art Theft', value: 'art-theft' }, - { name: 'Cybercrime', value: 'cybercrime' }, + { name: 'Taschendiebstahl', value: 'pickpocketing' }, + { name: 'Einbruch', value: 'burglary' }, + { name: 'Banküberfall', value: 'bank-heist' }, + { name: 'Kunstraub', value: 'art-theft' }, + { name: 'Cyberkriminalität', value: 'cybercrime' }, ) ), @@ -52,7 +52,7 @@ export default { throw createError( "User is in jail", ErrorTypes.RATE_LIMIT, - `You're in jail for ${timeLeft} more minutes!`, + `Du sitzt noch ${timeLeft} Minuten im Gefängnis!`, { jailTimeRemaining: userData.jailedUntil! - now } ); } @@ -62,7 +62,7 @@ export default { throw createError( "Crime cooldown active", ErrorTypes.RATE_LIMIT, - `You need to wait ${timeLeft} more minutes before committing another crime.`, + `Du musst noch ${timeLeft} Minuten warten, bevor du das nächste Verbrechen begehen kannst.`, { remaining: lastCrime + CRIME_COOLDOWN - now, cooldownType: 'crime' } ); } @@ -76,7 +76,7 @@ export default { throw createError( "Invalid crime type", ErrorTypes.VALIDATION, - "Please select a valid crime type.", + "Bitte wähle eine gültige Verbrechensart.", { crimeType } ); } @@ -95,8 +95,8 @@ export default { await setEconomyData(client, guildId, userId, userData); const embed = successEmbed( - "🕵️ Crime Successful!", - `You successfully committed ${crime.name} and earned **${amountEarned}** coins!` + "🕵️ Verbrechen erfolgreich!", + `Dein Coup „${crime.label}“ war erfolgreich und hat dir **${amountEarned}** Münzen eingebracht!` ); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); @@ -110,9 +110,9 @@ export default { await setEconomyData(client, guildId, userId, userData); const embed = warningEmbed( - "🚔 Crime Failed!", - `You were caught while attempting ${crime.name} and have been sent to jail! ` + - `You were fined ${fine.toLocaleString()} coins and will be in jail for 2 hours.` + "🚔 Verbrechen gescheitert!", + `Du wurdest bei „${crime.label}“ erwischt und ins Gefängnis gesteckt! ` + + `Du musst ${fine.toLocaleString()} Münzen Strafe zahlen und sitzt 2 Stunden im Gefängnis.` ); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); diff --git a/src/commands/Economy/modules/daily.ts b/src/commands/Economy/modules/daily.ts index 8a55270cf2..69df467e5d 100644 --- a/src/commands/Economy/modules/daily.ts +++ b/src/commands/Economy/modules/daily.ts @@ -16,7 +16,7 @@ const PREMIUM_BONUS_PERCENTAGE = 0.1; export default { data: new SlashCommandBuilder() .setName('daily') - .setDescription('Claim your daily cash reward'), + .setDescription('Hol dir deine tägliche Belohnung'), execute: withErrorHandling(async (interaction, config, client) => { const deferred = await InteractionHelper.safeDefer(interaction); @@ -34,7 +34,7 @@ export default { throw createError( "Failed to load economy data for daily", ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", + "Deine Wirtschaftsdaten konnten nicht geladen werden. Bitte versuch es später erneut.", { userId, guildId } ); } @@ -46,7 +46,7 @@ export default { throw createError( "Daily cooldown active", ErrorTypes.RATE_LIMIT, - `You need to wait before claiming daily again. Try again in **${formatDuration(timeRemaining)}**.`, + `Du musst noch warten, bevor du deine tägliche Belohnung wieder abholen kannst. Versuch es in **${formatDuration(timeRemaining)}** erneut.`, { timeRemaining, cooldownType: 'daily' } ); } @@ -67,7 +67,7 @@ export default { DAILY_AMOUNT * PREMIUM_BONUS_PERCENTAGE, ); earned += bonusAmount; - bonusMessage = `\n✨ **Premium Bonus:** +$${bonusAmount.toLocaleString()}`; + bonusMessage = `\n✨ **Premium-Bonus:** +$${bonusAmount.toLocaleString()}`; hasPremiumRole = true; } @@ -86,18 +86,18 @@ export default { }); const embed = successEmbed( - "✅ Daily Claimed!", - `You have claimed your daily **$${earned.toLocaleString()}**!${bonusMessage}` + "✅ Tägliche Belohnung abgeholt!", + `Du hast deine täglichen **$${earned.toLocaleString()}** abgeholt!${bonusMessage}` ) .addFields({ - name: "New Cash Balance", + name: "Geldbörse (neu)", value: `$${userData.wallet.toLocaleString()}`, inline: true, }) .setFooter({ text: hasPremiumRole - ? `Next claim in 24 hours. (Premium Active)` - : `Next claim in 24 hours.`, + ? `Nächste Abholung in 24 Stunden. (Premium aktiv)` + : `Nächste Abholung in 24 Stunden.`, }); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); diff --git a/src/commands/Economy/modules/deposit.ts b/src/commands/Economy/modules/deposit.ts index b0eef2e886..ad4e938f8d 100644 --- a/src/commands/Economy/modules/deposit.ts +++ b/src/commands/Economy/modules/deposit.ts @@ -8,11 +8,11 @@ import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName('deposit') - .setDescription('Deposit money from your wallet into your bank') + .setDescription('Zahle Geld aus deiner Geldbörse auf deine Bank ein') .addStringOption(option => option .setName('amount') - .setDescription('Amount to deposit (number or "all")') + .setDescription('Betrag zum Einzahlen (Zahl oder "all")') .setRequired(true) ), @@ -30,7 +30,7 @@ export default { throw createError( "Failed to load economy data", ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", + "Deine Wirtschaftsdaten konnten nicht geladen werden. Bitte versuch es später erneut.", { userId, guildId } ); } @@ -47,7 +47,7 @@ export default { throw createError( "Invalid deposit amount", ErrorTypes.VALIDATION, - `Please enter a valid number or 'all'. You entered: \`${amountInput}\``, + `Bitte gib eine gültige Zahl oder „all“ ein. Deine Eingabe: \`${amountInput}\``, { amountInput, userId } ); } @@ -57,7 +57,7 @@ export default { throw createError( "Zero deposit amount", ErrorTypes.VALIDATION, - "You have no cash to deposit.", + "Du hast kein Geld zum Einzahlen.", { userId, walletBalance: userData.wallet } ); } @@ -68,7 +68,7 @@ export default { embeds: [ buildUserErrorEmbed( 'validation', - `You tried to deposit more than you have. Depositing your remaining cash: **$${depositAmount.toLocaleString()}**` + `Du wolltest mehr einzahlen, als du hast. Dein restliches Geld wird eingezahlt: **$${depositAmount.toLocaleString()}**` ) ], flags: MessageFlags.Ephemeral, @@ -81,7 +81,7 @@ export default { throw createError( "Bank is full", ErrorTypes.VALIDATION, - `Your bank is currently full (Max Capacity: $${maxBank.toLocaleString()}). Purchase a **Bank Upgrade** to increase your limit.`, + `Deine Bank ist voll (max. Kapazität: $${maxBank.toLocaleString()}). Kauf ein **Bank-Upgrade**, um dein Limit zu erhöhen.`, { maxBank, currentBank: userData.bank, userId } ); } @@ -95,7 +95,7 @@ export default { embeds: [ buildUserErrorEmbed( 'validation', - `You only had space for **$${depositAmount.toLocaleString()}** in your bank account (Max: $${maxBank.toLocaleString()}). The rest remains in your cash.` + `Auf deinem Bankkonto war nur noch Platz für **$${depositAmount.toLocaleString()}** (max.: $${maxBank.toLocaleString()}). Der Rest bleibt in deiner Geldbörse.` ) ], flags: MessageFlags.Ephemeral, @@ -107,7 +107,7 @@ export default { throw createError( "No space or cash for deposit", ErrorTypes.VALIDATION, - "The amount you tried to deposit was either 0 or exceeded your bank capacity after checking your cash balance.", + "Der Betrag war entweder 0 oder hat nach Prüfung deiner Geldbörse die Kapazität deiner Bank überschritten.", { depositAmount, availableSpace, walletBalance: userData.wallet } ); } @@ -118,17 +118,17 @@ export default { await setEconomyData(client, guildId, userId, userData); const embed = successEmbed( - 'Deposit Successful', - `You successfully deposited **$${depositAmount.toLocaleString()}** into your bank.` + 'Einzahlung erfolgreich', + `Du hast **$${depositAmount.toLocaleString()}** auf deine Bank eingezahlt.` ) .addFields( { - name: "New Cash Balance", + name: "Geldbörse (neu)", value: `$${userData.wallet.toLocaleString()}`, inline: true, }, { - name: "New Bank Balance", + name: "Bank (neu)", value: `$${userData.bank.toLocaleString()} / $${maxBank.toLocaleString()}`, inline: true, }, diff --git a/src/commands/Economy/modules/economy.ts b/src/commands/Economy/modules/economy.ts index 88a8abee7c..dda7f8092d 100644 --- a/src/commands/Economy/modules/economy.ts +++ b/src/commands/Economy/modules/economy.ts @@ -8,13 +8,13 @@ export default { slashOnly: true, data: new SlashCommandBuilder() .setName('economy') - .setDescription('Economy management commands') + .setDescription('Commands zur Verwaltung der Wirtschaft') .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .setDMPermission(false) .addSubcommand(subcommand => subcommand .setName('dashboard') - .setDescription('Open the economy management dashboard') + .setDescription('Öffne das Wirtschafts-Dashboard') ), category: 'Economy', diff --git a/src/commands/Economy/modules/economy_dashboard.ts b/src/commands/Economy/modules/economy_dashboard.ts index a5cc96d92d..97c70dddd4 100644 --- a/src/commands/Economy/modules/economy_dashboard.ts +++ b/src/commands/Economy/modules/economy_dashboard.ts @@ -64,43 +64,43 @@ async function buildDashboardEmbed(guild: Guild, client: NexoClient) { const avgBalance = userCount > 0 ? Math.floor(totalInCirculation / userCount) : 0; return new EmbedBuilder() - .setTitle('💰 Economy Dashboard') - .setDescription(`Manage the economy system for **${guild.name}**.\nSelect an option below to perform an action.`) + .setTitle('💰 Wirtschafts-Dashboard') + .setDescription(`Verwalte das Wirtschaftssystem von **${guild.name}**.\nWähle unten eine Option aus.`) .setColor(getColor('economy')) .addFields( - { name: '💰 Total in Circulation', value: `\`${currencySymbol}${totalInCirculation.toLocaleString()}\``, inline: true }, - { name: '👥 Active Users', value: `\`${userCount.toLocaleString()}\``, inline: true }, - { name: '📊 Average Balance', value: `\`${currencySymbol}${avgBalance.toLocaleString()}\``, inline: true }, - { name: '💱 Currency Symbol', value: `\`${currencySymbol}\``, inline: true }, - { name: '📝 Currency Name', value: `\`${currencyName}\``, inline: true }, + { name: '💰 Gesamt im Umlauf', value: `\`${currencySymbol}${totalInCirculation.toLocaleString()}\``, inline: true }, + { name: '👥 Aktive Nutzer', value: `\`${userCount.toLocaleString()}\``, inline: true }, + { name: '📊 Durchschnittlicher Kontostand', value: `\`${currencySymbol}${avgBalance.toLocaleString()}\``, inline: true }, + { name: '💱 Währungssymbol', value: `\`${currencySymbol}\``, inline: true }, + { name: '📝 Währungsname', value: `\`${currencyName}\``, inline: true }, ) - .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) + .setFooter({ text: 'Das Dashboard schließt sich nach 10 Minuten Inaktivität' }) .setTimestamp(); } function buildSelectMenu(guildId: string) { return new StringSelectMenuBuilder() .setCustomId(`economy_dashboard_${guildId}`) - .setPlaceholder('Select an action...') + .setPlaceholder('Wähle eine Aktion...') .addOptions( new StringSelectMenuOptionBuilder() - .setLabel('Add Currency') - .setDescription('Add currency to a user\'s wallet or bank') + .setLabel('Währung hinzufügen') + .setDescription('Füge der Geldbörse oder Bank eines Nutzers Währung hinzu') .setValue('add_currency') .setEmoji('💰'), new StringSelectMenuOptionBuilder() - .setLabel('Remove Currency') - .setDescription('Remove currency from a user\'s wallet or bank') + .setLabel('Währung entfernen') + .setDescription('Entferne Währung aus der Geldbörse oder Bank eines Nutzers') .setValue('remove_currency') .setEmoji('💸'), new StringSelectMenuOptionBuilder() - .setLabel('Change Currency Symbol') - .setDescription('Change the currency symbol (e.g., $, €, £)') + .setLabel('Währungssymbol ändern') + .setDescription('Ändere das Währungssymbol (z. B. $, €, £)') .setValue('change_currency') .setEmoji('💱'), new StringSelectMenuOptionBuilder() - .setLabel('Change Currency Name') - .setDescription('Change the currency name (e.g., coins, credits)') + .setLabel('Währungsname ändern') + .setDescription('Ändere den Namen der Währung (z. B. Münzen, Credits)') .setValue('change_name') .setEmoji('📝'), ); @@ -193,8 +193,8 @@ export default { const errorMessage = error instanceof NexoBotError - ? error.userMessage || 'An error occurred while processing your selection.' - : 'An unexpected error occurred while processing your request.'; + ? error.userMessage || 'Beim Verarbeiten deiner Auswahl ist ein Fehler aufgetreten.' + : 'Beim Verarbeiten deiner Anfrage ist ein unerwarteter Fehler aufgetreten.'; if (!selectInteraction.replied && !selectInteraction.deferred) { await selectInteraction.deferUpdate().catch(() => {}); @@ -210,8 +210,8 @@ export default { collector.on('end', async (collected, reason) => { if (reason === 'time') { const timeoutEmbed = new EmbedBuilder() - .setTitle('Dashboard Timed Out') - .setDescription('This dashboard has been closed due to inactivity. Please run the command again to continue.') + .setTitle('Dashboard abgelaufen') + .setDescription('Dieses Dashboard wurde wegen Inaktivität geschlossen. Führe den Command erneut aus, um weiterzumachen.') .setColor(getColor('error')); await InteractionHelper.safeEditReply(interaction, { @@ -226,7 +226,7 @@ export default { throw new NexoBotError( `Economy dashboard failed: ${error instanceof Error ? error.message : String(error)}`, ErrorTypes.UNKNOWN, - 'Failed to open the economy dashboard.', + 'Das Wirtschafts-Dashboard konnte nicht geöffnet werden.', ); } }, @@ -235,23 +235,23 @@ export default { async function handleAddCurrency(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, guild: Guild, client: NexoClient) { const modal = new ModalBuilder() .setCustomId(`economy_add_currency_${guild.id}`) - .setTitle('Add Currency'); + .setTitle('Währung hinzufügen'); const userSelect = new UserSelectMenuBuilder() .setCustomId('target_user') - .setPlaceholder('Select a user...') + .setPlaceholder('Wähle einen Nutzer...') .setMinValues(1) .setMaxValues(1) .setRequired(true); const userLabel = new LabelBuilder() - .setLabel('Target User') - .setDescription('User to add currency to') + .setLabel('Ziel-Nutzer') + .setDescription('Nutzer, dem Währung hinzugefügt wird') .setUserSelectMenuComponent(userSelect); const amountInput = new TextInputBuilder() .setCustomId('amount') - .setLabel('Amount to add') + .setLabel('Betrag zum Hinzufügen') .setStyle(TextInputStyle.Short) .setPlaceholder('100') .setMinLength(1) @@ -260,7 +260,7 @@ async function handleAddCurrency(selectInteraction: StringSelectMenuInteraction, const typeInput = new TextInputBuilder() .setCustomId('type') - .setLabel('Type (wallet or bank)') + .setLabel('Typ (wallet = Geldbörse, bank = Bank)') .setStyle(TextInputStyle.Short) .setPlaceholder('wallet') .setMinLength(1) @@ -289,23 +289,23 @@ async function handleAddCurrency(selectInteraction: StringSelectMenuInteraction, const type = submitted.fields.getTextInputValue('type').trim().toLowerCase(); if (isNaN(amount) || amount <= 0) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Amount must be a positive number.' }); + await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Der Betrag muss eine positive Zahl sein.' }); return; } if (type !== 'wallet' && type !== 'bank') { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Type must be either "wallet" or "bank".' }); + await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Der Typ muss entweder „wallet“ oder „bank“ sein.' }); return; } const member = await guild.members.fetch(userId).catch(() => null); if (!member) { - await replyUserError(submitted, { type: ErrorTypes.USER_INPUT, message: 'The specified user is not in this server.' }); + await replyUserError(submitted, { type: ErrorTypes.USER_INPUT, message: 'Der angegebene Nutzer ist nicht auf diesem Server.' }); return; } if (member.user.bot) { - await replyUserError(submitted, { type: ErrorTypes.UNKNOWN, message: 'Bots do not have economy accounts.' }); + await replyUserError(submitted, { type: ErrorTypes.UNKNOWN, message: 'Bots haben kein Wirtschaftskonto.' }); return; } @@ -314,7 +314,7 @@ async function handleAddCurrency(selectInteraction: StringSelectMenuInteraction, 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}${newBalance.toLocaleString()}`)], + embeds: [successEmbed('Währung hinzugefügt', `${currencySymbol}${amount.toLocaleString()} wurden ${type === 'wallet' ? 'der Geldbörse' : 'der Bank'} von ${member.user.tag} hinzugefügt.\n**Neuer Kontostand:** ${currencySymbol}${newBalance.toLocaleString()}`)], flags: MessageFlags.Ephemeral, }); @@ -332,23 +332,23 @@ async function handleAddCurrency(selectInteraction: StringSelectMenuInteraction, async function handleRemoveCurrency(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, guild: Guild, client: NexoClient) { const modal = new ModalBuilder() .setCustomId(`economy_remove_currency_${guild.id}`) - .setTitle('Remove Currency'); + .setTitle('Währung entfernen'); const userSelect = new UserSelectMenuBuilder() .setCustomId('target_user') - .setPlaceholder('Select a user...') + .setPlaceholder('Wähle einen Nutzer...') .setMinValues(1) .setMaxValues(1) .setRequired(true); const userLabel = new LabelBuilder() - .setLabel('Target User') - .setDescription('User to remove currency from') + .setLabel('Ziel-Nutzer') + .setDescription('Nutzer, dem Währung entfernt wird') .setUserSelectMenuComponent(userSelect); const amountInput = new TextInputBuilder() .setCustomId('amount') - .setLabel('Amount to remove') + .setLabel('Betrag zum Entfernen') .setStyle(TextInputStyle.Short) .setPlaceholder('100') .setMinLength(1) @@ -357,7 +357,7 @@ async function handleRemoveCurrency(selectInteraction: StringSelectMenuInteracti const typeInput = new TextInputBuilder() .setCustomId('type') - .setLabel('Type (wallet or bank)') + .setLabel('Typ (wallet = Geldbörse, bank = Bank)') .setStyle(TextInputStyle.Short) .setPlaceholder('wallet') .setMinLength(1) @@ -386,23 +386,23 @@ async function handleRemoveCurrency(selectInteraction: StringSelectMenuInteracti const type = submitted.fields.getTextInputValue('type').trim().toLowerCase(); if (isNaN(amount) || amount <= 0) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Amount must be a positive number.' }); + await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Der Betrag muss eine positive Zahl sein.' }); return; } if (type !== 'wallet' && type !== 'bank') { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Type must be either "wallet" or "bank".' }); + await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Der Typ muss entweder „wallet“ oder „bank“ sein.' }); return; } const member = await guild.members.fetch(userId).catch(() => null); if (!member) { - await replyUserError(submitted, { type: ErrorTypes.USER_INPUT, message: 'The specified user is not in this server.' }); + await replyUserError(submitted, { type: ErrorTypes.USER_INPUT, message: 'Der angegebene Nutzer ist nicht auf diesem Server.' }); return; } if (member.user.bot) { - await replyUserError(submitted, { type: ErrorTypes.UNKNOWN, message: 'Bots do not have economy accounts.' }); + await replyUserError(submitted, { type: ErrorTypes.UNKNOWN, message: 'Bots haben kein Wirtschaftskonto.' }); return; } @@ -411,7 +411,7 @@ async function handleRemoveCurrency(selectInteraction: StringSelectMenuInteracti 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}${newBalance.toLocaleString()}`)], + embeds: [successEmbed('Währung entfernt', `${currencySymbol}${amount.toLocaleString()} wurden aus ${type === 'wallet' ? 'der Geldbörse' : 'der Bank'} von ${member.user.tag} entfernt.\n**Neuer Kontostand:** ${currencySymbol}${newBalance.toLocaleString()}`)], flags: MessageFlags.Ephemeral, }); @@ -429,11 +429,11 @@ async function handleRemoveCurrency(selectInteraction: StringSelectMenuInteracti async function handleChangeCurrency(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, guild: Guild) { const modal = new ModalBuilder() .setCustomId(`economy_change_currency_${guild.id}`) - .setTitle('Change Currency Symbol'); + .setTitle('Währungssymbol ändern'); const symbolInput = new TextInputBuilder() .setCustomId('currency_symbol') - .setLabel('New Currency Symbol') + .setLabel('Neues Währungssymbol') .setStyle(TextInputStyle.Short) .setValue(BotConfig.economy.currency.symbol) .setPlaceholder('$') @@ -457,19 +457,19 @@ async function handleChangeCurrency(selectInteraction: StringSelectMenuInteracti const newSymbol = submitted.fields.getTextInputValue('currency_symbol').trim(); if (newSymbol.length === 0 || newSymbol.length > 3) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Currency symbol must be 1-3 characters long.' }); + await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Das Währungssymbol muss 1–3 Zeichen lang sein.' }); return; } const success = await updateConfigFile(newSymbol, BotConfig.economy.currency.name); if (!success) { - await replyUserError(submitted, { type: ErrorTypes.UNKNOWN, message: 'Could not update the config file. Please check the logs.' }); + await replyUserError(submitted, { type: ErrorTypes.UNKNOWN, message: 'Die Konfigurationsdatei konnte nicht aktualisiert werden. Bitte prüf die Logs.' }); return; } await submitted.reply({ - embeds: [successEmbed('Currency Symbol Updated', `Currency symbol changed to **${newSymbol}**.\n\n**Note:** The bot needs to be restarted for changes to take effect.`)], + embeds: [successEmbed('Währungssymbol aktualisiert', `Das Währungssymbol ist jetzt **${newSymbol}**.\n\n**Hinweis:** Der Bot muss neu gestartet werden, damit die Änderung wirksam wird.`)], flags: MessageFlags.Ephemeral, }); @@ -483,14 +483,14 @@ async function handleChangeCurrency(selectInteraction: StringSelectMenuInteracti async function handleChangeName(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, guild: Guild) { const modal = new ModalBuilder() .setCustomId(`economy_change_name_${guild.id}`) - .setTitle('Change Currency Name'); + .setTitle('Währungsname ändern'); const nameInput = new TextInputBuilder() .setCustomId('currency_name') - .setLabel('New Currency Name') + .setLabel('Neuer Währungsname') .setStyle(TextInputStyle.Short) .setValue(BotConfig.economy.currency.name) - .setPlaceholder('coins') + .setPlaceholder('Münzen') .setMinLength(1) .setMaxLength(20) .setRequired(true); @@ -511,19 +511,19 @@ async function handleChangeName(selectInteraction: StringSelectMenuInteraction, const newName = submitted.fields.getTextInputValue('currency_name').trim(); if (newName.length === 0 || newName.length > 20) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Currency name must be 1-20 characters long.' }); + await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Der Währungsname muss 1–20 Zeichen lang sein.' }); return; } const success = await updateConfigFile(BotConfig.economy.currency.symbol, newName); if (!success) { - await replyUserError(submitted, { type: ErrorTypes.UNKNOWN, message: 'Could not update the config file. Please check the logs.' }); + await replyUserError(submitted, { type: ErrorTypes.UNKNOWN, message: 'Die Konfigurationsdatei konnte nicht aktualisiert werden. Bitte prüf die Logs.' }); return; } await submitted.reply({ - embeds: [successEmbed('Currency Name Updated', `Currency name changed to **${newName}**.\n\n**Note:** The bot needs to be restarted for changes to take effect.`)], + embeds: [successEmbed('Währungsname aktualisiert', `Der Währungsname ist jetzt **${newName}**.\n\n**Hinweis:** Der Bot muss neu gestartet werden, damit die Änderung wirksam wird.`)], flags: MessageFlags.Ephemeral, }); diff --git a/src/commands/Economy/modules/eleaderboard.ts b/src/commands/Economy/modules/eleaderboard.ts index aaebc34805..5ea31dbb65 100644 --- a/src/commands/Economy/modules/eleaderboard.ts +++ b/src/commands/Economy/modules/eleaderboard.ts @@ -10,7 +10,7 @@ import type { EconomyData } from '../../../utils/economy.js'; export default { data: new SlashCommandBuilder() .setName("eleaderboard") - .setDescription("View the server's top 10 richest users.") + .setDescription("Zeige die 10 reichsten Nutzer des Servers.") .setDMPermission(false), execute: withErrorHandling(async (interaction, config, client) => { @@ -33,7 +33,7 @@ export default { throw createError( "No economy data found", ErrorTypes.VALIDATION, - "No economy data found for this server." + "Für diesen Server wurden keine Wirtschaftsdaten gefunden." ); } @@ -78,12 +78,12 @@ export default { const description = leaderboardEntries.length > 0 ? leaderboardEntries.join("\n") - : "No economy data is available for this server yet."; + : "Für diesen Server gibt es noch keine Wirtschaftsdaten."; const embed = createEmbed({ - title: `Economy Leaderboard`, + title: `Wirtschafts-Bestenliste`, description, - footer: `Your Rank: ${userRank > 0 ?`#${userRank}`: "No ranking data available"}`, + footer: `Dein Rang: ${userRank > 0 ?`#${userRank}`: "Keine Rangdaten verfügbar"}`, }); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); diff --git a/src/commands/Economy/modules/fish.ts b/src/commands/Economy/modules/fish.ts index 320ad6b960..02056a2af3 100644 --- a/src/commands/Economy/modules/fish.ts +++ b/src/commands/Economy/modules/fish.ts @@ -12,29 +12,37 @@ const BASE_MAX_REWARD = 900; const FISHING_ROD_MULTIPLIER = 1.5; const FISH_TYPES = [ - { name: 'Bass', emoji: '🐟', rarity: 'common' }, - { name: 'Salmon', emoji: '🐟', rarity: 'common' }, - { name: 'Trout', emoji: '🐟', rarity: 'common' }, - { name: 'Tuna', emoji: '🐠', rarity: 'uncommon' }, - { name: 'Swordfish', emoji: '🐠', rarity: 'uncommon' }, - { name: 'Octopus', emoji: '🐙', rarity: 'rare' }, - { name: 'Lobster', emoji: '🦞', rarity: 'rare' }, - { name: 'Shark', emoji: '🦈', rarity: 'epic' }, - { name: 'Whale', emoji: '🐋', rarity: 'legendary' }, + { name: 'Barsch', emoji: '🐟', rarity: 'common' }, + { name: 'Lachs', emoji: '🐟', rarity: 'common' }, + { name: 'Forelle', emoji: '🐟', rarity: 'common' }, + { name: 'Thunfisch', emoji: '🐠', rarity: 'uncommon' }, + { name: 'Schwertfisch', emoji: '🐠', rarity: 'uncommon' }, + { name: 'Oktopus', emoji: '🐙', rarity: 'rare' }, + { name: 'Hummer', emoji: '🦞', rarity: 'rare' }, + { name: 'Hai', emoji: '🦈', rarity: 'epic' }, + { name: 'Wal', emoji: '🐋', rarity: 'legendary' }, ]; +const RARITY_LABELS: Record = { + common: 'Gewöhnlich', + uncommon: 'Ungewöhnlich', + rare: 'Selten', + epic: 'Episch', + legendary: 'Legendär', +}; + const CATCH_MESSAGES = [ - "You cast your line into the crystal clear waters...", - "You wait patiently as your bobber floats...", - "After a few minutes of waiting, you feel a tug...", - "The water ripples as something takes your bait...", - "You reel in your catch with expert precision...", + "Du wirfst deine Angel ins kristallklare Wasser...", + "Du wartest geduldig, während dein Schwimmer treibt...", + "Nach ein paar Minuten spürst du einen Ruck...", + "Das Wasser kräuselt sich, als etwas anbeißt...", + "Du holst deinen Fang mit Profi-Präzision ein...", ]; export default { data: new SlashCommandBuilder() .setName('fish') - .setDescription('Go fishing to catch fish and earn money'), + .setDescription('Geh angeln, fang Fische und verdiene Geld'), execute: withErrorHandling(async (interaction, config, client) => { const deferred = await InteractionHelper.safeDefer(interaction); @@ -58,7 +66,7 @@ export default { throw createError( "Fishing cooldown active", ErrorTypes.RATE_LIMIT, - `You're too tired to fish right now. Rest for **${hours}h ${minutes}m** before fishing again.`, + `Du bist gerade zu müde zum Angeln. Ruh dich **${hours}h ${minutes}m** aus, bevor du wieder angelst.`, { remaining, cooldownType: 'fish' } ); } @@ -92,7 +100,7 @@ export default { if (hasFishingRod > 0) { finalEarned = Math.floor(baseEarned * FISHING_ROD_MULTIPLIER); - multiplierMessage = `\n🎣 **Fishing Rod Bonus: +50%**`; + multiplierMessage = `\n🎣 **Angel-Bonus: +50 %**`; } const catchMessage = CATCH_MESSAGES[Math.floor(Math.random() * CATCH_MESSAGES.length)]; @@ -111,23 +119,23 @@ export default { }; const embed = createEmbed({ - title: 'Fishing Success!', - description: `${catchMessage}\n\nYou caught a **${fishCaught.emoji} ${fishCaught.name}**! You sold it for **$${finalEarned.toLocaleString()}**!${multiplierMessage}`, + title: 'Erfolgreich geangelt!', + description: `${catchMessage}\n\nDein Fang: **${fishCaught.emoji} ${fishCaught.name}**! Du hast ihn für **$${finalEarned.toLocaleString()}** verkauft!${multiplierMessage}`, color: rarityColors[fishCaught.rarity] }) .addFields( { - name: "New Cash Balance", + name: "Geldbörse (neu)", value: `$${userData.wallet.toLocaleString()}`, inline: true, }, { - name: "Rarity", - value: fishCaught.rarity.charAt(0).toUpperCase() + fishCaught.rarity.slice(1), + name: "Seltenheit", + value: RARITY_LABELS[fishCaught.rarity] ?? fishCaught.rarity, inline: true, } ) - .setFooter({ text: `Next fishing trip available in 45 minutes.` }); + .setFooter({ text: `Nächster Angelausflug in 45 Minuten möglich.` }); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'fish' }) diff --git a/src/commands/Economy/modules/gamble.ts b/src/commands/Economy/modules/gamble.ts index c19523a356..22c89c9b4c 100644 --- a/src/commands/Economy/modules/gamble.ts +++ b/src/commands/Economy/modules/gamble.ts @@ -15,11 +15,11 @@ const GAMBLE_COOLDOWN = 5 * 60 * 1000; export default { data: new SlashCommandBuilder() .setName('gamble') - .setDescription('Gamble your money for a chance to win more') + .setDescription('Setze dein Geld ein und gewinne mit etwas Glück mehr') .addIntegerOption(option => option .setName('amount') - .setDescription('Amount of cash to gamble') + .setDescription('Betrag, den du setzen möchtest') .setRequired(true) .setMinValue(1) ), @@ -46,7 +46,7 @@ export default { throw createError( "Gamble cooldown active", ErrorTypes.RATE_LIMIT, - `You need to cool down before gambling again. Wait **${minutes}m ${seconds}s**.`, + `Mach mal Pause, bevor du wieder spielst. Warte **${minutes}m ${seconds}s**.`, { remaining, cooldownType: 'gamble' } ); } @@ -55,7 +55,7 @@ export default { throw createError( "Insufficient cash for gamble", ErrorTypes.VALIDATION, - `You only have $${userData.wallet.toLocaleString()} cash, but you are trying to bet $${betAmount.toLocaleString()}.`, + `Du hast nur $${userData.wallet.toLocaleString()} in deiner Geldbörse, willst aber $${betAmount.toLocaleString()} setzen.`, { required: betAmount, current: userData.wallet } ); } @@ -68,14 +68,14 @@ export default { if (cloverCount > 0) { winChance += CLOVER_WIN_BONUS; userData.inventory["lucky_clover"] -= 1; - cloverMessage = `\n🍀 **Lucky Clover Consumed:** Your win chance was boosted!`; + cloverMessage = `\n🍀 **Glücksklee verbraucht:** Deine Gewinnchance wurde erhöht!`; usedClover = true; } else if (charmCount > 0) { winChance += CHARM_WIN_BONUS; userData.inventory["lucky_charm"] -= 1; - cloverMessage = `\n🍀 **Lucky Charm Used (${charmCount - 1} uses remaining):** Your win chance was boosted!`; + cloverMessage = `\n🍀 **Glücksbringer benutzt (noch ${charmCount - 1} Nutzungen):** Deine Gewinnchance wurde erhöht!`; usedCharm = true; } @@ -89,15 +89,15 @@ export default { cashChange = amountWon - betAmount; resultEmbed = successEmbed( - "🎉 You Won!", - `You successfully gambled and turned your **$${betAmount.toLocaleString()}** bet into **$${amountWon.toLocaleString()}**!${cloverMessage}`, + "🎉 Gewonnen!", + `Du hast aus deinem Einsatz von **$${betAmount.toLocaleString()}** ganze **$${amountWon.toLocaleString()}** gemacht!${cloverMessage}`, ); } else { cashChange = -betAmount; resultEmbed = warningEmbed( - "💔 You Lost...", - `The dice rolled against you. You lost your **$${betAmount.toLocaleString()}** bet.`, + "💔 Verloren...", + `Das Glück war nicht auf deiner Seite. Dein Einsatz von **$${betAmount.toLocaleString()}** ist weg.`, ); } @@ -109,22 +109,22 @@ userData.lastGamble = now; const newCash = userData.wallet; resultEmbed.addFields({ - name: "New Cash Balance", + name: "Geldbörse (neu)", value: `$${newCash.toLocaleString()}`, inline: true, }); if (usedClover) { resultEmbed.setFooter({ - text: `You have ${userData.inventory["lucky_clover"]} Lucky Clovers left. Win chance was ${Math.round(winChance * 100)}%.`, + text: `Du hast noch ${userData.inventory["lucky_clover"]} Glücksklee. Gewinnchance war ${Math.round(winChance * 100)} %.`, }); } else if (usedCharm) { resultEmbed.setFooter({ - text: `You have ${userData.inventory["lucky_charm"]} Lucky Charm uses left. Win chance was ${Math.round(winChance * 100)}%.`, + text: `Du hast noch ${userData.inventory["lucky_charm"]} Glücksbringer-Nutzungen. Gewinnchance war ${Math.round(winChance * 100)} %.`, }); } else { resultEmbed.setFooter({ - text: `Next gamble available in 5 minutes. Base win chance: ${Math.round(BASE_WIN_CHANCE * 100)}%.`, + text: `Nächstes Spiel in 5 Minuten möglich. Basis-Gewinnchance: ${Math.round(BASE_WIN_CHANCE * 100)} %.`, }); } diff --git a/src/commands/Economy/modules/inventory.ts b/src/commands/Economy/modules/inventory.ts index e755c2fee5..fe2b0ab169 100644 --- a/src/commands/Economy/modules/inventory.ts +++ b/src/commands/Economy/modules/inventory.ts @@ -12,7 +12,7 @@ const SHOP_ITEMS = shopItems; export default { data: new SlashCommandBuilder() .setName('inventory') - .setDescription('View your economy inventory'), + .setDescription('Zeige dein Inventar an'), execute: withErrorHandling(async (interaction, config, client) => { const deferred = await InteractionHelper.safeDefer(interaction); @@ -29,14 +29,14 @@ export default { throw createError( "Failed to load economy data for inventory", ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", + "Deine Wirtschaftsdaten konnten nicht geladen werden. Bitte versuch es später erneut.", { userId, guildId } ); } const inventory = userData.inventory || {}; - let inventoryDescription = "Your inventory is currently empty."; + let inventoryDescription = "Dein Inventar ist gerade leer."; if (Object.keys(inventory).length > 0) { inventoryDescription = Object.entries(inventory) @@ -62,7 +62,7 @@ export default { }); const embed = createEmbed({ - title: `🎒 ${interaction.user.username}'s Inventory`, + title: `🎒 Inventar von ${interaction.user.username}`, description: inventoryDescription, }).setThumbnail(interaction.user.displayAvatarURL()); diff --git a/src/commands/Economy/modules/mine.ts b/src/commands/Economy/modules/mine.ts index 372bf7067e..6d3635feef 100644 --- a/src/commands/Economy/modules/mine.ts +++ b/src/commands/Economy/modules/mine.ts @@ -13,17 +13,17 @@ const PICKAXE_MULTIPLIER = 1.2; const DIAMOND_PICKAXE_MULTIPLIER = 2.0; const MINE_LOCATIONS = [ - "abandoned gold mine", - "dark, damp cave", - "backyard rock quarry", - "volcanic obsidian vent", - "deep-sea mineral trench", + "eine verlassene Goldmine", + "eine dunkle, feuchte Höhle", + "einen Steinbruch im Hinterhof", + "einen vulkanischen Obsidianschlot", + "einen Tiefsee-Mineraliengraben", ]; export default { data: new SlashCommandBuilder() .setName('mine') - .setDescription('Go mining to earn money'), + .setDescription('Geh in die Mine und verdiene Geld'), execute: withErrorHandling(async (interaction, config, client) => { const deferred = await InteractionHelper.safeDefer(interaction); @@ -48,7 +48,7 @@ export default { throw createError( "Mining cooldown active", ErrorTypes.RATE_LIMIT, - `Your pickaxe is cooling down. Wait for **${hours}h ${minutes}m** before mining again.`, + `Deine Spitzhacke muss sich erholen. Warte **${hours}h ${minutes}m**, bevor du wieder schürfst.`, { remaining, cooldownType: 'mine' } ); } @@ -63,10 +63,10 @@ export default { if (hasDiamondPickaxe > 0) { finalEarned = Math.floor(baseEarned * DIAMOND_PICKAXE_MULTIPLIER); - multiplierMessage = `\n💎 **Diamond Pickaxe Bonus: +100%**`; + multiplierMessage = `\n💎 **Diamant-Spitzhacken-Bonus: +100 %**`; } else if (hasPickaxe > 0) { finalEarned = Math.floor(baseEarned * PICKAXE_MULTIPLIER); - multiplierMessage = `\n⛏️ **Pickaxe Bonus: +20%**`; + multiplierMessage = `\n⛏️ **Spitzhacken-Bonus: +20 %**`; } const location = @@ -80,15 +80,15 @@ userData.lastMine = now; await setEconomyData(client, guildId, userId, userData); const embed = successEmbed( - "💰 Mining Expedition Successful!", - `You explored a **${location}** and managed to find minerals worth **$${finalEarned.toLocaleString()}**!${multiplierMessage}`, + "💰 Bergbau-Expedition erfolgreich!", + `Du hast **${location}** erkundet und Mineralien im Wert von **$${finalEarned.toLocaleString()}** gefunden!${multiplierMessage}`, ) .addFields({ - name: "New Cash Balance", + name: "Geldbörse (neu)", value: `$${userData.wallet.toLocaleString()}`, inline: true, }) - .setFooter({ text: `Next mine available in 1 hour.` }); + .setFooter({ text: `Nächstes Schürfen in 1 Stunde möglich.` }); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'mine' }) diff --git a/src/commands/Economy/modules/pay.ts b/src/commands/Economy/modules/pay.ts index 1027c6adbf..8e83d84271 100644 --- a/src/commands/Economy/modules/pay.ts +++ b/src/commands/Economy/modules/pay.ts @@ -10,17 +10,17 @@ import EconomyService from '../../../services/economyService.js'; export default { data: new SlashCommandBuilder() .setName('pay') - .setDescription('Pay another user some of your cash') + .setDescription('Gib einem anderen Nutzer etwas von deinem Geld') .addUserOption(option => option .setName('user') - .setDescription('User to pay') + .setDescription('Nutzer, den du bezahlen möchtest') .setRequired(true) ) .addIntegerOption(option => option .setName('amount') - .setDescription('Amount to pay') + .setDescription('Betrag, den du zahlen möchtest') .setRequired(true) .setMinValue(1) ), @@ -45,7 +45,7 @@ export default { throw createError( "Cannot pay bot", ErrorTypes.VALIDATION, - "You cannot pay a bot.", + "Du kannst keinen Bot bezahlen.", { receiverId: receiver.id, isBot: true } ); } @@ -54,7 +54,7 @@ export default { throw createError( "Cannot pay self", ErrorTypes.VALIDATION, - "You cannot pay yourself.", + "Du kannst dich nicht selbst bezahlen.", { senderId, receiverId: receiver.id } ); } @@ -63,7 +63,7 @@ export default { throw createError( "Invalid payment amount", ErrorTypes.VALIDATION, - "Amount must be greater than zero.", + "Der Betrag muss größer als null sein.", { amount, senderId } ); } @@ -77,7 +77,7 @@ export default { throw createError( "Failed to load sender economy data", ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", + "Deine Wirtschaftsdaten konnten nicht geladen werden. Bitte versuch es später erneut.", { userId: senderId, guildId } ); } @@ -86,7 +86,7 @@ export default { throw createError( "Failed to load receiver economy data", ErrorTypes.DATABASE, - "Failed to load the receiver's economy data. Please try again later.", + "Die Wirtschaftsdaten des Empfängers konnten nicht geladen werden. Bitte versuch es später erneut.", { userId: receiver.id, guildId } ); } @@ -103,23 +103,23 @@ export default { const updatedReceiverData = await getEconomyData(client, guildId, receiver.id); const embed = successEmbed( - 'Payment Successful', - `You successfully paid **${receiver.username}** the amount of **$${amount.toLocaleString()}**!` + 'Zahlung erfolgreich', + `Du hast **${receiver.username}** erfolgreich **$${amount.toLocaleString()}** gezahlt!` ) .addFields( { - name: "Payment Amount", + name: "Betrag", value: `$${amount.toLocaleString()}`, inline: true, }, { - name: "Your New Balance", + name: "Dein neuer Kontostand", value: `$${updatedSenderData.wallet.toLocaleString()}`, inline: true, }, ) .setFooter({ - text: `Paid to ${receiver.tag}`, + text: `Gezahlt an ${receiver.tag}`, iconURL: receiver.displayAvatarURL(), }); @@ -135,10 +135,10 @@ export default { try { const receiverEmbed = createEmbed({ - title: "Incoming Payment!", - description: `${interaction.user.username} paid you **$${amount.toLocaleString()}**.` + title: "Zahlung erhalten!", + description: `${interaction.user.username} hat dir **$${amount.toLocaleString()}** gezahlt.` }).addFields({ - name: "Your New Cash", + name: "Deine Geldbörse (neu)", value: `$${updatedReceiverData.wallet.toLocaleString()}`, inline: true, }); diff --git a/src/commands/Economy/modules/rob.ts b/src/commands/Economy/modules/rob.ts index d05983cdc9..a8d8b07aa9 100644 --- a/src/commands/Economy/modules/rob.ts +++ b/src/commands/Economy/modules/rob.ts @@ -14,11 +14,11 @@ const FINE_PERCENTAGE = 0.1; export default { data: new SlashCommandBuilder() .setName('rob') - .setDescription('Attempt to rob another user (very risky)') + .setDescription('Versuche, einen anderen Nutzer auszurauben (sehr riskant)') .addUserOption(option => option .setName('user') - .setDescription('User to rob') + .setDescription('Nutzer, den du ausrauben willst') .setRequired(true) ), @@ -35,7 +35,7 @@ export default { throw createError( "Cannot rob self", ErrorTypes.VALIDATION, - "You cannot rob yourself.", + "Du kannst dich nicht selbst ausrauben.", { robberId, victimId: victimUser.id } ); } @@ -44,7 +44,7 @@ export default { throw createError( "Cannot rob bot", ErrorTypes.VALIDATION, - "You cannot rob a bot.", + "Du kannst keinen Bot ausrauben.", { victimId: victimUser.id, isBot: true } ); } @@ -56,7 +56,7 @@ export default { throw createError( "Failed to load economy data", ErrorTypes.DATABASE, - "Failed to load economy data. Please try again later.", + "Die Wirtschaftsdaten konnten nicht geladen werden. Bitte versuch es später erneut.", { robberId: !!robberData, victimId: !!victimData, guildId } ); } @@ -71,7 +71,7 @@ export default { throw createError( "Robbery cooldown active", ErrorTypes.RATE_LIMIT, - `You need to lay low. Wait **${hours}h ${minutes}m** before attempting another robbery.`, + `Du musst untertauchen. Warte **${hours}h ${minutes}m**, bevor du den nächsten Raub versuchst.`, { remaining, hours, minutes, cooldownType: 'rob' } ); } @@ -80,7 +80,7 @@ export default { throw createError( "Victim too poor", ErrorTypes.VALIDATION, - `${victimUser.username} is too poor. They need at least $500 cash to be worth robbing.`, + `${victimUser.username} ist zu arm. Es lohnt sich erst ab mindestens $500 in der Geldbörse.`, { victimWallet: victimData.wallet, required: 500 } ); } @@ -94,8 +94,8 @@ export default { return await InteractionHelper.safeEditReply(interaction, { embeds: [ warningEmbed( - 'Robbery Blocked', - `${victimUser.username} was prepared! Your attempt failed because they own a **Personal Safe**. You got away clean but didn't gain anything.` + 'Raub verhindert', + `${victimUser.username} war vorbereitet! Dein Versuch ist an einem **persönlichen Safe** gescheitert. Du bist unerkannt entkommen, hast aber nichts erbeutet.` ) ], }); @@ -111,8 +111,8 @@ export default { victimData.wallet = (victimData.wallet || 0) - amountStolen; resultEmbed = successEmbed( - 'Robbery Successful', - `You successfully stole **$${amountStolen.toLocaleString()}** from ${victimUser.username}!` + 'Raub erfolgreich', + `Du hast ${victimUser.username} erfolgreich **$${amountStolen.toLocaleString()}** abgenommen!` ); } else { const fineAmount = Math.floor((robberData.wallet || 0) * FINE_PERCENTAGE); @@ -125,8 +125,8 @@ export default { resultEmbed = buildUserErrorEmbed( 'unknown', - `You failed the robbery and were caught! You were fined **$${fineAmount.toLocaleString()}** of your own cash.`, - { titleOverride: 'Robbery Failed' } + `Der Raub ist gescheitert und du wurdest erwischt! Du musst **$${fineAmount.toLocaleString()}** aus deiner eigenen Geldbörse Strafe zahlen.`, + { titleOverride: 'Raub gescheitert' } ); } @@ -138,17 +138,17 @@ export default { resultEmbed .addFields( { - name: `Your New Cash (${interaction.user.username})`, + name: `Deine Geldbörse (${interaction.user.username})`, value: `$${robberData.wallet.toLocaleString()}`, inline: true, }, { - name: `Victim's New Cash (${victimUser.username})`, + name: `Geldbörse des Opfers (${victimUser.username})`, value: `$${victimData.wallet.toLocaleString()}`, inline: true, }, ) - .setFooter({ text: `Next robbery available in ${Math.ceil(ROB_COOLDOWN / (60 * 60 * 1000))} hours.` }); + .setFooter({ text: `Nächster Raub in ${Math.ceil(ROB_COOLDOWN / (60 * 60 * 1000))} Stunden möglich.` }); await InteractionHelper.safeEditReply(interaction, { embeds: [resultEmbed] }); }, { command: 'rob' }) diff --git a/src/commands/Economy/modules/shop-config.ts b/src/commands/Economy/modules/shop-config.ts index c2f06a4995..00598fb770 100644 --- a/src/commands/Economy/modules/shop-config.ts +++ b/src/commands/Economy/modules/shop-config.ts @@ -6,15 +6,15 @@ export default { slashOnly: true, data: new SlashCommandBuilder() .setName('shop-config') - .setDescription('Configure shop settings. (Manage Server required)') + .setDescription('Shop-Einstellungen konfigurieren. („Server verwalten“ erforderlich)') .addSubcommand(subcommand => subcommand .setName('setrole') - .setDescription('Set the Discord role granted when the Premium Role shop item is purchased.') + .setDescription('Lege die Rolle fest, die beim Kauf des Items „Premium-Rolle“ vergeben wird.') .addRoleOption(option => option .setName('role') - .setDescription('The role to grant for Premium Role purchases.') + .setDescription('Die Rolle, die beim Kauf der Premium-Rolle vergeben wird.') .setRequired(true), ), ), diff --git a/src/commands/Economy/modules/shop.ts b/src/commands/Economy/modules/shop.ts index ba5311cde9..6b22426581 100644 --- a/src/commands/Economy/modules/shop.ts +++ b/src/commands/Economy/modules/shop.ts @@ -6,7 +6,7 @@ export default { slashOnly: true, data: new SlashCommandBuilder() .setName('shop') - .setDescription('Browse the economy shop.'), + .setDescription('Durchstöbere den Shop.'), async execute(interaction, config, client) { return shopBrowse.execute(interaction, config, client); diff --git a/src/commands/Economy/modules/shop_browse.ts b/src/commands/Economy/modules/shop_browse.ts index 0c350891ef..cb3349af65 100644 --- a/src/commands/Economy/modules/shop_browse.ts +++ b/src/commands/Economy/modules/shop_browse.ts @@ -17,17 +17,17 @@ export default { const startIndex = (page - 1) * ITEMS_PER_PAGE; const pageItems = shopItems.slice(startIndex, startIndex + ITEMS_PER_PAGE); const embed = new EmbedBuilder() - .setTitle('Store') + .setTitle('Shop') .setColor(getColor('primary')) - .setDescription('Use `/eco buy item_id: quantity:` to purchase an item.'); + .setDescription('Nutze `/eco buy item_id: quantity:`, um ein Item zu kaufen.'); pageItems.forEach(item => { embed.addFields({ name: `${item.name} (${item.id})`, - value: `**Type:** ${item.type}\n **Price:** $${item.price.toLocaleString()}\n${item.description}`, + value: `**Typ:** ${item.type}\n **Preis:** $${item.price.toLocaleString()}\n${item.description}`, inline: false, }); }); - embed.setFooter({ text: `Page ${page}/${totalPages}` }); + embed.setFooter({ text: `Seite ${page}/${totalPages}` }); return embed; }; @@ -37,12 +37,12 @@ export default { new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('shop_prev') - .setLabel('⬅️ Previous') + .setLabel('⬅️ Vorherige') .setStyle(ButtonStyle.Secondary) .setDisabled(page === 1), new ButtonBuilder() .setCustomId('shop_next') - .setLabel('Next ➡️') + .setLabel('Weiter ➡️') .setStyle(ButtonStyle.Secondary) .setDisabled(page === totalPages), ), @@ -62,7 +62,7 @@ export default { collector.on('collect', async (buttonInteraction) => { if (buttonInteraction.user.id !== interaction.user.id) { - await buttonInteraction.reply({ content: '❌ You cannot use these buttons. Run `/eco shop` to get your own shop view.', flags: 64 }); + await buttonInteraction.reply({ content: '❌ Du kannst diese Buttons nicht nutzen. Führe `/eco shop` aus, um deine eigene Shop-Ansicht zu öffnen.', flags: 64 }); return; } const { customId } = buttonInteraction; diff --git a/src/commands/Economy/modules/shop_config_setrole.ts b/src/commands/Economy/modules/shop_config_setrole.ts index 65ba854c37..ac1b08edff 100644 --- a/src/commands/Economy/modules/shop_config_setrole.ts +++ b/src/commands/Economy/modules/shop_config_setrole.ts @@ -9,7 +9,7 @@ import { replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; export default { async execute(interaction, config, client) { if (!(interaction.member as GuildMember).permissions.has(PermissionsBitField.Flags.ManageGuild)) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need **Manage Server** permissions to set the premium role.' }); + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Du brauchst die Berechtigung **Server verwalten**, um die Premium-Rolle festzulegen.' }); } const role = interaction.options.getRole('role')!; @@ -21,12 +21,12 @@ export default { await setGuildConfig(client, guildId, currentConfig); return InteractionHelper.safeReply(interaction, { - embeds: [successEmbed('Premium Role Set', `The **Premium Shop Role** has been set to ${role.toString()}. Members who purchase the Premium Role item will be granted this role.`)], + embeds: [successEmbed('Premium-Rolle festgelegt', `Die **Premium-Shop-Rolle** ist jetzt ${role.toString()}. Mitglieder, die das Item „Premium-Rolle“ kaufen, erhalten diese Rolle.`)], ephemeral: true, }); } catch (error) { logger.error('shop_config_setrole error:', error); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Could not save the guild configuration.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Die Server-Konfiguration konnte nicht gespeichert werden.' }); } }, } satisfies SubcommandModule; diff --git a/src/commands/Economy/modules/slut.ts b/src/commands/Economy/modules/slut.ts index cffbddcb16..17a719d5e2 100644 --- a/src/commands/Economy/modules/slut.ts +++ b/src/commands/Economy/modules/slut.ts @@ -10,36 +10,36 @@ import { InteractionHelper } from '../../../utils/interactionHelper.js'; const SLUT_COOLDOWN = 45 * 60 * 1000; const SLUT_ACTIVITIES = [ - { name: "Cam Stream", min: 120, max: 450, risk: 0.2 }, - { name: "Private Dance Session", min: 220, max: 700, risk: 0.25 }, - { name: "After-Hours Club Host", min: 320, max: 900, risk: 0.3 }, - { name: "VIP Companion Booking", min: 550, max: 1400, risk: 0.35 }, - { name: "Exclusive Livestream", min: 850, max: 2200, risk: 0.4 }, + { name: "Cam-Stream", min: 120, max: 450, risk: 0.2 }, + { name: "Private Tanz-Session", min: 220, max: 700, risk: 0.25 }, + { name: "Nachtclub-Host", min: 320, max: 900, risk: 0.3 }, + { name: "VIP-Begleitbuchung", min: 550, max: 1400, risk: 0.35 }, + { name: "Exklusiver Livestream", min: 850, max: 2200, risk: 0.4 }, ]; const POSITIVE_OUTCOMES = [ - "Your stream blew up and tips poured in.", - "A VIP booking paid far above average.", - "Your after-hours shift was packed and profitable.", - "Premium requests came through and your payout jumped.", + "Dein Stream ging durch die Decke und das Trinkgeld floss nur so.", + "Eine VIP-Buchung hat weit über dem Durchschnitt gezahlt.", + "Deine Nachtschicht war voll und hat sich richtig gelohnt.", + "Premium-Anfragen kamen rein und deine Auszahlung ist gestiegen.", ]; const FINE_OUTCOMES = [ - "Venue security issued a compliance fine.", - "A moderation strike triggered a platform fee.", - "You were flagged and had to pay a penalty.", + "Die Security vor Ort hat dir ein Bußgeld verpasst.", + "Ein Moderations-Strike hat eine Plattformgebühr ausgelöst.", + "Du wurdest gemeldet und musstest eine Strafe zahlen.", ]; const ROBBED_OUTCOMES = [ - "A fake buyer chargeback wiped part of your earnings.", - "A scam booking cleaned out a chunk of your cash.", - "You got baited by a fraud account and lost money.", + "Die Rückbuchung eines Fake-Käufers hat einen Teil deiner Einnahmen gekostet.", + "Eine Betrugsbuchung hat dir einen Batzen Geld abgeknöpft.", + "Du bist auf einen Fake-Account reingefallen und hast Geld verloren.", ]; const LOSS_OUTCOMES = [ - "The set flopped and you had to cover operating costs.", - "You burned budget on prep and made no return.", - "The shift went sideways and left you in the red.", + "Der Auftritt war ein Flop und du musstest die Kosten selbst tragen.", + "Du hast Geld in die Vorbereitung gesteckt und nichts zurückbekommen.", + "Die Schicht lief schief und du bist im Minus gelandet.", ]; function randomInt(min: number, max: number): number { @@ -62,7 +62,7 @@ function resolveOutcome(activity: typeof SLUT_ACTIVITIES[number], wallet: number type: 'payout', delta: amount, message: randomChoice(POSITIVE_OUTCOMES), - title: `${activity.name} - Payout` + title: `${activity.name} – Auszahlung` }; } @@ -76,7 +76,7 @@ function resolveOutcome(activity: typeof SLUT_ACTIVITIES[number], wallet: number type: 'fine', delta: -amount, message: randomChoice(FINE_OUTCOMES), - title: `${activity.name} - Fined` + title: `${activity.name} – Strafe` }; } @@ -88,7 +88,7 @@ function resolveOutcome(activity: typeof SLUT_ACTIVITIES[number], wallet: number type: 'robbed', delta: -amount, message: randomChoice(ROBBED_OUTCOMES), - title: `${activity.name} - Robbed` + title: `${activity.name} – Abgezockt` }; } @@ -99,14 +99,14 @@ function resolveOutcome(activity: typeof SLUT_ACTIVITIES[number], wallet: number type: 'loss', delta: -amount, message: randomChoice(LOSS_OUTCOMES), - title: `${activity.name} - Loss` + title: `${activity.name} – Verlust` }; } export default { data: new SlashCommandBuilder() .setName('slut') - .setDescription('Take a risky provocative job for random payout or loss'), + .setDescription('Nimm einen riskanten, anrüchigen Job an – Gewinn oder Verlust per Zufall'), execute: withErrorHandling(async (interaction, config, client) => { const deferred = await InteractionHelper.safeDefer(interaction); @@ -124,7 +124,7 @@ export default { throw createError( "Failed to load economy data for slut command", ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", + "Deine Wirtschaftsdaten konnten nicht geladen werden. Bitte versuch es später erneut.", { userId, guildId } ); } @@ -136,7 +136,7 @@ export default { throw createError( "Slut cooldown active", ErrorTypes.RATE_LIMIT, - `You need to wait before you can work again! Try again in **${Math.ceil(remainingTime / 60000)}** minutes.`, + `Du musst noch warten, bevor du wieder arbeiten kannst! Versuch es in **${Math.ceil(remainingTime / 60000)}** Minuten erneut.`, { timeRemaining: remainingTime, cooldownType: 'slut' } ); } @@ -171,11 +171,11 @@ export default { const amountLabel = `${outcome.delta >= 0 ? '+' : '-'}$${Math.abs(outcome.delta).toLocaleString()}`; const summaryLines = [ `${outcome.message}`, - `💸 **Net Result:** ${amountLabel}`, - `💳 **Current Balance:** $${userData.wallet.toLocaleString()}`, - `📊 **Total Sessions:** ${userData.totalSluts}`, - `💵 **Total Earned:** $${(userData.totalSlutEarnings || 0).toLocaleString()}`, - `🧾 **Total Lost:** $${(userData.totalSlutLosses || 0).toLocaleString()}` + `💸 **Ergebnis:** ${amountLabel}`, + `💳 **Aktueller Kontostand:** $${userData.wallet.toLocaleString()}`, + `📊 **Jobs gesamt:** ${userData.totalSluts}`, + `💵 **Gesamt verdient:** $${(userData.totalSlutEarnings || 0).toLocaleString()}`, + `🧾 **Gesamt verloren:** $${(userData.totalSlutLosses || 0).toLocaleString()}` ]; const embed = createEmbed({ diff --git a/src/commands/Economy/modules/withdraw.ts b/src/commands/Economy/modules/withdraw.ts index fea4f3f042..f0e0f061d3 100644 --- a/src/commands/Economy/modules/withdraw.ts +++ b/src/commands/Economy/modules/withdraw.ts @@ -8,11 +8,11 @@ import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName('withdraw') - .setDescription('Withdraw money from your bank to your wallet') + .setDescription('Hebe Geld von deiner Bank in deine Geldbörse ab') .addIntegerOption(option => option .setName('amount') - .setDescription('Amount to withdraw') + .setDescription('Betrag, den du abheben möchtest') .setRequired(true) .setMinValue(1) ), @@ -30,7 +30,7 @@ export default { throw createError( "Failed to load economy data", ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", + "Deine Wirtschaftsdaten konnten nicht geladen werden. Bitte versuch es später erneut.", { userId, guildId } ); } @@ -41,7 +41,7 @@ export default { throw createError( "Invalid withdrawal amount", ErrorTypes.VALIDATION, - "You must withdraw a positive amount.", + "Du musst einen positiven Betrag abheben.", { amount: withdrawAmount, userId } ); } @@ -54,7 +54,7 @@ export default { throw createError( "Empty bank account", ErrorTypes.VALIDATION, - "Your bank account is empty.", + "Dein Bankkonto ist leer.", { userId, bankBalance: userData.bank } ); } @@ -65,17 +65,17 @@ export default { await setEconomyData(client, guildId, userId, userData); const embed = successEmbed( - 'Withdrawal Successful', - `You successfully withdrew **$${withdrawAmount.toLocaleString()}** from your bank.` + 'Abhebung erfolgreich', + `Du hast **$${withdrawAmount.toLocaleString()}** von deiner Bank abgehoben.` ) .addFields( { - name: "New Cash Balance", + name: "Geldbörse (neu)", value: `$${userData.wallet.toLocaleString()}`, inline: true, }, { - name: "New Bank Balance", + name: "Bank (neu)", value: `$${userData.bank.toLocaleString()}`, inline: true, }, diff --git a/src/commands/Economy/modules/work.ts b/src/commands/Economy/modules/work.ts index d4e7fc28d0..ef704a473f 100644 --- a/src/commands/Economy/modules/work.ts +++ b/src/commands/Economy/modules/work.ts @@ -12,22 +12,22 @@ 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", + "Softwareentwickler", "Barista", - "Janitor", + "Hausmeister", "YouTuber", - "Discord Bot Developer", - "Cashier", - "Pizza Delivery Driver", - "Librarian", - "Gardener", - "Data Analyst", + "Discord-Bot-Entwickler", + "Kassierer", + "Pizzalieferant", + "Bibliothekar", + "Gärtner", + "Datenanalyst", ]; export default { data: new SlashCommandBuilder() .setName('work') - .setDescription('Work to earn some money'), + .setDescription('Arbeite, um etwas Geld zu verdienen'), execute: withErrorHandling(async (interaction, config, client) => { const deferred = await InteractionHelper.safeDefer(interaction); @@ -43,7 +43,7 @@ export default { throw createError( "Failed to load economy data for work", ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", + "Deine Wirtschaftsdaten konnten nicht geladen werden. Bitte versuch es später erneut.", { userId, guildId } ); } @@ -67,7 +67,7 @@ export default { throw createError( "Work cooldown active", ErrorTypes.RATE_LIMIT, - `You're working too fast! Wait **${Math.floor(remaining / 3600000)}h ${Math.floor((remaining % 3600000) / 60000)}m** before working again.`, + `Du arbeitest zu schnell! Warte **${Math.floor(remaining / 3600000)}h ${Math.floor((remaining % 3600000) / 60000)}m**, bevor du wieder arbeitest.`, { timeRemaining: remaining, cooldownType: 'work' } ); } @@ -79,7 +79,7 @@ export default { let multiplierMessage = ""; if (hasLaptop > 0) { earned = Math.floor(earned * LAPTOP_MULTIPLIER); - multiplierMessage = "\n💻 **Laptop Bonus:** +50% earnings!"; + multiplierMessage = "\n💻 **Laptop-Bonus:** +50 % Verdienst!"; } userData.wallet = (userData.wallet || 0) + earned; @@ -99,23 +99,23 @@ export default { }); const embed = successEmbed( - "💼 Work Complete!", - `You worked as a **${job}** and earned **$${earned.toLocaleString()}**!${multiplierMessage}` + "💼 Arbeit erledigt!", + `Du hast als **${job}** gearbeitet und **$${earned.toLocaleString()}** verdient!${multiplierMessage}` ) .addFields( { - name: "New Balance", + name: "Neuer Kontostand", value: `$${userData.wallet.toLocaleString()}`, inline: true, }, { - name: "Next Work", + name: "Nächste Schicht", value: ``, inline: true, } ) .setFooter({ - text: `Requested by ${interaction.user.tag}`, + text: `Angefragt von ${interaction.user.tag}`, iconURL: interaction.user.displayAvatarURL(), }); diff --git a/src/commands/Fun/count.ts b/src/commands/Fun/count.ts index 9094567595..7e91fa9108 100644 --- a/src/commands/Fun/count.ts +++ b/src/commands/Fun/count.ts @@ -18,47 +18,47 @@ import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; export default { data: new SlashCommandBuilder() .setName('count') - .setDescription('Manage the server counting game') + .setDescription('Verwalte das Zählspiel des Servers') .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .setDMPermission(false) .addSubcommand((subcommand) => subcommand .setName('setup') - .setDescription('Start a counting game in a text channel') + .setDescription('Starte ein Zählspiel in einem Textkanal') .addChannelOption((option) => option .setName('channel') - .setDescription('The channel where counting will take place') + .setDescription('Der Kanal, in dem gezählt wird') .setRequired(true) .addChannelTypes(ChannelType.GuildText), ) .addStringOption((option) => option .setName('system') - .setDescription('The counting system to use') + .setDescription('Das Zahlensystem, das genutzt wird') .setRequired(true) .addChoices(...getCountingSystemChoices()), ), ) .addSubcommand((subcommand) => - subcommand.setName('disable').setDescription('Disable the counting game for this server'), + subcommand.setName('disable').setDescription('Deaktiviere das Zählspiel für diesen Server'), ) .addSubcommand((subcommand) => - subcommand.setName('status').setDescription('View current counting game status'), + subcommand.setName('status').setDescription('Zeige den aktuellen Status des Zählspiels'), ) .addSubcommand((subcommand) => subcommand .setName('reset') - .setDescription('Reset the current counting sequence') + .setDescription('Setze die aktuelle Zählfolge zurück') .addIntegerOption((option) => option .setName('start') - .setDescription('The number to start at after reset') + .setDescription('Die Startzahl nach dem Zurücksetzen') .setMinValue(1), ), ) .addSubcommand((subcommand) => - subcommand.setName('leaderboard').setDescription('Show the counting game leaderboard'), + subcommand.setName('leaderboard').setDescription('Zeige die Bestenliste des Zählspiels'), ), category: 'Fun', @@ -71,7 +71,7 @@ export default { } if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the **Manage Server** permission to use this command.' }); + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Du brauchst die Berechtigung **Server verwalten**, um diesen Command zu nutzen.' }); } const guildId = interaction.guildId!; @@ -82,19 +82,19 @@ export default { const channel = interaction.options.getChannel('channel'); const system = interaction.options.getString('system')!; if (!channel || channel.type !== ChannelType.GuildText) { - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please choose a text channel for the counting game.' }); + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Bitte wähle einen Textkanal für das Zählspiel.' }); } 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: `Auf diesem Server ist bereits ein Zählkanal aktiv: <#${config.channelId}>. Deaktiviere zuerst das aktuelle Zählspiel oder nutze diesen Kanal.` }); } await activateCountingGame(interaction.client, guildId, channel.id, system); return await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( - 'Counting Game Enabled', - `The counting game is now active in ${channel} using the **${getCountingSystemLabel(system)}** system. Players must count up from **1** and may not post two numbers in a row.`, + 'Zählspiel aktiviert', + `Das Zählspiel ist jetzt in ${channel} mit dem System **${getCountingSystemLabel(system)}** aktiv. Gezählt wird ab **1**, und niemand darf zwei Zahlen hintereinander posten.`, ), ], }); @@ -103,32 +103,32 @@ export default { if (subcommand === 'disable') { if (!config.enabled) { return await InteractionHelper.safeEditReply(interaction, { - embeds: [infoEmbed('Counting Game Disabled', 'The counting game is already disabled for this server.')], + embeds: [infoEmbed('Zählspiel deaktiviert', 'Das Zählspiel ist auf diesem Server bereits deaktiviert.')], }); } await disableCountingGame(interaction.client, guildId); return await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed('Counting Game Disabled', 'The counting game has been disabled.')], + embeds: [successEmbed('Zählspiel deaktiviert', 'Das Zählspiel wurde deaktiviert.')], }); } if (subcommand === 'status') { const fields = [ - { name: 'Enabled', value: config.enabled ? 'Yes' : 'No', inline: true }, - { name: 'Channel', value: config.channelId ? `<#${config.channelId}>` : 'Not configured', inline: true }, + { name: 'Aktiviert', value: config.enabled ? 'Ja' : 'Nein', inline: true }, + { name: 'Kanal', value: config.channelId ? `<#${config.channelId}>` : 'Nicht eingerichtet', inline: true }, { name: 'System', value: getCountingSystemLabel(config.system), inline: true }, - { name: 'Next count', value: getExpectedCountValue(config), inline: true }, - { name: 'Current streak', value: `${config.currentStreak}`, inline: true }, - { name: 'Best streak', value: `${config.bestStreak || 0}`, inline: true }, - { name: 'Last counter', value: config.lastUserId ? `<@${config.lastUserId}>` : 'None', inline: true }, + { name: 'Nächste Zahl', value: getExpectedCountValue(config), inline: true }, + { name: 'Aktuelle Serie', value: `${config.currentStreak}`, inline: true }, + { name: 'Beste Serie', value: `${config.bestStreak || 0}`, inline: true }, + { name: 'Zuletzt gezählt von', value: config.lastUserId ? `<@${config.lastUserId}>` : 'Niemand', inline: true }, ]; return await InteractionHelper.safeEditReply(interaction, { embeds: [ createEmbed({ - title: 'Counting Game Status', - description: 'Overview of the currently configured counting game.', + title: 'Status des Zählspiels', + description: 'Übersicht über das aktuell eingerichtete Zählspiel.', fields, color: 'primary', }), @@ -138,7 +138,7 @@ export default { if (subcommand === 'reset') { if (!config.enabled) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Enable the counting game first with `/count setup`.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Aktiviere das Zählspiel zuerst mit `/count setup`.' }); } const startNumber = interaction.options.getInteger('start') || 1; @@ -147,8 +147,8 @@ export default { return await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( - 'Counting Game Reset', - `The counting sequence has been reset. Start again with **${startNumber}** in <#${config.channelId}>.`, + 'Zählspiel zurückgesetzt', + `Die Zählfolge wurde zurückgesetzt. Fang in <#${config.channelId}> wieder mit **${startNumber}** an.`, ), ], }); @@ -160,18 +160,18 @@ export default { return await InteractionHelper.safeEditReply(interaction, { embeds: [ createEmbed({ - title: 'Counting Game Leaderboard', - description: leaderboard.length > 0 ? leaderboard.join('\n') : 'No counts have been recorded yet.', + title: 'Bestenliste des Zählspiels', + description: leaderboard.length > 0 ? leaderboard.join('\n') : 'Bisher wurde noch nicht gezählt.', color: 'primary', }), ], }); } - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please choose a valid counting game action.' }); + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Bitte wähle eine gültige Aktion für das Zählspiel.' }); } catch (error) { logger.error('Count command error:', error); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Something went wrong while managing the counting game.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Verwalten des Zählspiels ist etwas schiefgelaufen.' }); } }, } satisfies Command; diff --git a/src/commands/Fun/fun.ts b/src/commands/Fun/fun.ts index d625aa8355..e07106a8b5 100644 --- a/src/commands/Fun/fun.ts +++ b/src/commands/Fun/fun.ts @@ -5,7 +5,7 @@ import rollCommand from './modules/roll.js'; export default createRouterCommand({ name: 'fun', - description: 'Fun commands and mini games', + description: 'Spaß-Commands und Minispiele', category: 'Fun', routes: [ { legacyName: 'fight', module: fightCommand }, diff --git a/src/commands/Fun/modules/fight.ts b/src/commands/Fun/modules/fight.ts index 0039d23ff4..c91aa03131 100644 --- a/src/commands/Fun/modules/fight.ts +++ b/src/commands/Fun/modules/fight.ts @@ -10,11 +10,11 @@ const EMBED_DESCRIPTION_LIMIT = 4096; export default { data: new SlashCommandBuilder() .setName("fight") - .setDescription("Starts a simulated 1v1 text-based battle.") + .setDescription("Startet einen simulierten 1-gegen-1-Textkampf.") .addUserOption((option) => option .setName("opponent") - .setDescription("The user to fight.") + .setDescription("Der Nutzer, gegen den du kämpfst.") .setRequired(true), ), category: 'Fun', @@ -27,16 +27,16 @@ export default { 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.` + "⚔️ Ungültige Herausforderung", + `**${challenger.username}**, du kannst nicht gegen dich selbst kämpfen! Das wäre ein Unentschieden, bevor es überhaupt losgeht.` ); 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." + "⚔️ Ungültiger Gegner", + "Du kannst nicht gegen Bots kämpfen! Fordere lieber eine echte Person heraus." ); return await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); } @@ -48,25 +48,25 @@ export default { const log: string[] = []; log.push( - `💥 **${challenger.username}** challenges **${opponent.username}** to a duel! (Best of ${rounds} rounds)`, + `💥 **${challenger.username}** fordert **${opponent.username}** zum Duell heraus! (${rounds} Runden)`, ); 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", + "schlägt wild zu", + "landet einen kritischen Treffer", + "wirkt einen schwachen Zauber", + "pariert und kontert", ][rand(0, 3)]; log.push( - `\n**Round ${i}:** ${attacker.username} ${action} on ${target.username} for ${rand(1, damage)} damage!`, + `\n**Runde ${i}:** ${attacker.username} ${action} gegen ${target.username} und verursacht ${rand(1, damage)} Schaden!`, ); } const outcomeText = log.join("\n"); - const winnerText = `👑 **${winner.username}** has defeated ${loser.username} and claims the victory!`; + const winnerText = `👑 **${winner.username}** hat ${loser.username} besiegt und holt sich den Sieg!`; const fullDescription = `${outcomeText}\n\n${winnerText}`; const description = fullDescription.length <= EMBED_DESCRIPTION_LIMIT @@ -74,7 +74,7 @@ export default { : `${fullDescription.slice(0, EMBED_DESCRIPTION_LIMIT - 15)}\n\n...`; const embed = successEmbed( - "🏆 Duel Complete!", + "🏆 Duell beendet!", description ); diff --git a/src/commands/Fun/modules/flip.ts b/src/commands/Fun/modules/flip.ts index 53eff971b3..a036e1ff78 100644 --- a/src/commands/Fun/modules/flip.ts +++ b/src/commands/Fun/modules/flip.ts @@ -8,16 +8,16 @@ import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("flip") - .setDescription("Flips a coin (Heads or Tails)."), + .setDescription("Wirft eine Münze (Kopf oder Zahl)."), category: 'Fun', async execute(interaction, config, client) { - const result = Math.random() < 0.5 ? "Heads" : "Tails"; - const emoji = result === "Heads" ? "🪙" : "🔮"; + const result = Math.random() < 0.5 ? "Kopf" : "Zahl"; + const emoji = result === "Kopf" ? "🪙" : "🔮"; const embed = successEmbed( - "Heads or Tails?", - `The coin landed on... **${result}** ${emoji}!`, + "Kopf oder Zahl?", + `Die Münze zeigt... **${result}** ${emoji}!`, ); await InteractionHelper.safeReply(interaction, { embeds: [embed] }); diff --git a/src/commands/Fun/modules/roll.ts b/src/commands/Fun/modules/roll.ts index 6ea2858935..8b6acbaa74 100644 --- a/src/commands/Fun/modules/roll.ts +++ b/src/commands/Fun/modules/roll.ts @@ -8,11 +8,11 @@ import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("roll") - .setDescription("Rolls dice using standard notation (e.g., 2d20, 1d6 + 5).") + .setDescription("Würfelt mit Standardnotation (z. B. 2d20, 1d6 + 5).") .addStringOption((option) => option .setName("notation") - .setDescription("The dice notation (e.g., 2d6, 1d20 + 4)") + .setDescription("Die Würfelnotation (z. B. 2d6, 1d20 + 4)") .setRequired(true) .setMaxLength(50), ), @@ -32,7 +32,7 @@ export default { throw new NexoBotError( `Invalid dice notation: ${notation}`, ErrorTypes.USER_INPUT, - 'Invalid notation. Use format like `1d20` or `3d6+5`.' + 'Ungültige Notation. Nutze ein Format wie `1d20` oder `3d6+5`.' ); } @@ -44,7 +44,7 @@ export default { throw new NexoBotError( `Too many dice requested: ${numDice}`, ErrorTypes.VALIDATION, - 'Please keep the number of dice between 1 and 20.' + 'Die Anzahl der Würfel muss zwischen 1 und 20 liegen.' ); } @@ -52,7 +52,7 @@ export default { throw new NexoBotError( `Invalid number of sides: ${numSides}`, ErrorTypes.VALIDATION, - 'Please keep the number of sides between 1 and 1000.' + 'Die Anzahl der Seiten muss zwischen 1 und 1000 liegen.' ); } @@ -68,12 +68,12 @@ export default { const finalTotal = totalRoll + modifier; const resultsDetail = - numDice > 1 ? `**Rolls:** ${rolls.join(" + ")}\n` : ""; + numDice > 1 ? `**Würfe:** ${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}**`, + `🎲 Würfle ${numDice}d${numSides}${modifier !== 0 ? match[3] : ""}`, + `${resultsDetail}**Gesamtwurf:** ${totalRoll}${modifierText} = **${finalTotal}**`, ); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); diff --git a/src/commands/Giveaway/giveaway.ts b/src/commands/Giveaway/giveaway.ts index 5f18d6f178..e3989a0aee 100644 --- a/src/commands/Giveaway/giveaway.ts +++ b/src/commands/Giveaway/giveaway.ts @@ -7,7 +7,7 @@ import grerollCommand from './modules/greroll.js'; export default createRouterCommand({ name: 'giveaway', - description: 'Create and manage giveaways', + description: 'Giveaways erstellen und verwalten', category: 'Giveaway', defaultMemberPermissions: PermissionFlagsBits.ManageGuild, routes: [ diff --git a/src/commands/Giveaway/modules/gcreate.ts b/src/commands/Giveaway/modules/gcreate.ts index 24c7114a07..230cc7a3c5 100644 --- a/src/commands/Giveaway/modules/gcreate.ts +++ b/src/commands/Giveaway/modules/gcreate.ts @@ -23,19 +23,19 @@ const GIVEAWAY_MAX_WINNERS = botConfig.giveaways?.maximumWinners ?? 10; export default { data: new SlashCommandBuilder() .setName("gcreate") - .setDescription("Starts a new giveaway in a specified channel.") + .setDescription("Startet ein neues Giveaway in einem bestimmten Kanal.") .addStringOption((option) => option .setName("duration") .setDescription( - "How long the giveaway should last (e.g., 1h, 30m, 5d).", + "Wie lange das Giveaway laufen soll (z. B. 1h, 30m, 5d).", ) .setRequired(true), ) .addIntegerOption((option) => option .setName("winners") - .setDescription("The number of winners to pick.") + .setDescription("Die Anzahl der Gewinner.") .setMinValue(GIVEAWAY_MIN_WINNERS) .setMaxValue(GIVEAWAY_MAX_WINNERS) .setRequired(true), @@ -43,13 +43,13 @@ export default { .addStringOption((option) => option .setName("prize") - .setDescription("The prize being given away.") + .setDescription("Der Preis, der verlost wird.") .setRequired(true), ) .addChannelOption((option) => option .setName("channel") - .setDescription("The channel to send the giveaway to (defaults to current channel).") + .setDescription("Der Kanal für das Giveaway (Standard: aktueller Kanal).") .addChannelTypes(ChannelType.GuildText) .setRequired(false), ) @@ -63,7 +63,7 @@ export default { throw new NexoBotError( 'Giveaway command used outside guild', ErrorTypes.VALIDATION, - 'This command can only be used in a server.', + 'Dieser Command kann nur auf einem Server genutzt werden.', { userId: interaction.user.id } ); } @@ -72,7 +72,7 @@ export default { throw new NexoBotError( 'User lacks ManageGuild permission', ErrorTypes.PERMISSION, - "You need the 'Manage Server' permission to start a giveaway.", + "Du brauchst die Berechtigung „Server verwalten“, um ein Giveaway zu starten.", { userId: interaction.user.id, guildId: interaction.guildId } ); } @@ -92,7 +92,7 @@ export default { throw new NexoBotError( 'Target channel is not text-based', ErrorTypes.VALIDATION, - 'The channel must be a text channel.', + 'Der Kanal muss ein Textkanal sein.', { channelId: targetChannel.id, channelType: targetChannel.type } ); } @@ -118,7 +118,7 @@ export default { const row = createGiveawayButtons(false); const giveawayMessage = await targetChannel.send({ - content: "🎉 **NEW GIVEAWAY** 🎉", + content: "🎉 **NEUES GIVEAWAY** 🎉", embeds: [embed], components: [row], }); @@ -140,27 +140,27 @@ export default { guildId: interaction.guildId, eventType: EVENT_TYPES.GIVEAWAY_CREATE, data: { - description: `Giveaway created: ${prizeName}`, + description: `Giveaway erstellt: ${prizeName}`, channelId: targetChannel.id, userId: interaction.user.id, fields: [ { - name: 'Prize', + name: 'Preis', value: prizeName, inline: true }, { - name: 'Winners', + name: 'Gewinner', value: winnerCount.toString(), inline: true }, { - name: 'Duration', + name: 'Dauer', value: durationString, inline: true }, { - name: 'Channel', + name: 'Kanal', value: targetChannel.toString(), inline: true } @@ -176,8 +176,8 @@ export default { await InteractionHelper.safeReply(interaction, { embeds: [ successEmbed( - `Giveaway Started! 🎉`, - `A new giveaway for **${prizeName}** has been started in ${targetChannel} and will end in **${durationString}**.`, + `Giveaway gestartet! 🎉`, + `Ein neues Giveaway für **${prizeName}** läuft jetzt in ${targetChannel} und endet in **${durationString}**.`, ), ], flags: MessageFlags.Ephemeral, diff --git a/src/commands/Giveaway/modules/gdelete.ts b/src/commands/Giveaway/modules/gdelete.ts index c104bbd5dd..f8b9825fa3 100644 --- a/src/commands/Giveaway/modules/gdelete.ts +++ b/src/commands/Giveaway/modules/gdelete.ts @@ -11,12 +11,12 @@ export default { data: new SlashCommandBuilder() .setName("gdelete") .setDescription( - "Deletes a giveaway message and removes it from the database.", + "Löscht eine Giveaway-Nachricht und entfernt sie aus der Datenbank.", ) .addStringOption((option) => option .setName("messageid") - .setDescription("The message ID of the giveaway to delete.") + .setDescription("Die Nachrichten-ID des Giveaways, das gelöscht werden soll.") .setRequired(true), ) .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild), @@ -26,7 +26,7 @@ export default { throw new NexoBotError( 'Giveaway command used outside guild', ErrorTypes.VALIDATION, - 'This command can only be used in a server.', + 'Dieser Command kann nur auf einem Server genutzt werden.', { userId: interaction.user.id } ); } @@ -35,7 +35,7 @@ export default { throw new NexoBotError( 'User lacks ManageGuild permission', ErrorTypes.PERMISSION, - "You need the 'Manage Server' permission to delete a giveaway.", + "Du brauchst die Berechtigung „Server verwalten“, um ein Giveaway zu löschen.", { userId: interaction.user.id, guildId: interaction.guildId } ); } @@ -48,7 +48,7 @@ export default { throw new NexoBotError( 'Invalid message ID format', ErrorTypes.VALIDATION, - 'Please provide a valid message ID.', + 'Bitte gib eine gültige Nachrichten-ID an.', { providedId: messageId } ); } @@ -60,13 +60,13 @@ export default { throw new NexoBotError( `Giveaway not found: ${messageId}`, ErrorTypes.VALIDATION, - "No giveaway was found with that message ID.", + "Mit dieser Nachrichten-ID wurde kein Giveaway gefunden.", { messageId, guildId: interaction.guildId } ); } let deletedMessage = false; - let channelName = "Unknown Channel"; + let channelName = "Unbekannter Kanal"; const tryDeleteFromChannel = async (channel: Channel | null) => { if (!channel || !channel.isTextBased() || !channel.messages?.fetch) { @@ -79,7 +79,7 @@ export default { } await message.delete(); - channelName = (channel as { name?: string | null }).name || 'unknown-channel'; + channelName = (channel as { name?: string | null }).name || 'unbekannter-kanal'; deletedMessage = true; return true; }; @@ -117,7 +117,7 @@ export default { throw new NexoBotError( `Failed to delete giveaway from database: ${messageId}`, ErrorTypes.UNKNOWN, - 'The giveaway could not be removed from the database. Please try again.', + 'Das Giveaway konnte nicht aus der Datenbank entfernt werden. Bitte versuch es erneut.', { messageId, guildId: interaction.guildId } ); } @@ -129,24 +129,24 @@ export default { throw new NexoBotError( `Giveaway still exists after deletion: ${messageId}`, ErrorTypes.UNKNOWN, - 'Deletion did not persist in the database. Please try again.', + 'Das Löschen wurde nicht in der Datenbank gespeichert. Bitte versuch es erneut.', { messageId, guildId: interaction.guildId } ); } const statusMsg = deletedMessage - ? `and the message was deleted from #${channelName}` - : `but the message was already deleted or the channel was inaccessible.`; + ? ` und die Nachricht wurde aus #${channelName} entfernt` + : `, die Nachricht war aber bereits gelöscht oder der Kanal war nicht erreichbar`; 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.` + ? `Für dieses Giveaway wurden bereits ${winnerIds.length} Gewinner gezogen.` : wasEnded - ? 'This giveaway was ended with no valid winners.' - : 'No winner was picked before deletion.'; + ? 'Dieses Giveaway wurde ohne gültige Gewinner beendet.' + : 'Vor dem Löschen wurde kein Gewinner gezogen.'; logger.info(`Giveaway deleted: ${messageId} in ${channelName}`); @@ -156,17 +156,17 @@ export default { guildId: interaction.guildId, eventType: EVENT_TYPES.GIVEAWAY_DELETE, data: { - description: `Giveaway deleted: ${giveaway.prize}`, + description: `Giveaway gelöscht: ${giveaway.prize}`, channelId: giveaway.channelId, userId: interaction.user.id, fields: [ { - name: 'Prize', - value: giveaway.prize || 'Unknown', + name: 'Preis', + value: giveaway.prize || 'Unbekannt', inline: true }, { - name: 'Entries', + name: 'Teilnahmen', value: (giveaway.participants?.length || 0).toString(), inline: true } @@ -180,8 +180,8 @@ export default { return InteractionHelper.safeReply(interaction, { embeds: [ successEmbed( - "Giveaway Deleted", - `Successfully deleted the giveaway for **${giveaway.prize}** ${statusMsg}. ${winnerStatusMsg}`, + "Giveaway gelöscht", + `Das Giveaway für **${giveaway.prize}** wurde gelöscht${statusMsg}. ${winnerStatusMsg}`, ), ], flags: MessageFlags.Ephemeral, diff --git a/src/commands/Giveaway/modules/gend.ts b/src/commands/Giveaway/modules/gend.ts index 34a4d4cbb2..bcc04cf561 100644 --- a/src/commands/Giveaway/modules/gend.ts +++ b/src/commands/Giveaway/modules/gend.ts @@ -16,12 +16,12 @@ export default { data: new SlashCommandBuilder() .setName("gend") .setDescription( - "Ends an active giveaway immediately and picks the winner(s).", + "Beendet ein aktives Giveaway sofort und zieht die Gewinner.", ) .addStringOption((option) => option .setName("messageid") - .setDescription("The message ID of the giveaway to end.") + .setDescription("Die Nachrichten-ID des Giveaways, das beendet werden soll.") .setRequired(true), ) .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild), @@ -31,7 +31,7 @@ export default { throw new NexoBotError( 'Giveaway command used outside guild', ErrorTypes.VALIDATION, - 'This command can only be used in a server.', + 'Dieser Command kann nur auf einem Server genutzt werden.', { userId: interaction.user.id } ); } @@ -40,7 +40,7 @@ export default { throw new NexoBotError( 'User lacks ManageGuild permission', ErrorTypes.PERMISSION, - "You need the 'Manage Server' permission to end a giveaway.", + "Du brauchst die Berechtigung „Server verwalten“, um ein Giveaway zu beenden.", { userId: interaction.user.id, guildId: interaction.guildId } ); } @@ -53,7 +53,7 @@ export default { throw new NexoBotError( 'Invalid message ID format', ErrorTypes.VALIDATION, - 'Please provide a valid message ID.', + 'Bitte gib eine gültige Nachrichten-ID an.', { providedId: messageId } ); } @@ -65,7 +65,7 @@ export default { throw new NexoBotError( `Giveaway not found: ${messageId}`, ErrorTypes.VALIDATION, - "No giveaway was found with that message ID in the database.", + "In der Datenbank wurde kein Giveaway mit dieser Nachrichten-ID gefunden.", { messageId, guildId: interaction.guildId } ); } @@ -91,7 +91,7 @@ export default { throw new NexoBotError( `Channel not found: ${updatedGiveaway.channelId}`, ErrorTypes.VALIDATION, - "Could not find the channel where the giveaway was hosted. The giveaway state has been updated.", + "Der Kanal des Giveaways wurde nicht gefunden. Der Status des Giveaways wurde trotzdem aktualisiert.", { channelId: updatedGiveaway.channelId, messageId } ); } @@ -107,7 +107,7 @@ export default { throw new NexoBotError( `Message not found: ${messageId}`, ErrorTypes.VALIDATION, - "Could not find the giveaway message. The giveaway state has been updated.", + "Die Giveaway-Nachricht wurde nicht gefunden. Der Status des Giveaways wurde trotzdem aktualisiert.", { messageId, channelId: updatedGiveaway.channelId } ); } @@ -122,7 +122,7 @@ export default { const newRow = createGiveawayButtons(true); await message.edit({ - content: "🎉 **GIVEAWAY ENDED** 🎉", + content: "🎉 **GIVEAWAY BEENDET** 🎉", embeds: [newEmbed], components: [newRow], }); @@ -132,7 +132,7 @@ export default { .map((id) => `<@${id}>`) .join(","); const winnerPingMsg = await (channel as SendableChannels).send({ - content: `🎉 CONGRATULATIONS ${winnerMentions}! You won the **${updatedGiveaway.prize}** giveaway! Please contact the host <@${updatedGiveaway.hostId}> to claim your prize.`, + content: `🎉 HERZLICHEN GLÜCKWUNSCH ${winnerMentions}! Du hast das Giveaway **${updatedGiveaway.prize}** gewonnen! Melde dich beim Host <@${updatedGiveaway.hostId}>, um deinen Preis abzuholen.`, }); updatedGiveaway.winnerPingMessageId = winnerPingMsg.id; await saveGiveaway(interaction.client, interaction.guildId, updatedGiveaway); @@ -145,22 +145,22 @@ export default { guildId: interaction.guildId, eventType: EVENT_TYPES.GIVEAWAY_WINNER, data: { - description: `Giveaway ended with ${winners.length} winner(s)`, + description: `Giveaway mit ${winners.length} Gewinner(n) beendet`, channelId: channel.id, userId: interaction.user.id, fields: [ { - name: 'Prize', - value: updatedGiveaway.prize || 'Mystery Prize!', + name: 'Preis', + value: updatedGiveaway.prize || 'Überraschungspreis!', inline: true }, { - name: 'Winners', + name: 'Gewinner', value: winnerMentions, inline: false }, { - name: 'Entries', + name: 'Teilnahmen', value: endResult.participantCount.toString(), inline: true } @@ -172,7 +172,7 @@ export default { } } else { await (channel as SendableChannels).send({ - content: `The giveaway for **${updatedGiveaway.prize}** has ended with no valid entries.`, + content: `Das Giveaway für **${updatedGiveaway.prize}** ist ohne gültige Teilnahmen zu Ende gegangen.`, }); logger.info(`Giveaway ended with no winners: ${messageId}`); } @@ -182,8 +182,8 @@ export default { 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.`, + "Giveaway beendet ✅", + `Das Giveaway für **${updatedGiveaway.prize}** in ${channel} wurde beendet. ${winners.length} Gewinner aus ${endResult.participantCount} Teilnahmen gezogen.`, ), ], flags: MessageFlags.Ephemeral, diff --git a/src/commands/Giveaway/modules/greroll.ts b/src/commands/Giveaway/modules/greroll.ts index 78d738a242..e4a26bbfdb 100644 --- a/src/commands/Giveaway/modules/greroll.ts +++ b/src/commands/Giveaway/modules/greroll.ts @@ -16,11 +16,11 @@ import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("greroll") - .setDescription("Rerolls the winner(s) for an ended giveaway.") + .setDescription("Zieht neue Gewinner für ein beendetes Giveaway.") .addStringOption((option) => option .setName("messageid") - .setDescription("The message ID of the ended giveaway.") + .setDescription("Die Nachrichten-ID des beendeten Giveaways.") .setRequired(true), ) .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild), @@ -30,7 +30,7 @@ export default { throw new NexoBotError( 'Giveaway command used outside guild', ErrorTypes.VALIDATION, - 'This command can only be used in a server.', + 'Dieser Command kann nur auf einem Server genutzt werden.', { userId: interaction.user.id } ); } @@ -39,7 +39,7 @@ export default { throw new NexoBotError( 'User lacks ManageGuild permission', ErrorTypes.PERMISSION, - "You need the 'Manage Server' permission to reroll a giveaway.", + "Du brauchst die Berechtigung „Server verwalten“, um neue Gewinner zu ziehen.", { userId: interaction.user.id, guildId: interaction.guildId } ); } @@ -52,7 +52,7 @@ export default { throw new NexoBotError( 'Invalid message ID format', ErrorTypes.VALIDATION, - 'Please provide a valid message ID.', + 'Bitte gib eine gültige Nachrichten-ID an.', { providedId: messageId } ); } @@ -68,7 +68,7 @@ export default { throw new NexoBotError( `Giveaway not found: ${messageId}`, ErrorTypes.VALIDATION, - "No giveaway was found with that message ID in the database.", + "In der Datenbank wurde kein Giveaway mit dieser Nachrichten-ID gefunden.", { messageId, guildId: interaction.guildId } ); } @@ -77,7 +77,7 @@ export default { throw new NexoBotError( `Giveaway still active: ${messageId}`, ErrorTypes.VALIDATION, - "This giveaway is still active. Please use `/giveaway end` to end it first.", + "Dieses Giveaway läuft noch. Beende es zuerst mit `/giveaway end`.", { messageId, status: 'active' } ); } @@ -88,7 +88,7 @@ export default { throw new NexoBotError( `Insufficient participants for reroll: ${participants.length} < ${giveaway.winnerCount}`, ErrorTypes.VALIDATION, - "Not enough entries to pick the required number of winners.", + "Nicht genug Teilnahmen, um die nötige Anzahl an Gewinnern zu ziehen.", { participantsCount: participants.length, winnersNeeded: giveaway.winnerCount } ); } @@ -125,8 +125,8 @@ export default { 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.", + "Neu gezogen", + "Die neuen Gewinner wurden gezogen und gespeichert. Der Kanal für die Ankündigung wurde nicht gefunden.", ), ], flags: MessageFlags.Ephemeral, @@ -157,11 +157,11 @@ export default { : null; if (existingPingMsg) { await existingPingMsg.edit({ - content: `🔄 **GIVEAWAY REROLL** 🔄 New winners for **${giveaway.prize}**: ${winnerMentions}!`, + content: `🔄 **GIVEAWAY NEU GEZOGEN** 🔄 Neue Gewinner für **${giveaway.prize}**: ${winnerMentions}!`, }); } else { const newPingMsg = await (channel as SendableChannels).send({ - content: `🔄 **GIVEAWAY REROLL** 🔄 New winners for **${giveaway.prize}**: ${winnerMentions}!`, + content: `🔄 **GIVEAWAY NEU GEZOGEN** 🔄 Neue Gewinner für **${giveaway.prize}**: ${winnerMentions}!`, }); updatedGiveaway.winnerPingMessageId = newPingMsg.id; } @@ -174,22 +174,22 @@ export default { guildId: interaction.guildId, eventType: EVENT_TYPES.GIVEAWAY_REROLL, data: { - description: `Giveaway rerolled: ${giveaway.prize}`, + description: `Giveaway neu gezogen: ${giveaway.prize}`, channelId: giveaway.channelId, userId: interaction.user.id, fields: [ { - name: 'Prize', - value: giveaway.prize || 'Mystery Prize!', + name: 'Preis', + value: giveaway.prize || 'Überraschungspreis!', inline: true }, { - name: 'New Winners', + name: 'Neue Gewinner', value: winnerMentions, inline: false }, { - name: 'Total Entries', + name: 'Teilnahmen gesamt', value: participants.length.toString(), inline: true } @@ -203,8 +203,8 @@ export default { return InteractionHelper.safeReply(interaction, { embeds: [ successEmbed( - "Reroll Complete", - `The new winners have been announced in ${channel}. (Original message not found).`, + "Neu gezogen", + `Die neuen Gewinner wurden in ${channel} bekannt gegeben. (Ursprüngliche Nachricht nicht gefunden.)`, ), ], flags: MessageFlags.Ephemeral, @@ -221,7 +221,7 @@ export default { const newRow = createGiveawayButtons(true); await message.edit({ - content: "🔄 **GIVEAWAY REROLLED** 🔄", + content: "🔄 **GIVEAWAY NEU GEZOGEN** 🔄", embeds: [newEmbed], components: [newRow], }); @@ -235,11 +235,11 @@ 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: `🔄 **NEUE GEWINNER** 🔄 HERZLICHEN GLÜCKWUNSCH ${winnerMentions}! Du hast das Giveaway **${giveaway.prize}** gewonnen! Melde dich beim Host <@${giveaway.hostId}>, um deinen Preis abzuholen.`, }); } else { const newPingMsg = await (channel as SendableChannels).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: `🔄 **NEUE GEWINNER** 🔄 HERZLICHEN GLÜCKWUNSCH ${winnerMentions}! Du hast das Giveaway **${giveaway.prize}** gewonnen! Melde dich beim Host <@${giveaway.hostId}>, um deinen Preis abzuholen.`, }); updatedGiveaway.winnerPingMessageId = newPingMsg.id; } @@ -252,22 +252,22 @@ export default { guildId: interaction.guildId, eventType: EVENT_TYPES.GIVEAWAY_REROLL, data: { - description: `Giveaway rerolled: ${giveaway.prize}`, + description: `Giveaway neu gezogen: ${giveaway.prize}`, channelId: giveaway.channelId, userId: interaction.user.id, fields: [ { - name: 'Prize', - value: giveaway.prize || 'Mystery Prize!', + name: 'Preis', + value: giveaway.prize || 'Überraschungspreis!', inline: true }, { - name: 'New Winners', + name: 'Neue Gewinner', value: winnerMentions, inline: false }, { - name: 'Total Entries', + name: 'Teilnahmen gesamt', value: participants.length.toString(), inline: true } @@ -281,8 +281,8 @@ export default { return InteractionHelper.safeReply(interaction, { embeds: [ successEmbed( - "Reroll Successful ✅", - `Successfully rerolled the giveaway for **${giveaway.prize}** in ${channel}. Selected ${newWinners.length} new winner(s).`, + "Erfolgreich neu gezogen ✅", + `Für das Giveaway **${giveaway.prize}** in ${channel} wurden ${newWinners.length} neue Gewinner gezogen.`, ), ], flags: MessageFlags.Ephemeral, diff --git a/src/commands/JoinToCreate/jointocreate.ts b/src/commands/JoinToCreate/jointocreate.ts index 62dd193065..f3565db706 100644 --- a/src/commands/JoinToCreate/jointocreate.ts +++ b/src/commands/JoinToCreate/jointocreate.ts @@ -25,55 +25,55 @@ type ChannelConfiguration = Omit subcommand .setName("setup") - .setDescription("Set up a new Join to Create voice channel.") + .setDescription("Neuen Join-to-Create-Sprachkanal einrichten.") .addChannelOption((option) => option .setName("category") - .setDescription("Category to create the channel in.") + .setDescription("Kategorie, in der der Kanal erstellt wird.") .addChannelTypes(ChannelType.GuildCategory) ) .addStringOption((option) => option .setName("channel_name") - .setDescription("Select a template for naming temporary voice channels.") + .setDescription("Vorlage für die Namen temporärer Sprachkanäle.") .addChoices( - { name: "{username}'s Room (Default)", value: "{username}'s Room" }, - { name: "{username}'s Channel", value: "{username}'s Channel" }, - { name: "{username}'s Lounge", value: "{username}'s Lounge" }, - { name: "{username}'s Space", value: "{username}'s Space" }, - { name: "{displayName}'s Room", value: "{displayName}'s Room" }, - { name: "{username}'s VC", value: "{username}'s VC" }, - { name: "{username}'s Music Room", value: "{username}'s Music Room" }, - { name: "{username}'s Gaming Room", value: "{username}'s Gaming Room" }, - { name: "{username}'s Chat Room", value: "{username}'s Chat Room" }, - { name: "{username}'s Private Room", value: "{username}'s Private Room" } + { name: "Raum von {username}", value: "Raum von {username}" }, + { name: "Kanal von {username}", value: "Kanal von {username}" }, + { name: "Lounge von {username}", value: "Lounge von {username}" }, + { name: "Bereich von {username}", value: "Bereich von {username}" }, + { name: "Raum von {displayName}", value: "Raum von {displayName}" }, + { name: "VC von {username}", value: "VC von {username}" }, + { name: "Musikraum von {username}", value: "Musikraum von {username}" }, + { name: "Gaming-Raum von {username}", value: "Gaming-Raum von {username}" }, + { name: "Chatraum von {username}", value: "Chatraum von {username}" }, + { name: "Privater Raum von {username}", value: "Privater Raum von {username}" } ) ) .addIntegerOption((option) => option .setName("user_limit") - .setDescription("Maximum number of users in temporary channels. (0 = unlimited)") + .setDescription("Maximale Nutzerzahl in temporären Kanälen (0 = unbegrenzt).") ) .addIntegerOption((option) => option .setName("bitrate") - .setDescription("Bitrate for temporary channels in kbps (8-96).") + .setDescription("Bitrate temporärer Kanäle in kbps (8-96).") ) ) .addSubcommand((subcommand) => subcommand .setName("dashboard") - .setDescription("Configure an existing Join to Create system.") + .setDescription("Bestehendes Join-to-Create-System konfigurieren.") .addChannelOption((option) => option .setName("trigger_channel") - .setDescription("The Join to Create trigger channel to configure.") + .setDescription("Der Join-to-Create-Trigger-Kanal, der konfiguriert werden soll.") .setRequired(true) .addChannelTypes(ChannelType.GuildVoice) ) @@ -87,7 +87,7 @@ export default { throw new NexoBotError( 'User lacks ManageGuild permission', ErrorTypes.PERMISSION, - 'You need **Manage Server** permission to use this command.' + 'Du brauchst die Berechtigung **Server verwalten**, um diesen Command zu nutzen.' ); } @@ -106,14 +106,14 @@ export default { } catch (error) { try { - let errorMessage = 'An error occurred while executing this command.'; + let errorMessage = 'Beim Ausführen dieses Commands ist ein Fehler aufgetreten.'; if (error instanceof NexoBotError) { - errorMessage = error.userMessage || 'An error occurred. Please try again.'; + errorMessage = error.userMessage || 'Ein Fehler ist aufgetreten. Bitte versuche es erneut.'; logger.debug(`NexoBotError [${error.type}]: ${(error as Error).message}`, error.context || {}); } else { logger.error('Unexpected error in jointocreate command:', error); - errorMessage = 'An unexpected error occurred. Please try again or contact support.'; + errorMessage = 'Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut oder wende dich an den Support.'; } return replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: errorMessage }); @@ -127,7 +127,7 @@ export default { async function handleSetupSubcommand(interaction: ChatInputCommandInteraction, client: NexoClient) { try { const category = interaction.options.getChannel('category') as CategoryChannel | null; - const nameTemplate = interaction.options.getString('channel_name') || "{username}'s Room"; + const nameTemplate = interaction.options.getString('channel_name') || "Raum von {username}"; const userLimit = interaction.options.getInteger('user_limit') || 0; const bitrate = interaction.options.getInteger('bitrate') || 64; const guildId = interaction.guild!.id; @@ -158,7 +158,7 @@ async function handleSetupSubcommand(interaction: ChatInputCommandInteraction, c if (activeTriggerChannels.length > 0) { const primaryTrigger = activeTriggerChannels[0]; - const errorMessage = `This server already has a Join to Create channel set up: ${primaryTrigger}\n\nUse \`/jointocreate dashboard\` to modify it, or remove it first before creating a new one.`; + const errorMessage = `Dieser Server hat bereits einen Join-to-Create-Kanal: ${primaryTrigger}\n\nNutze \`/jointocreate dashboard\`, um ihn zu bearbeiten, oder entferne ihn zuerst, bevor du einen neuen erstellst.`; throw new NexoBotError( 'Guild already has a Join to Create channel', @@ -176,7 +176,7 @@ async function handleSetupSubcommand(interaction: ChatInputCommandInteraction, c logger.debug('Creating Join to Create trigger channel...'); let triggerChannel = await interaction.guild!.channels.create({ - name: 'Join to Create', + name: 'Join-to-Create', type: ChannelType.GuildVoice, parent: category?.id, userLimit: 0, @@ -198,7 +198,7 @@ async function handleSetupSubcommand(interaction: ChatInputCommandInteraction, c categoryId: category?.id }); - await logConfigurationChange(client, guildId, interaction.user.id, 'Initialized Join to Create', { + await logConfigurationChange(client, guildId, interaction.user.id, 'Join-to-Create eingerichtet', { channelId: triggerChannel.id, nameTemplate, userLimit, @@ -208,13 +208,13 @@ async function handleSetupSubcommand(interaction: ChatInputCommandInteraction, c logger.info(`Successfully created Join to Create system in guild ${guildId}`); const responseEmbed = successEmbed( - '✅ Setup Complete', - `Created Join to Create channel: ${triggerChannel}\n\n` + - `**Settings:**\n` + - `• Template: \`${nameTemplate}\`\n` + - `• User Limit: ${userLimit === 0 ? 'Unlimited' : userLimit + ' users'}\n` + + '✅ Einrichtung abgeschlossen', + `Join-to-Create-Kanal erstellt: ${triggerChannel}\n\n` + + `**Einstellungen:**\n` + + `• Vorlage: \`${nameTemplate}\`\n` + + `• Nutzerlimit: ${userLimit === 0 ? 'Unbegrenzt' : userLimit + ' Nutzer'}\n` + `• Bitrate: ${bitrate} kbps\n` + - `${category ?`• Category: ${category.name}`: '• Category: Root level'}` + `${category ?`• Kategorie: ${category.name}`: '• Kategorie: Oberste Ebene'}` ); return await InteractionHelper.safeEditReply(interaction, { embeds: [responseEmbed] }); @@ -227,7 +227,7 @@ async function handleSetupSubcommand(interaction: ChatInputCommandInteraction, c throw new NexoBotError( `Setup failed: ${(error as Error).message}`, ErrorTypes.DISCORD_API, - 'Failed to set up Join to Create system. Please check bot permissions.' + 'Join-to-Create konnte nicht eingerichtet werden. Bitte prüfe die Berechtigungen des Bots.' ); } } @@ -241,18 +241,18 @@ async function handleConfigSubcommand(interaction: ChatInputCommandInteraction, const channelConfig = currentConfig.channelConfig || {}; const configEmbed = new EmbedBuilder() - .setTitle('Join to Create Configuration') - .setDescription(`Configuration for ${triggerChannel}`) + .setTitle('Join-to-Create-Konfiguration') + .setDescription(`Konfiguration für ${triggerChannel}`) .setColor(getColor('info')) .addFields( { - name: 'Channel Name Template', - value: `\`${channelConfig.nameTemplate || currentConfig.channelNameTemplate || "{username}'s Room"}\``, + name: 'Kanalnamen-Vorlage', + value: `\`${channelConfig.nameTemplate || currentConfig.channelNameTemplate || "Raum von {username}"}\``, inline: false }, { - name: 'User Limit', - value: `${(channelConfig.userLimit ?? currentConfig.userLimit ?? 0) === 0 ? 'Unlimited' : (channelConfig.userLimit ?? currentConfig.userLimit ?? 0) + ' users'}`, + name: 'Nutzerlimit', + value: `${(channelConfig.userLimit ?? currentConfig.userLimit ?? 0) === 0 ? 'Unbegrenzt' : (channelConfig.userLimit ?? currentConfig.userLimit ?? 0) + ' Nutzer'}`, inline: true }, { @@ -261,17 +261,17 @@ async function handleConfigSubcommand(interaction: ChatInputCommandInteraction, inline: true } ) - .setFooter({ text: 'Use the buttons below to modify settings • Only one trigger channel is supported per guild' }) + .setFooter({ text: 'Nutze die Buttons unten, um Einstellungen zu ändern • Nur ein Trigger-Kanal pro Server möglich' }) .setTimestamp(); const nameButton = new ButtonBuilder() .setCustomId(`jtc_config_name_${triggerChannel.id}`) - .setLabel('📝 Name Template') + .setLabel('📝 Namensvorlage') .setStyle(ButtonStyle.Primary); const limitButton = new ButtonBuilder() .setCustomId(`jtc_config_limit_${triggerChannel.id}`) - .setLabel('👥 User Limit') + .setLabel('👥 Nutzerlimit') .setStyle(ButtonStyle.Primary); const bitrateButton = new ButtonBuilder() @@ -281,7 +281,7 @@ async function handleConfigSubcommand(interaction: ChatInputCommandInteraction, const deleteButton = new ButtonBuilder() .setCustomId(`jtc_config_delete_${triggerChannel.id}`) - .setLabel('🗑️ Remove Channel') + .setLabel('🗑️ Kanal entfernen') .setStyle(ButtonStyle.Danger); const row = new ActionRowBuilder().addComponents(nameButton, limitButton, bitrateButton, deleteButton); @@ -297,7 +297,7 @@ async function handleConfigSubcommand(interaction: ChatInputCommandInteraction, throw new NexoBotError( 'Failed to fetch interaction reply for collector setup', ErrorTypes.DISCORD_API, - 'Failed to open configuration controls. Please run `/jointocreate dashboard` again.' + 'Die Einstellungen konnten nicht geöffnet werden. Bitte führe `/jointocreate dashboard` erneut aus.' ); } @@ -311,7 +311,7 @@ async function handleConfigSubcommand(interaction: ChatInputCommandInteraction, if (!hasManageGuildPermission(buttonInteraction.member as GuildMember)) { await buttonInteraction.reply({ - content: '❌ You need **Manage Server** permission to use these controls.', + content: '❌ Du brauchst die Berechtigung **Server verwalten**, um diese Steuerung zu nutzen.', flags: MessageFlags.Ephemeral }); return; @@ -330,8 +330,8 @@ async function handleConfigSubcommand(interaction: ChatInputCommandInteraction, } } catch (error) { const userMessage = error instanceof NexoBotError - ? error.userMessage || 'An error occurred.' - : 'An error occurred while processing your request.'; + ? error.userMessage || 'Ein Fehler ist aufgetreten.' + : 'Bei der Verarbeitung deiner Anfrage ist ein Fehler aufgetreten.'; if (error instanceof NexoBotError) { logger.debug(`Button interaction validation error: ${(error as Error).message}`, error.context || {}); @@ -356,7 +356,7 @@ async function handleConfigSubcommand(interaction: ChatInputCommandInteraction, message.edit({ components: [disabledRow], - embeds: [configEmbed.setFooter({ text: 'Configuration session expired. Run the command again to make changes.' })] + embeds: [configEmbed.setFooter({ text: 'Sitzung abgelaufen. Führe den Command erneut aus, um Änderungen vorzunehmen.' })] }).catch(() => {}); }); @@ -367,7 +367,7 @@ async function handleConfigSubcommand(interaction: ChatInputCommandInteraction, throw new NexoBotError( `Config failed: ${(error as Error).message}`, ErrorTypes.DATABASE, - 'Failed to load configuration.' + 'Konfiguration konnte nicht geladen werden.' ); } } @@ -375,25 +375,25 @@ async function handleConfigSubcommand(interaction: ChatInputCommandInteraction, async function handleNameTemplateModal(interaction: ButtonInteraction, triggerChannel: VoiceChannel, currentConfig: ChannelConfiguration, client: NexoClient) { try { const TEMPLATE_OPTIONS = [ - { label: "{username}'s Room (Default)", value: "{username}'s Room" }, - { label: "{username}'s Channel", value: "{username}'s Channel" }, - { label: "{username}'s Lounge", value: "{username}'s Lounge" }, - { label: "{username}'s Space", value: "{username}'s Space" }, - { label: "{displayName}'s Room", value: "{displayName}'s Room" }, - { label: "{username}'s VC", value: "{username}'s VC" }, - { label: "{username}'s Music Room", value: "{username}'s Music Room" }, - { label: "{username}'s Gaming Room", value: "{username}'s Gaming Room" }, - { label: "{username}'s Chat Room", value: "{username}'s Chat Room" }, - { label: "{username}'s Private Room", value: "{username}'s Private Room" }, + { label: "Raum von {username}", value: "Raum von {username}" }, + { label: "Kanal von {username}", value: "Kanal von {username}" }, + { label: "Lounge von {username}", value: "Lounge von {username}" }, + { label: "Bereich von {username}", value: "Bereich von {username}" }, + { label: "Raum von {displayName}", value: "Raum von {displayName}" }, + { label: "VC von {username}", value: "VC von {username}" }, + { label: "Musikraum von {username}", value: "Musikraum von {username}" }, + { label: "Gaming-Raum von {username}", value: "Gaming-Raum von {username}" }, + { label: "Chatraum von {username}", value: "Chatraum von {username}" }, + { label: "Privater Raum von {username}", value: "Privater Raum von {username}" }, ]; const currentTemplate = currentConfig.channelConfig?.nameTemplate || currentConfig.channelNameTemplate - || "{username}'s Room"; + || "Raum von {username}"; const templateSelect = new StringSelectMenuBuilder() .setCustomId('template') - .setPlaceholder('Pick a name template...') + .setPlaceholder('Wähle eine Namensvorlage...') .setOptions( TEMPLATE_OPTIONS.map(o => ({ label: o.label, @@ -403,12 +403,12 @@ async function handleNameTemplateModal(interaction: ButtonInteraction, triggerCh ); const templateLabel = new LabelBuilder() - .setLabel('Channel name template') + .setLabel('Kanalnamen-Vorlage') .setStringSelectMenuComponent(templateSelect); const modal = new ModalBuilder() .setCustomId(`jtc_name_modal_${triggerChannel.id}`) - .setTitle('Channel Name Template') + .setTitle('Kanalnamen-Vorlage') .addLabelComponents(templateLabel); await interaction.showModal(modal); @@ -420,7 +420,7 @@ async function handleNameTemplateModal(interaction: ButtonInteraction, triggerCh if (!hasManageGuildPermission(modalSubmission.member as GuildMember)) { await modalSubmission.reply({ - content: '❌ You need **Manage Server** permission to modify these settings.', + content: '❌ Du brauchst die Berechtigung **Server verwalten**, um diese Einstellungen zu ändern.', flags: MessageFlags.Ephemeral }); return; @@ -432,13 +432,13 @@ async function handleNameTemplateModal(interaction: ButtonInteraction, triggerCh nameTemplate: newTemplate }); - await logConfigurationChange(client, interaction.guild!.id, interaction.user.id, 'Updated channel name template', { + await logConfigurationChange(client, interaction.guild!.id, interaction.user.id, 'Kanalnamen-Vorlage aktualisiert', { channelId: triggerChannel.id, newTemplate }); await modalSubmission.reply({ - embeds: [successEmbed('Updated', `Channel name template changed to \`${newTemplate}\``)], + embeds: [successEmbed('Aktualisiert', `Kanalnamen-Vorlage geändert zu \`${newTemplate}\``)], flags: MessageFlags.Ephemeral }); @@ -453,7 +453,7 @@ async function handleNameTemplateModal(interaction: ButtonInteraction, triggerCh throw new NexoBotError( `Modal error: ${(error as Error).message}`, ErrorTypes.UNKNOWN, - 'An error occurred while updating the template.' + 'Beim Aktualisieren der Vorlage ist ein Fehler aufgetreten.' ); } } @@ -464,13 +464,13 @@ async function handleUserLimitModal(interaction: ButtonInteraction, triggerChann const modal = new ModalBuilder() .setCustomId(`jtc_limit_modal_${triggerChannel.id}`) - .setTitle('Configure User Limit') + .setTitle('Nutzerlimit festlegen') .addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('user_limit') - .setLabel('Enter user limit (0-99, 0 = unlimited)') - .setPlaceholder('Enter a number between 0 and 99') + .setLabel('Nutzerlimit (0-99, 0 = unbegrenzt)') + .setPlaceholder('Gib eine Zahl zwischen 0 und 99 ein') .setStyle(TextInputStyle.Short) .setRequired(true) .setMinLength(1) @@ -488,7 +488,7 @@ async function handleUserLimitModal(interaction: ButtonInteraction, triggerChann if (!hasManageGuildPermission(modalSubmission.member as GuildMember)) { await modalSubmission.reply({ - content: '❌ You need **Manage Server** permission to modify these settings.', + content: '❌ Du brauchst die Berechtigung **Server verwalten**, um diese Einstellungen zu ändern.', flags: MessageFlags.Ephemeral }); return; @@ -500,13 +500,13 @@ async function handleUserLimitModal(interaction: ButtonInteraction, triggerChann userLimit: parseInt(userInput) }); - await logConfigurationChange(client, interaction.guild!.id, interaction.user.id, 'Updated user limit', { + await logConfigurationChange(client, interaction.guild!.id, interaction.user.id, 'Nutzerlimit aktualisiert', { channelId: triggerChannel.id, userLimit: parseInt(userInput) }); await modalSubmission.reply({ - embeds: [successEmbed('Updated', `User limit changed to ${parseInt(userInput) === 0 ? 'Unlimited' : parseInt(userInput) + ' users'}`)], + embeds: [successEmbed('Aktualisiert', `Nutzerlimit geändert zu ${parseInt(userInput) === 0 ? 'Unbegrenzt' : parseInt(userInput) + ' Nutzer'}`)], flags: MessageFlags.Ephemeral }); @@ -521,7 +521,7 @@ async function handleUserLimitModal(interaction: ButtonInteraction, triggerChann throw new NexoBotError( `Modal error: ${(error as Error).message}`, ErrorTypes.UNKNOWN, - 'An error occurred while updating the user limit.' + 'Beim Aktualisieren des Nutzerlimits ist ein Fehler aufgetreten.' ); } } @@ -532,13 +532,13 @@ async function handleBitrateModal(interaction: ButtonInteraction, triggerChannel const modal = new ModalBuilder() .setCustomId(`jtc_bitrate_modal_${triggerChannel.id}`) - .setTitle('Configure Bitrate') + .setTitle('Bitrate festlegen') .addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('bitrate') - .setLabel('Enter bitrate in kbps (8-384)') - .setPlaceholder('Enter a number between 8 and 384') + .setLabel('Bitrate in kbps (8-384)') + .setPlaceholder('Gib eine Zahl zwischen 8 und 384 ein') .setStyle(TextInputStyle.Short) .setRequired(true) .setMinLength(1) @@ -556,7 +556,7 @@ async function handleBitrateModal(interaction: ButtonInteraction, triggerChannel if (!hasManageGuildPermission(modalSubmission.member as GuildMember)) { await modalSubmission.reply({ - content: '❌ You need **Manage Server** permission to modify these settings.', + content: '❌ Du brauchst die Berechtigung **Server verwalten**, um diese Einstellungen zu ändern.', flags: MessageFlags.Ephemeral }); return; @@ -568,13 +568,13 @@ async function handleBitrateModal(interaction: ButtonInteraction, triggerChannel bitrate: parseInt(userInput) * 1000 }); - await logConfigurationChange(client, interaction.guild!.id, interaction.user.id, 'Updated bitrate', { + await logConfigurationChange(client, interaction.guild!.id, interaction.user.id, 'Bitrate aktualisiert', { channelId: triggerChannel.id, bitrate: parseInt(userInput) }); await modalSubmission.reply({ - embeds: [successEmbed('Updated', `Bitrate changed to ${parseInt(userInput)} kbps`)], + embeds: [successEmbed('Aktualisiert', `Bitrate geändert zu ${parseInt(userInput)} kbps`)], flags: MessageFlags.Ephemeral }); @@ -589,7 +589,7 @@ async function handleBitrateModal(interaction: ButtonInteraction, triggerChannel throw new NexoBotError( `Modal error: ${(error as Error).message}`, ErrorTypes.UNKNOWN, - 'An error occurred while updating the bitrate.' + 'Beim Aktualisieren der Bitrate ist ein Fehler aufgetreten.' ); } } @@ -599,16 +599,16 @@ async function handleChannelDeletion(interaction: ButtonInteraction, triggerChan const confirmRow = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`jtc_delete_confirm_${triggerChannel.id}`) - .setLabel('🗑️ Yes, Delete') + .setLabel('🗑️ Ja, löschen') .setStyle(ButtonStyle.Danger), new ButtonBuilder() .setCustomId(`jtc_delete_cancel_${triggerChannel.id}`) - .setLabel('❌ Cancel') + .setLabel('❌ Abbrechen') .setStyle(ButtonStyle.Secondary) ); await InteractionHelper.safeReply(interaction, { - embeds: [warningEmbed('Confirm Deletion', `Are you sure you want to remove **${triggerChannel.name}** from the Join to Create system?\n\nThis action cannot be undone.`)], + embeds: [warningEmbed('Löschen bestätigen', `Willst du **${triggerChannel.name}** wirklich aus dem Join-to-Create-System entfernen?\n\nDas kann nicht rückgängig gemacht werden.`)], components: [confirmRow], flags: MessageFlags.Ephemeral }); @@ -628,7 +628,7 @@ async function handleChannelDeletion(interaction: ButtonInteraction, triggerChan if (!hasManageGuildPermission(buttonInteraction.member as GuildMember)) { await buttonInteraction.reply({ - content: '❌ You need **Manage Server** permission to remove channels.', + content: '❌ Du brauchst die Berechtigung **Server verwalten**, um Kanäle zu entfernen.', flags: MessageFlags.Ephemeral }); return; @@ -638,14 +638,14 @@ async function handleChannelDeletion(interaction: ButtonInteraction, triggerChan await removeTriggerChannel(client, interaction.guild!.id, triggerChannel.id); - await logConfigurationChange(client, interaction.guild!.id, interaction.user.id, 'Removed Join to Create trigger', { + await logConfigurationChange(client, interaction.guild!.id, interaction.user.id, 'Join-to-Create-Trigger entfernt', { channelId: triggerChannel.id, channelName: triggerChannel.name }); try { if (triggerChannel.members.size === 0) { - await triggerChannel.delete('Join to Create trigger removed by administrator'); + await triggerChannel.delete('Join-to-Create-Trigger von einem Administrator entfernt'); } } catch (deleteError) { logger.warn(`Could not delete channel ${triggerChannel.id}: ${(deleteError as Error).message}`); @@ -653,20 +653,20 @@ async function handleChannelDeletion(interaction: ButtonInteraction, triggerChan } await buttonInteraction.update({ - embeds: [successEmbed('Removed', `**${triggerChannel.name}** has been removed from the Join to Create system.`)], + embeds: [successEmbed('Entfernt', `**${triggerChannel.name}** wurde aus dem Join-to-Create-System entfernt.`)], components: [] }); } else { await buttonInteraction.update({ - embeds: [successEmbed('Cancelled', 'Channel removal has been cancelled.')], + embeds: [successEmbed('Abgebrochen', 'Das Entfernen des Kanals wurde abgebrochen.')], components: [] }); } } catch (collectError) { logger.error('Error handling delete confirmation:', collectError); await buttonInteraction.reply({ - content: '❌ An error occurred while processing your request.', + content: '❌ Bei der Verarbeitung deiner Anfrage ist ein Fehler aufgetreten.', flags: MessageFlags.Ephemeral }).catch(() => {}); } @@ -686,7 +686,7 @@ async function handleChannelDeletion(interaction: ButtonInteraction, triggerChan throw new NexoBotError( `Deletion error: ${(error as Error).message}`, ErrorTypes.UNKNOWN, - 'An error occurred while removing the channel.' + 'Beim Entfernen des Kanals ist ein Fehler aufgetreten.' ); } } \ No newline at end of file diff --git a/src/commands/JoinToCreate/modules/config_setup.ts b/src/commands/JoinToCreate/modules/config_setup.ts index 2355c6a88f..a56e99d8e8 100644 --- a/src/commands/JoinToCreate/modules/config_setup.ts +++ b/src/commands/JoinToCreate/modules/config_setup.ts @@ -36,57 +36,57 @@ export default { throw new NexoBotError( `Channel ${triggerChannel.id} is not a Join to Create trigger`, ErrorTypes.VALIDATION, - `${triggerChannel} is not configured as a Join to Create trigger channel.` + `${triggerChannel} ist nicht als Join-to-Create-Trigger-Kanal eingerichtet.` ); } const embed = new EmbedBuilder() - .setTitle('Join to Create Configuration') - .setDescription(`Configure settings for ${triggerChannel}`) + .setTitle('Join-to-Create-Konfiguration') + .setDescription(`Einstellungen für ${triggerChannel} konfigurieren`) .setColor(getColor('info')) .addFields( { - name: 'Current Channel Name Template', + name: 'Aktuelle Kanalnamen-Vorlage', value: `\`${currentConfig.channelOptions?.[triggerChannel.id]?.nameTemplate || currentConfig.channelNameTemplate}\``, inline: false }, { - name: 'Current User Limit', - value: `${currentConfig.channelOptions?.[triggerChannel.id]?.userLimit || currentConfig.userLimit === 0 ? 'No limit' : currentConfig.userLimit + ' users'}`, + name: 'Aktuelles Nutzerlimit', + value: `${currentConfig.channelOptions?.[triggerChannel.id]?.userLimit || currentConfig.userLimit === 0 ? 'Kein Limit' : currentConfig.userLimit + ' Nutzer'}`, inline: true }, { - name: 'Current Bitrate', + name: 'Aktuelle Bitrate', value: `${(currentConfig.channelOptions?.[triggerChannel.id]?.bitrate || currentConfig.bitrate) / 1000} kbps`, inline: true } ) - .setFooter({ text: 'Select an option to configure below' }) + .setFooter({ text: 'Wähle unten eine Option zum Konfigurieren' }) .setTimestamp(); const selectMenu = new StringSelectMenuBuilder() .setCustomId(`jointocreate_config_${triggerChannel.id}`) - .setPlaceholder('Select a configuration option') + .setPlaceholder('Wähle eine Einstellung') .addOptions( new StringSelectMenuOptionBuilder() - .setLabel('Change Channel Name Template') - .setDescription('Modify the template for temporary channel names') + .setLabel('Kanalnamen-Vorlage ändern') + .setDescription('Vorlage für temporäre Kanalnamen ändern') .setValue('name_template'), new StringSelectMenuOptionBuilder() - .setLabel('Change User Limit') - .setDescription('Set maximum users per temporary channel') + .setLabel('Nutzerlimit ändern') + .setDescription('Maximale Nutzerzahl pro temporärem Kanal festlegen') .setValue('user_limit'), new StringSelectMenuOptionBuilder() - .setLabel('Change Bitrate') - .setDescription('Adjust audio quality for temporary channels') + .setLabel('Bitrate ändern') + .setDescription('Audioqualität temporärer Kanäle anpassen') .setValue('bitrate'), new StringSelectMenuOptionBuilder() - .setLabel('Remove This Trigger Channel') - .setDescription('Remove this channel from the Join to Create system') + .setLabel('Diesen Trigger-Kanal entfernen') + .setDescription('Diesen Kanal aus dem Join-to-Create-System entfernen') .setValue('remove_trigger'), new StringSelectMenuOptionBuilder() - .setLabel('View Current Settings') - .setDescription('Show all current configuration details') + .setLabel('Aktuelle Einstellungen anzeigen') + .setDescription('Alle aktuellen Konfigurationsdetails anzeigen') .setValue('view_settings') ); @@ -136,8 +136,8 @@ time: 60000 } const errorMessage = error instanceof NexoBotError - ? error.userMessage || 'An error occurred while processing your selection.' - : 'An error occurred while processing your selection.'; + ? error.userMessage || 'Bei der Verarbeitung deiner Auswahl ist ein Fehler aufgetreten.' + : 'Bei der Verarbeitung deiner Auswahl ist ein Fehler aufgetreten.'; await replyUserError(selectInteraction, { type: ErrorTypes.CONFIGURATION, @@ -165,7 +165,7 @@ time: 60000 throw new NexoBotError( `Config setup failed: ${(error as Error).message}`, ErrorTypes.UNKNOWN, - 'Failed to configure Join to Create system.' + 'Join-to-Create konnte nicht konfiguriert werden.' ); } } @@ -173,22 +173,22 @@ time: 60000 async function handleNameTemplateChange(interaction: StringSelectMenuInteraction, triggerChannel: VoiceChannel, currentConfig: JoinToCreateConfig, client: NexoClient) { const embed = new EmbedBuilder() - .setTitle('Channel Name Template Configuration') - .setDescription('Please enter the new channel name template.') + .setTitle('Kanalnamen-Vorlage konfigurieren') + .setDescription('Bitte gib die neue Kanalnamen-Vorlage ein.') .addFields( { - name: 'Available Variables', - value: '• `{username}` - User\'s username\n• `{display_name}` - User\'s display name\n• `{user_tag}` - User\'s tag (User#1234)\n• `{guild_name}` - Server name', + name: 'Verfügbare Variablen', + value: '• `{username}` - Nutzername\n• `{display_name}` - Anzeigename\n• `{user_tag}` - Tag des Nutzers (User#1234)\n• `{guild_name}` - Servername', inline: false }, { - name: 'Current Template', + name: 'Aktuelle Vorlage', value: `\`${currentConfig.channelOptions?.[triggerChannel.id]?.nameTemplate || currentConfig.channelNameTemplate}\``, inline: false } ) .setColor(getColor('info')) - .setFooter({ text: 'Type your new template in the chat below' }); + .setFooter({ text: 'Schreib deine neue Vorlage unten in den Chat' }); await interaction.followUp({ embeds: [embed], flags: MessageFlags.Ephemeral }); @@ -205,7 +205,7 @@ time: 600_000, if (!newTemplate || newTemplate.length > 100) { await replyUserError(interaction, { type: ErrorTypes.VALIDATION, - message: 'Template must be between 1 and 100 characters.' + message: 'Die Vorlage muss zwischen 1 und 100 Zeichen lang sein.' }); return; } @@ -221,7 +221,7 @@ time: 600_000, }); await interaction.followUp({ - embeds: [successEmbed('Template Updated', `Channel name template changed to \`${newTemplate}\``)], + embeds: [successEmbed('Vorlage aktualisiert', `Kanalnamen-Vorlage geändert zu \`${newTemplate}\``)], flags: MessageFlags.Ephemeral, }); @@ -234,8 +234,8 @@ time: 600_000, } const errorMessage = error instanceof NexoBotError - ? error.userMessage || 'Could not update the channel name template.' - : 'Could not update the channel name template.'; + ? error.userMessage || 'Die Kanalnamen-Vorlage konnte nicht aktualisiert werden.' + : 'Die Kanalnamen-Vorlage konnte nicht aktualisiert werden.'; await replyUserError(interaction, { type: ErrorTypes.CONFIGURATION, @@ -248,7 +248,7 @@ time: 600_000, if (reason === 'time') { replyUserError(interaction, { type: ErrorTypes.RATE_LIMIT, - message: 'No response received. Template update cancelled.' + message: 'Keine Antwort erhalten. Aktualisierung der Vorlage abgebrochen.' }).catch(() => {}); } }); @@ -256,17 +256,17 @@ time: 600_000, async function handleUserLimitChange(interaction: StringSelectMenuInteraction, triggerChannel: VoiceChannel, currentConfig: JoinToCreateConfig, client: NexoClient) { const embed = new EmbedBuilder() - .setTitle('User Limit Configuration') - .setDescription('Please enter the new user limit (0-99, where 0 = no limit).') + .setTitle('Nutzerlimit konfigurieren') + .setDescription('Bitte gib das neue Nutzerlimit ein (0-99, 0 = kein Limit).') .addFields( { - name: 'Current Limit', - value: `${currentConfig.channelOptions?.[triggerChannel.id]?.userLimit || currentConfig.userLimit === 0 ? 'No limit' : currentConfig.userLimit + ' users'}`, + name: 'Aktuelles Limit', + value: `${currentConfig.channelOptions?.[triggerChannel.id]?.userLimit || currentConfig.userLimit === 0 ? 'Kein Limit' : currentConfig.userLimit + ' Nutzer'}`, inline: false } ) .setColor(getColor('info')) - .setFooter({ text: 'Type the new limit in the chat below' }); + .setFooter({ text: 'Schreib das neue Limit unten in den Chat' }); await interaction.followUp({ embeds: [embed], flags: MessageFlags.Ephemeral }); @@ -283,7 +283,7 @@ async function handleUserLimitChange(interaction: StringSelectMenuInteraction, t if (newLimit < 0 || newLimit > 99) { await replyUserError(interaction, { type: ErrorTypes.VALIDATION, - message: 'User limit must be between 0 and 99.' + message: 'Das Nutzerlimit muss zwischen 0 und 99 liegen.' }); return; } @@ -299,7 +299,7 @@ async function handleUserLimitChange(interaction: StringSelectMenuInteraction, t }); await interaction.followUp({ - embeds: [successEmbed('Limit Updated', `User limit changed to ${newLimit === 0 ? 'No limit' : newLimit + ' users'}`)], + embeds: [successEmbed('Limit aktualisiert', `Nutzerlimit geändert zu ${newLimit === 0 ? 'Kein Limit' : newLimit + ' Nutzer'}`)], flags: MessageFlags.Ephemeral, }); @@ -312,8 +312,8 @@ async function handleUserLimitChange(interaction: StringSelectMenuInteraction, t } const errorMessage = error instanceof NexoBotError - ? error.userMessage || 'Could not update the user limit.' - : 'Could not update the user limit.'; + ? error.userMessage || 'Das Nutzerlimit konnte nicht aktualisiert werden.' + : 'Das Nutzerlimit konnte nicht aktualisiert werden.'; await replyUserError(interaction, { type: ErrorTypes.CONFIGURATION, @@ -326,7 +326,7 @@ async function handleUserLimitChange(interaction: StringSelectMenuInteraction, t if (reason === 'time') { replyUserError(interaction, { type: ErrorTypes.RATE_LIMIT, - message: 'No valid response received. Update cancelled.' + message: 'Keine gültige Antwort erhalten. Aktualisierung abgebrochen.' }).catch(() => {}); } }); @@ -334,22 +334,22 @@ async function handleUserLimitChange(interaction: StringSelectMenuInteraction, t async function handleBitrateChange(interaction: StringSelectMenuInteraction, triggerChannel: VoiceChannel, currentConfig: JoinToCreateConfig, client: NexoClient) { const embed = new EmbedBuilder() - .setTitle('Bitrate Configuration') - .setDescription('Please enter the new bitrate in kbps (8-384).') + .setTitle('Bitrate konfigurieren') + .setDescription('Bitte gib die neue Bitrate in kbps ein (8-384).') .addFields( { - name: 'Current Bitrate', + name: 'Aktuelle Bitrate', value: `${(currentConfig.channelOptions?.[triggerChannel.id]?.bitrate || currentConfig.bitrate) / 1000} kbps`, inline: false }, { - name: 'Common Values', - value: '• 64 kbps - Normal quality\n• 96 kbps - Good quality\n• 128 kbps - High quality\n• 256 kbps - Very high quality', + name: 'Übliche Werte', + value: '• 64 kbps - Normale Qualität\n• 96 kbps - Gute Qualität\n• 128 kbps - Hohe Qualität\n• 256 kbps - Sehr hohe Qualität', inline: false } ) .setColor(getColor('info')) - .setFooter({ text: 'Type the new bitrate in the chat below' }); + .setFooter({ text: 'Schreib die neue Bitrate unten in den Chat' }); await interaction.followUp({ embeds: [embed], flags: MessageFlags.Ephemeral }); @@ -366,7 +366,7 @@ async function handleBitrateChange(interaction: StringSelectMenuInteraction, tri if (newBitrate < 8 || newBitrate > 384) { await replyUserError(interaction, { type: ErrorTypes.VALIDATION, - message: 'Bitrate must be between 8 and 384 kbps.' + message: 'Die Bitrate muss zwischen 8 und 384 kbps liegen.' }); return; } @@ -382,7 +382,7 @@ async function handleBitrateChange(interaction: StringSelectMenuInteraction, tri }); await interaction.followUp({ - embeds: [successEmbed('Bitrate Updated', `Bitrate changed to ${newBitrate} kbps`)], + embeds: [successEmbed('Bitrate aktualisiert', `Bitrate geändert zu ${newBitrate} kbps`)], flags: MessageFlags.Ephemeral, }); @@ -395,8 +395,8 @@ async function handleBitrateChange(interaction: StringSelectMenuInteraction, tri } const errorMessage = error instanceof NexoBotError - ? error.userMessage || 'Could not update the bitrate.' - : 'Could not update the bitrate.'; + ? error.userMessage || 'Die Bitrate konnte nicht aktualisiert werden.' + : 'Die Bitrate konnte nicht aktualisiert werden.'; await replyUserError(interaction, { type: ErrorTypes.CONFIGURATION, @@ -409,7 +409,7 @@ async function handleBitrateChange(interaction: StringSelectMenuInteraction, tri if (reason === 'time') { replyUserError(interaction, { type: ErrorTypes.RATE_LIMIT, - message: 'No valid response received. Update cancelled.' + message: 'Keine gültige Antwort erhalten. Aktualisierung abgebrochen.' }).catch(() => {}); } }); @@ -417,19 +417,19 @@ async function handleBitrateChange(interaction: StringSelectMenuInteraction, tri async function handleRemoveTrigger(interaction: StringSelectMenuInteraction, triggerChannel: VoiceChannel, currentConfig: JoinToCreateConfig, client: NexoClient) { const embed = new EmbedBuilder() - .setTitle('Remove Trigger Channel') - .setDescription(`Are you sure you want to remove ${triggerChannel} from the Join to Create system?`) + .setTitle('Trigger-Kanal entfernen') + .setDescription(`Willst du ${triggerChannel} wirklich aus dem Join-to-Create-System entfernen?`) .setColor('#ff6600') - .setFooter({ text: 'This action cannot be undone' }); + .setFooter({ text: 'Das kann nicht rückgängig gemacht werden' }); const row = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`confirm_remove_${triggerChannel.id}`) - .setLabel('Remove Channel') + .setLabel('Kanal entfernen') .setStyle(ButtonStyle.Danger), new ButtonBuilder() .setCustomId(`cancel_remove_${triggerChannel.id}`) - .setLabel('Cancel') + .setLabel('Abbrechen') .setStyle(ButtonStyle.Secondary) ); @@ -456,13 +456,13 @@ async function handleRemoveTrigger(interaction: StringSelectMenuInteraction, tri if (success) { await buttonInteraction.followUp({ - embeds: [successEmbed('Channel Removed', `${triggerChannel} has been removed from the Join to Create system.`)], + embeds: [successEmbed('Kanal entfernt', `${triggerChannel} wurde aus dem Join-to-Create-System entfernt.`)], flags: MessageFlags.Ephemeral, }); } else { await replyUserError(buttonInteraction, { type: ErrorTypes.CONFIGURATION, - message: 'Could not remove the trigger channel.' + message: 'Der Trigger-Kanal konnte nicht entfernt werden.' }); } } catch (error) { @@ -473,8 +473,8 @@ async function handleRemoveTrigger(interaction: StringSelectMenuInteraction, tri } const errorMessage = error instanceof NexoBotError - ? error.userMessage || 'An error occurred while removing the trigger channel.' - : 'An error occurred while removing the trigger channel.'; + ? error.userMessage || 'Beim Entfernen des Trigger-Kanals ist ein Fehler aufgetreten.' + : 'Beim Entfernen des Trigger-Kanals ist ein Fehler aufgetreten.'; await replyUserError(buttonInteraction, { type: ErrorTypes.CONFIGURATION, @@ -483,7 +483,7 @@ async function handleRemoveTrigger(interaction: StringSelectMenuInteraction, tri } } else { await buttonInteraction.followUp({ - embeds: [successEmbed('Cancelled', 'Channel removal has been cancelled.')], + embeds: [successEmbed('Abgebrochen', 'Das Entfernen des Kanals wurde abgebrochen.')], flags: MessageFlags.Ephemeral, }); } @@ -493,7 +493,7 @@ async function handleRemoveTrigger(interaction: StringSelectMenuInteraction, tri if (reason === 'time') { replyUserError(interaction, { type: ErrorTypes.RATE_LIMIT, - message: 'No response received. Removal cancelled.' + message: 'Keine Antwort erhalten. Entfernen abgebrochen.' }).catch(() => {}); } }); @@ -503,23 +503,23 @@ async function handleViewSettings(interaction: StringSelectMenuInteraction, trig const channelConfig: Partial = currentConfig.channelOptions?.[triggerChannel.id] || {}; const embed = new EmbedBuilder() - .setTitle('Current Settings') - .setDescription(`Configuration for ${triggerChannel}`) + .setTitle('Aktuelle Einstellungen') + .setDescription(`Konfiguration für ${triggerChannel}`) .setColor(getColor('info')) .addFields( { - name: 'Trigger Channel', + name: 'Trigger-Kanal', value: `${triggerChannel} (${triggerChannel.id})`, inline: false }, { - name: 'Channel Name Template', + name: 'Kanalnamen-Vorlage', value: `\`${channelConfig.nameTemplate || currentConfig.channelNameTemplate}\``, inline: false }, { - name: 'User Limit', - value: `${channelConfig.userLimit || currentConfig.userLimit === 0 ? 'No limit' : (channelConfig.userLimit || currentConfig.userLimit) + ' users'}`, + name: 'Nutzerlimit', + value: `${channelConfig.userLimit || currentConfig.userLimit === 0 ? 'Kein Limit' : (channelConfig.userLimit || currentConfig.userLimit) + ' Nutzer'}`, inline: true }, { @@ -528,17 +528,17 @@ async function handleViewSettings(interaction: StringSelectMenuInteraction, trig inline: true }, { - name: 'Category', - value: currentConfig.categoryId ? `<#${currentConfig.categoryId}>` : 'Not set', + name: 'Kategorie', + value: currentConfig.categoryId ? `<#${currentConfig.categoryId}>` : 'Nicht festgelegt', inline: true }, { - name: 'System Status', - value: currentConfig.enabled ? '✅ Enabled' : '❌ Disabled', + name: 'Systemstatus', + value: currentConfig.enabled ? '✅ Aktiviert' : '❌ Deaktiviert', inline: true }, { - name: 'Active Temporary Channels', + name: 'Aktive temporäre Kanäle', value: Object.keys(currentConfig.temporaryChannels || {}).length.toString(), inline: true } diff --git a/src/commands/JoinToCreate/modules/setup.ts b/src/commands/JoinToCreate/modules/setup.ts index db866645ea..d83b46ba59 100644 --- a/src/commands/JoinToCreate/modules/setup.ts +++ b/src/commands/JoinToCreate/modules/setup.ts @@ -10,14 +10,14 @@ import type { SubcommandModule } from '../../../types/index.js'; export default { async execute(interaction, config, client) { const category = interaction.options.getChannel('category') as CategoryChannel | null; - const nameTemplate = interaction.options.getString('channel_name') || "{username}'s Room"; + const nameTemplate = interaction.options.getString('channel_name') || "Raum von {username}"; const userLimit = interaction.options.getInteger('user_limit') || 0; const bitrate = interaction.options.getInteger('bitrate') || 64; const guildId = interaction.guild!.id; try { const triggerChannel = await interaction.guild!.channels.create({ - name: 'Join to Create', + name: 'Join-to-Create', type: ChannelType.GuildVoice, parent: category?.id, userLimit: userLimit, @@ -40,14 +40,14 @@ export default { await addJoinToCreateTrigger(client, guildId, triggerChannel.id, triggerOptions); 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` + + '✅ Join-to-Create eingerichtet', + `Trigger-Kanal erstellt: ${triggerChannel}\n\n` + + `**Einstellungen:**\n` + + `• Namensvorlage für temporäre Kanäle: \`${nameTemplate}\`\n` + + `• Nutzerlimit: ${userLimit === 0 ? 'Kein Limit' : userLimit + ' Nutzer'}\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.` + `${category ?`• Kategorie: ${category.name}`: '• Kategorie: Keine (oberste Ebene)'}\n\n` + + `Wenn Nutzer diesem Kanal beitreten, wird für sie ein temporärer Sprachkanal erstellt.` ); try { @@ -75,7 +75,7 @@ export default { throw new NexoBotError( `Setup failed: ${(error as Error).message}`, ErrorTypes.DISCORD_API, - 'Failed to set up Join to Create system.' + 'Join-to-Create konnte nicht eingerichtet werden.' ); } } diff --git a/src/commands/Leveling/level.ts b/src/commands/Leveling/level.ts index 89cee7f6dd..e6542c5530 100644 --- a/src/commands/Leveling/level.ts +++ b/src/commands/Leveling/level.ts @@ -7,7 +7,7 @@ import levelsetCommand from './modules/levelset.js'; export default createRouterCommand({ name: 'level', - description: 'Configure leveling and manage member levels', + description: 'Leveling konfigurieren und Level von Mitgliedern verwalten', category: 'Leveling', defaultMemberPermissions: PermissionFlagsBits.ManageGuild, routes: [ diff --git a/src/commands/Leveling/modules/leaderboard.ts b/src/commands/Leveling/modules/leaderboard.ts index 623d57200a..c1d5a38e79 100644 --- a/src/commands/Leveling/modules/leaderboard.ts +++ b/src/commands/Leveling/modules/leaderboard.ts @@ -8,7 +8,7 @@ import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('leaderboard') - .setDescription("Shows the server's level leaderboard") + .setDescription('Zeigt die Level-Bestenliste des Servers') .setDMPermission(false), category: 'Leveling', @@ -22,7 +22,7 @@ export default { embeds: [ new EmbedBuilder() .setColor('#f1c40f') - .setDescription('The leveling system is currently disabled on this server.') + .setDescription('Das Leveling-System ist auf diesem Server derzeit deaktiviert.') ], flags: MessageFlags.Ephemeral }); @@ -35,14 +35,14 @@ export default { throw new NexoBotError( 'No leaderboard data found', ErrorTypes.DATABASE, - 'No level data found yet. Start chatting to gain XP!' + 'Noch keine Level-Daten vorhanden. Schreib Nachrichten, um XP zu sammeln!' ); } const embed = new EmbedBuilder() - .setTitle('Level Leaderboard') + .setTitle('Level-Bestenliste') .setColor('#2ecc71') - .setDescription("Top 10 most active members in this server:") + .setDescription('Die 10 aktivsten Mitglieder auf diesem Server:') .setTimestamp(); const leaderboardText = await Promise.all( @@ -58,15 +58,15 @@ export default { else if (index === 2) rankPrefix = '🥉'; else rankPrefix = `**${index + 1}.**`; - return `${rankPrefix} ${userMention} - Level ${user.level} (${user.xp}/${xpForNextLevel} XP)`; + return `${rankPrefix} ${userMention} – Level ${user.level} (${user.xp}/${xpForNextLevel} XP)`; } catch { - return `**${index + 1}.** Error loading user ${user.userId}`; + return `**${index + 1}.** Fehler beim Laden von Nutzer ${user.userId}`; } }) ); embed.addFields({ - name: 'Rankings', + name: 'Rangliste', value: leaderboardText.join('\n') }); diff --git a/src/commands/Leveling/modules/level.ts b/src/commands/Leveling/modules/level.ts index 6845fda10c..10283ce523 100644 --- a/src/commands/Leveling/modules/level.ts +++ b/src/commands/Leveling/modules/level.ts @@ -13,24 +13,24 @@ import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('level') - .setDescription('Manage the leveling system') + .setDescription('Das Leveling-System verwalten') .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .setDMPermission(false) .addSubcommand((subcommand) => subcommand .setName('setup') - .setDescription('Set up the leveling system — this also enables it') + .setDescription('Das Leveling-System einrichten – dabei wird es auch aktiviert') .addChannelOption((option) => option .setName('channel') - .setDescription('Channel to send level-up notifications in') + .setDescription('Kanal für Level-up-Benachrichtigungen') .addChannelTypes(ChannelType.GuildText) .setRequired(true), ) .addIntegerOption((option) => option .setName('xp_min') - .setDescription('Minimum XP awarded per message (default: 15)') + .setDescription('Minimale XP pro Nachricht (Standard: 15)') .setMinValue(1) .setMaxValue(500) .setRequired(false), @@ -38,7 +38,7 @@ export default { .addIntegerOption((option) => option .setName('xp_max') - .setDescription('Maximum XP awarded per message (default: 25)') + .setDescription('Maximale XP pro Nachricht (Standard: 25)') .setMinValue(1) .setMaxValue(500) .setRequired(false), @@ -47,7 +47,7 @@ export default { option .setName('message') .setDescription( - 'Level-up message. Use {user} and {level} as placeholders (default provided)', + 'Level-up-Nachricht. Platzhalter: {user} und {level} (Standard vorhanden)', ) .setMaxLength(500) .setRequired(false), @@ -55,7 +55,7 @@ export default { .addIntegerOption((option) => option .setName('xp_cooldown') - .setDescription('Seconds between XP grants per user (default: 60)') + .setDescription('Sekunden zwischen XP-Vergaben pro Nutzer (Standard: 60)') .setMinValue(0) .setMaxValue(3600) .setRequired(false), @@ -64,7 +64,7 @@ export default { .addSubcommand((subcommand) => subcommand .setName('dashboard') - .setDescription('Open the interactive leveling configuration dashboard'), + .setDescription('Das interaktive Leveling-Dashboard öffnen'), ), category: 'Leveling', @@ -75,7 +75,7 @@ export default { 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.' }); + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Du brauchst die Berechtigung **Server verwalten**, um diesen Command zu nutzen.' }); } const subcommand = interaction.options.getSubcommand(); @@ -90,25 +90,25 @@ export default { const xpMax = interaction.options.getInteger('xp_max') ?? 25; const message = interaction.options.getString('message') ?? - '{user} has leveled up to level {level}!'; + '{user} ist auf Level {level} aufgestiegen!'; 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}**).` }); + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: `Die minimalen XP (**${xpMin}**) dürfen nicht größer sein als die maximalen XP (**${xpMax}**).` }); } if (!botHasPermission(channel, ['SendMessages', 'EmbedLinks'])) { throw new NexoBotError( 'Bot missing permissions in the specified channel', ErrorTypes.PERMISSION, - `I need **SendMessages** and **EmbedLinks** permissions in ${channel} to send level-up notifications.`, + `Ich brauche die Berechtigungen **Nachrichten senden** und **Links einbetten** in ${channel}, um Level-up-Benachrichtigungen zu senden.`, ); } 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.` }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `Das Leveling-System ist auf diesem Server bereits eingerichtet (Level-up-Benachrichtigungen gehen an <#${existingConfig.levelUpChannel}>).\n\nNutze \`/level dashboard\`, um die Einstellungen anzupassen.` }); } const newConfig = { @@ -135,14 +135,14 @@ export default { return await InteractionHelper.safeEditReply(interaction, { embeds: [ createEmbed({ - title: 'Leveling System Set Up', + title: 'Leveling-System eingerichtet', 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.`, + `Das Leveling-System ist jetzt **aktiviert** und einsatzbereit.\n\n` + + `**Level-up-Kanal:** ${channel}\n` + + `**XP pro Nachricht:** ${xpMin} – ${xpMax}\n` + + `**XP-Cooldown:** ${xpCooldown}s\n` + + `**Level-up-Nachricht:** \`${message}\`\n\n` + + `Mit \`/level dashboard\` kannst du diese Einstellungen jederzeit anpassen.`, color: 'success', }), ], diff --git a/src/commands/Leveling/modules/level_dashboard.ts b/src/commands/Leveling/modules/level_dashboard.ts index 99cea0fd6b..41b5ec4c6a 100644 --- a/src/commands/Leveling/modules/level_dashboard.ts +++ b/src/commands/Leveling/modules/level_dashboard.ts @@ -28,87 +28,87 @@ import type { GuildLevelingConfig } from '../../../services/leveling/leveling.js import type { NexoClient, SubcommandModule } from '../../../types/index.js'; function buildDashboardEmbed(cfg: GuildLevelingConfig, guild: Guild) { - const channel = cfg.levelUpChannel ? `<#${cfg.levelUpChannel}>` : '`Not set`'; + const channel = cfg.levelUpChannel ? `<#${cfg.levelUpChannel}>` : '`Nicht gesetzt`'; const xpMin = cfg.xpRange?.min ?? cfg.xpPerMessage?.min ?? 15; const xpMax = cfg.xpRange?.max ?? cfg.xpPerMessage?.max ?? 25; const cooldown = cfg.xpCooldown ?? 60; - const rawMsg = cfg.levelUpMessage || '{user} has leveled up to level {level}!'; + const rawMsg = cfg.levelUpMessage || '{user} ist auf Level {level} aufgestiegen!'; const msgPreview = `\`${rawMsg.length > 60 ? rawMsg.substring(0, 60) + '…' : rawMsg}\``; const rewards = cfg.roleRewards ?? {}; const rewardEntries = Object.entries(rewards).sort(([a], [b]) => Number(a) - Number(b)); const rewardsValue = rewardEntries.length > 0 ? rewardEntries.map(([lvl, roleId]) => `Level **${lvl}** → <@&${roleId}>`).join('\n') - : '`None configured`'; + : '`Keine eingerichtet`'; const ignoredChannels = cfg.ignoredChannels ?? []; const ignoredRoles = cfg.ignoredRoles ?? []; - const ignoredChValue = ignoredChannels.length > 0 ? ignoredChannels.map(id => `<#${id}>`).join(',') : '`None`'; - const ignoredRoValue = ignoredRoles.length > 0 ? ignoredRoles.map(id => `<@&${id}>`).join(',') : '`None`'; + const ignoredChValue = ignoredChannels.length > 0 ? ignoredChannels.map(id => `<#${id}>`).join(',') : '`Keine`'; + const ignoredRoValue = ignoredRoles.length > 0 ? ignoredRoles.map(id => `<@&${id}>`).join(',') : '`Keine`'; return new EmbedBuilder() - .setTitle('⚡ Leveling System Dashboard') - .setDescription(`Manage leveling settings for **${guild.name}**.\nSelect an option below to modify a setting.`) + .setTitle('⚡ Leveling-Dashboard') + .setDescription(`Verwalte die Leveling-Einstellungen für **${guild.name}**.\nWähle unten eine Option, um eine Einstellung zu ändern.`) .setColor(getColor('info')) .addFields( - { name: 'Level-up Channel', value: channel, inline: true }, - { name: 'System Status', value: cfg.enabled ? '**Enabled**' : '**Disabled**', inline: true }, - { name: 'Announcements', value: cfg.announceLevelUp !== false ? '**Enabled**' : '**Disabled**', inline: true }, - { name: 'XP per Message', value: `\`${xpMin} – ${xpMax}\``, inline: true }, - { name: 'XP Cooldown', value: `\`${cooldown}s\``, inline: true }, + { name: 'Level-up-Kanal', value: channel, inline: true }, + { name: 'Systemstatus', value: cfg.enabled ? '**Aktiviert**' : '**Deaktiviert**', inline: true }, + { name: 'Ankündigungen', value: cfg.announceLevelUp !== false ? '**Aktiviert**' : '**Deaktiviert**', inline: true }, + { name: 'XP pro Nachricht', value: `\`${xpMin} – ${xpMax}\``, inline: true }, + { name: 'XP-Cooldown', value: `\`${cooldown}s\``, inline: true }, { name: '\u200B', value: '\u200B', inline: true }, - { name: 'Level-up Message', value: msgPreview, inline: false }, - { name: 'Role Rewards', value: rewardsValue, inline: false }, - { name: 'Ignored Channels', value: ignoredChValue, inline: true }, - { name: 'Ignored Roles', value: ignoredRoValue, inline: true }, + { name: 'Level-up-Nachricht', value: msgPreview, inline: false }, + { name: 'Rollen-Belohnungen', value: rewardsValue, inline: false }, + { name: 'Ignorierte Kanäle', value: ignoredChValue, inline: true }, + { name: 'Ignorierte Rollen', value: ignoredRoValue, inline: true }, ) - .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) + .setFooter({ text: 'Das Dashboard schließt sich nach 10 Minuten Inaktivität' }) .setTimestamp(); } function buildSelectMenu(guildId: string) { return new StringSelectMenuBuilder() .setCustomId(`level_cfg_${guildId}`) - .setPlaceholder('Select a setting to configure...') + .setPlaceholder('Wähle eine Einstellung...') .addOptions( new StringSelectMenuOptionBuilder() - .setLabel('Change Level-up Channel') - .setDescription('Set the channel where level-up notifications are sent') + .setLabel('Level-up-Kanal ändern') + .setDescription('Lege fest, in welchem Kanal Level-up-Benachrichtigungen gesendet werden') .setValue('channel') .setEmoji('📢'), new StringSelectMenuOptionBuilder() - .setLabel('Edit Level-up Message') - .setDescription('Customise the message shown when a user levels up') + .setLabel('Level-up-Nachricht bearbeiten') + .setDescription('Passe die Nachricht an, die beim Level-up eines Nutzers erscheint') .setValue('message') .setEmoji('💬'), new StringSelectMenuOptionBuilder() - .setLabel('Set XP Range') - .setDescription('Set the minimum and maximum XP rewarded per message') + .setLabel('XP-Bereich festlegen') + .setDescription('Lege die minimalen und maximalen XP pro Nachricht fest') .setValue('xp_range') .setEmoji('🎲'), new StringSelectMenuOptionBuilder() - .setLabel('Set XP Cooldown') - .setDescription('Seconds between XP grants for the same user') + .setLabel('XP-Cooldown festlegen') + .setDescription('Sekunden zwischen XP-Vergaben für denselben Nutzer') .setValue('xp_cooldown') .setEmoji('⏱️'), new StringSelectMenuOptionBuilder() - .setLabel('Add Role Reward') - .setDescription('Award a role when a user reaches a specific level') + .setLabel('Rollen-Belohnung hinzufügen') + .setDescription('Vergib eine Rolle, wenn ein Nutzer ein bestimmtes Level erreicht') .setValue('role_reward_add') .setEmoji('🏆'), new StringSelectMenuOptionBuilder() - .setLabel('Remove Role Reward') - .setDescription('Remove a role reward from a specific level') + .setLabel('Rollen-Belohnung entfernen') + .setDescription('Entferne die Rollen-Belohnung eines bestimmten Levels') .setValue('role_reward_remove') .setEmoji('\ud83d\uddd1\ufe0f'), new StringSelectMenuOptionBuilder() - .setLabel('Ignored Channels') - .setDescription('Toggle channels where XP will not be awarded') + .setLabel('Ignorierte Kanäle') + .setDescription('Kanäle umschalten, in denen keine XP vergeben werden') .setValue('ignore_channels') .setEmoji('\ud83d\udeab'), new StringSelectMenuOptionBuilder() - .setLabel('Ignored Roles') - .setDescription('Toggle roles that will not receive XP') + .setLabel('Ignorierte Rollen') + .setDescription('Rollen umschalten, die keine XP erhalten') .setValue('ignore_roles') .setEmoji('\ud83d\udeab'), ); @@ -120,7 +120,7 @@ function buildButtonRow(cfg: GuildLevelingConfig, guildId: string, disabled = fa return new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`level_cfg_toggle_announce_${guildId}`) - .setLabel('Announcements') + .setLabel('Ankündigungen') .setStyle(announceOn ? ButtonStyle.Success : ButtonStyle.Danger) .setEmoji('📣') .setDisabled(disabled), @@ -155,7 +155,7 @@ export default { throw new NexoBotError( 'Leveling system not configured', ErrorTypes.CONFIGURATION, - 'The leveling system has not been set up yet. Run `/level setup` first to configure it.', + 'Das Leveling-System ist noch nicht eingerichtet. Führe zuerst `/level setup` aus.', ); } @@ -209,8 +209,8 @@ export default { await btnInteraction.followUp({ embeds: [ successEmbed( - '✅ Announcements Updated', - `Level-up announcements are now **${cfg.announceLevelUp ? 'enabled' : 'disabled'}**.`, + '✅ Ankündigungen aktualisiert', + `Level-up-Ankündigungen sind jetzt **${cfg.announceLevelUp ? 'aktiviert' : 'deaktiviert'}**.`, ), ], flags: MessageFlags.Ephemeral, @@ -222,8 +222,8 @@ export default { await btnInteraction.followUp({ embeds: [ successEmbed( - '✅ System Updated', - `The leveling system is now **${cfg.enabled ? 'enabled' : 'disabled'}**.${!cfg.enabled ? '\nUsers will not earn XP until the system is re-enabled.' : ''}`, + '✅ System aktualisiert', + `Das Leveling-System ist jetzt **${cfg.enabled ? 'aktiviert' : 'deaktiviert'}**.${!cfg.enabled ? '\nNutzer erhalten keine XP, bis das System wieder aktiviert wird.' : ''}`, ), ], flags: MessageFlags.Ephemeral, @@ -239,7 +239,7 @@ export default { throw new NexoBotError( `Level dashboard failed: ${error instanceof Error ? error.message : String(error)}`, ErrorTypes.UNKNOWN, - 'Failed to open the leveling dashboard.', + 'Das Leveling-Dashboard konnte nicht geöffnet werden.', ); } }, @@ -248,23 +248,23 @@ export default { async function handleRoleRewardAdd(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, cfg: GuildLevelingConfig, guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId(`level_cfg_role_reward_add_${guildId}`) - .setTitle('🏆 Add Role Reward'); + .setTitle('🏆 Rollen-Belohnung hinzufügen'); const roleSelect = new RoleSelectMenuBuilder() .setCustomId('reward_role') - .setPlaceholder('Select a role to award...') + .setPlaceholder('Wähle eine Rolle als Belohnung...') .setMinValues(1) .setMaxValues(1) .setRequired(true); const roleLabel = new LabelBuilder() - .setLabel('Role to Award') - .setDescription('This role will be given when the user reaches the level') + .setLabel('Belohnungsrolle') + .setDescription('Diese Rolle erhält der Nutzer, wenn er das Level erreicht') .setRoleSelectMenuComponent(roleSelect); const levelInput = new TextInputBuilder() .setCustomId('reward_level') - .setLabel('Level required (1–500)') + .setLabel('Benötigtes Level (1–500)') .setStyle(TextInputStyle.Short) .setPlaceholder('10') .setMaxLength(3) @@ -289,7 +289,7 @@ async function handleRoleRewardAdd(selectInteraction: StringSelectMenuInteractio const level = parseInt(rawLevel, 10); if (isNaN(level) || level < 1 || level > 500) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Level must be a whole number between **1** and **500**.' }); + await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Das Level muss eine ganze Zahl zwischen **1** und **500** sein.' }); return; } @@ -300,7 +300,7 @@ async function handleRoleRewardAdd(selectInteraction: StringSelectMenuInteractio await saveLevelingConfig(client, guildId, cfg); await submitted.reply({ - embeds: [successEmbed('Role Reward Added', `<@&${roleId}> will now be awarded at level **${level}**.`)], + embeds: [successEmbed('Rollen-Belohnung hinzugefügt', `<@&${roleId}> wird jetzt ab Level **${level}** vergeben.`)], flags: MessageFlags.Ephemeral, }); @@ -315,25 +315,25 @@ async function handleRoleRewardRemove(selectInteraction: StringSelectMenuInterac await selectInteraction.deferUpdate(); await replyUserError(selectInteraction, { type: ErrorTypes.USER_INPUT, - message: 'There are no role rewards configured to remove.', + message: 'Es sind keine Rollen-Belohnungen eingerichtet, die entfernt werden können.', }); return; } const modal = new ModalBuilder() .setCustomId(`level_cfg_role_reward_remove_${guildId}`) - .setTitle('🗑️ Remove Role Reward'); + .setTitle('🗑️ Rollen-Belohnung entfernen'); const infoInput = new TextInputBuilder() .setCustomId('current_rewards') - .setLabel('Current rewards (read-only)') + .setLabel('Aktuelle Belohnungen (nur lesen)') .setStyle(TextInputStyle.Paragraph) .setValue(entries.map(([lvl, roleId]) => `Level ${lvl}: <@&${roleId}>`).join('\n')) .setRequired(false); const levelInput = new TextInputBuilder() .setCustomId('remove_level') - .setLabel('Level to remove reward from') + .setLabel('Level, dessen Belohnung entfernt wird') .setStyle(TextInputStyle.Short) .setValue(entries[0][0]) .setMaxLength(3) @@ -360,7 +360,7 @@ async function handleRoleRewardRemove(selectInteraction: StringSelectMenuInterac 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: `Für Level **${rawLevel}** ist keine Rollen-Belohnung eingerichtet.` }); return; } @@ -368,7 +368,7 @@ async function handleRoleRewardRemove(selectInteraction: StringSelectMenuInterac await saveLevelingConfig(client, guildId, cfg); await submitted.reply({ - embeds: [successEmbed('Role Reward Removed', `The role reward for level **${level}** has been removed.`)], + embeds: [successEmbed('Rollen-Belohnung entfernt', `Die Rollen-Belohnung für Level **${level}** wurde entfernt.`)], flags: MessageFlags.Ephemeral, }); @@ -378,19 +378,19 @@ async function handleRoleRewardRemove(selectInteraction: StringSelectMenuInterac async function handleChannel(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, cfg: GuildLevelingConfig, guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId(`level_cfg_channel_modal_${guildId}`) - .setTitle('\ud83d\udce2 Change Level-up Channel'); + .setTitle('\ud83d\udce2 Level-up-Kanal ändern'); const channelSelect = new ChannelSelectMenuBuilder() .setCustomId('levelup_channel') - .setPlaceholder('Select a text channel...') + .setPlaceholder('Wähle einen Textkanal...') .setMinValues(1) .setMaxValues(1) .addChannelTypes(ChannelType.GuildText) .setRequired(true); const channelLabel = new LabelBuilder() - .setLabel('Level-up Channel') - .setDescription('Channel where level-up notifications will be sent') + .setLabel('Level-up-Kanal') + .setDescription('Kanal, in dem Level-up-Benachrichtigungen gesendet werden') .setChannelSelectMenuComponent(channelSelect); modal.addLabelComponents(channelLabel); @@ -410,7 +410,7 @@ async function handleChannel(selectInteraction: StringSelectMenuInteraction, roo 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: `Ich brauche die Berechtigungen **Nachrichten senden** und **Links einbetten** in ${channel}, um Level-up-Benachrichtigungen zu senden.` }); return; } @@ -418,7 +418,7 @@ async function handleChannel(selectInteraction: StringSelectMenuInteraction, roo await saveLevelingConfig(client, guildId, cfg); await submitted.reply({ - embeds: [successEmbed('\u2705 Channel Updated', `Level-up notifications will now be sent in ${channel ??`<#${channelId}>`}.`)], + embeds: [successEmbed('\u2705 Kanal aktualisiert', `Level-up-Benachrichtigungen werden jetzt in ${channel ??`<#${channelId}>`} gesendet.`)], flags: MessageFlags.Ephemeral, }); @@ -428,19 +428,19 @@ async function handleChannel(selectInteraction: StringSelectMenuInteraction, roo async function handleIgnoreChannels(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, cfg: GuildLevelingConfig, guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId(`level_cfg_ignore_channels_${guildId}`) - .setTitle('\ud83d\udeab Ignored Channels'); + .setTitle('\ud83d\udeab Ignorierte Kanäle'); const channelSelect = new ChannelSelectMenuBuilder() .setCustomId('ignore_channel') - .setPlaceholder('Select channels to toggle...') + .setPlaceholder('Wähle Kanäle zum Umschalten...') .setMinValues(1) .setMaxValues(10) .addChannelTypes(ChannelType.GuildText) .setRequired(true); const channelLabel = new LabelBuilder() - .setLabel('Toggle Ignored Channels') - .setDescription('Selected channels will be toggled — XP will not be awarded in them') + .setLabel('Ignorierte Kanäle umschalten') + .setDescription('Ausgewählte Kanäle werden umgeschaltet – in ignorierten Kanälen gibt es keine XP') .setChannelSelectMenuComponent(channelSelect); modal.addLabelComponents(channelLabel); @@ -472,10 +472,10 @@ async function handleIgnoreChannels(selectInteraction: StringSelectMenuInteracti const list = cfg.ignoredChannels.length > 0 ? cfg.ignoredChannels.map(id => `<#${id}>`).join(',') - : '`None`'; + : '`Keine`'; await submitted.reply({ - embeds: [successEmbed('\u2705 Ignored Channels Updated', `XP will not be awarded in: ${list}`)], + embeds: [successEmbed('\u2705 Ignorierte Kanäle aktualisiert', `In diesen Kanälen gibt es keine XP: ${list}`)], flags: MessageFlags.Ephemeral, }); @@ -485,18 +485,18 @@ async function handleIgnoreChannels(selectInteraction: StringSelectMenuInteracti async function handleIgnoreRoles(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, cfg: GuildLevelingConfig, guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId(`level_cfg_ignore_roles_${guildId}`) - .setTitle('\ud83d\udeab Ignored Roles'); + .setTitle('\ud83d\udeab Ignorierte Rollen'); const roleSelect = new RoleSelectMenuBuilder() .setCustomId('ignore_role') - .setPlaceholder('Select roles to toggle...') + .setPlaceholder('Wähle Rollen zum Umschalten...') .setMinValues(1) .setMaxValues(10) .setRequired(true); const roleLabel = new LabelBuilder() - .setLabel('Toggle Ignored Roles') - .setDescription('Selected roles will be toggled — members with them will not earn XP') + .setLabel('Ignorierte Rollen umschalten') + .setDescription('Ausgewählte Rollen werden umgeschaltet – Mitglieder mit ignorierten Rollen erhalten keine XP') .setRoleSelectMenuComponent(roleSelect); modal.addLabelComponents(roleLabel); @@ -528,10 +528,10 @@ async function handleIgnoreRoles(selectInteraction: StringSelectMenuInteraction, const list = cfg.ignoredRoles.length > 0 ? cfg.ignoredRoles.map(id => `<@&${id}>`).join(',') - : '`None`'; + : '`Keine`'; await submitted.reply({ - embeds: [successEmbed('\u2705 Ignored Roles Updated', `These roles will not earn XP: ${list}`)], + embeds: [successEmbed('\u2705 Ignorierte Rollen aktualisiert', `Diese Rollen erhalten keine XP: ${list}`)], flags: MessageFlags.Ephemeral, }); @@ -541,18 +541,18 @@ async function handleIgnoreRoles(selectInteraction: StringSelectMenuInteraction, async function handleMessage(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, cfg: GuildLevelingConfig, guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId('level_cfg_message') - .setTitle('💬 Edit Level-up Message') + .setTitle('💬 Level-up-Nachricht bearbeiten') .addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('message_input') - .setLabel('Message ({user} and {level} are available)') + .setLabel('Nachricht ({user} und {level} möglich)') .setStyle(TextInputStyle.Paragraph) - .setValue(cfg.levelUpMessage || '{user} has leveled up to level {level}!') + .setValue(cfg.levelUpMessage || '{user} ist auf Level {level} aufgestiegen!') .setMaxLength(500) .setMinLength(1) .setRequired(true) - .setPlaceholder('{user} has leveled up to level {level}!'), + .setPlaceholder('{user} ist auf Level {level} aufgestiegen!'), ), ); @@ -579,13 +579,13 @@ async function handleMessage(selectInteraction: StringSelectMenuInteraction, roo cfg.levelUpMessage = newMessage; await saveLevelingConfig(client, guildId, cfg); - const preview = newMessage.replace('{user}', '@User').replace('{level}', '5'); + const preview = newMessage.replace('{user}', '@Nutzer').replace('{level}', '5'); await submitted.reply({ embeds: [ successEmbed( - '✅ Message Updated', - `Level-up message saved.\n**Preview:** ${preview}`, + '✅ Nachricht aktualisiert', + `Level-up-Nachricht gespeichert.\n**Vorschau:** ${preview}`, ), ], flags: MessageFlags.Ephemeral, @@ -600,12 +600,12 @@ async function handleXpRange(selectInteraction: StringSelectMenuInteraction, roo const modal = new ModalBuilder() .setCustomId('level_cfg_xp_range') - .setTitle('Set XP Range per Message') + .setTitle('XP-Bereich pro Nachricht festlegen') .addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('xp_min_input') - .setLabel('Minimum XP (1–500)') + .setLabel('Minimale XP (1–500)') .setStyle(TextInputStyle.Short) .setValue(String(currentMin)) .setMaxLength(3) @@ -616,7 +616,7 @@ async function handleXpRange(selectInteraction: StringSelectMenuInteraction, roo new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('xp_max_input') - .setLabel('Maximum XP (1–500)') + .setLabel('Maximale XP (1–500)') .setStyle(TextInputStyle.Short) .setValue(String(currentMax)) .setMaxLength(3) @@ -644,12 +644,12 @@ async function handleXpRange(selectInteraction: StringSelectMenuInteraction, roo const newMax = parseInt(rawMax, 10); if (isNaN(newMin) || isNaN(newMax) || newMin < 1 || newMax < 1 || newMin > 500 || newMax > 500) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Both XP values must be whole numbers between **1** and **500**.' }); + await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Beide XP-Werte müssen ganze Zahlen zwischen **1** und **500** sein.' }); return; } if (newMin > newMax) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Minimum XP cannot be greater than maximum XP.' }); + await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Die minimalen XP dürfen nicht größer sein als die maximalen XP.' }); return; } @@ -659,8 +659,8 @@ async function handleXpRange(selectInteraction: StringSelectMenuInteraction, roo await submitted.reply({ embeds: [ successEmbed( - '✅ XP Range Updated', - `Users will now earn between **${newMin}** and **${newMax}** XP per message.`, + '✅ XP-Bereich aktualisiert', + `Nutzer erhalten jetzt zwischen **${newMin}** und **${newMax}** XP pro Nachricht.`, ), ], flags: MessageFlags.Ephemeral, @@ -672,12 +672,12 @@ async function handleXpRange(selectInteraction: StringSelectMenuInteraction, roo async function handleXpCooldown(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, cfg: GuildLevelingConfig, guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId('level_cfg_cooldown') - .setTitle('⏱️ Set XP Cooldown') + .setTitle('⏱️ XP-Cooldown festlegen') .addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('cooldown_input') - .setLabel('Cooldown in seconds (0–3600)') + .setLabel('Cooldown in Sekunden (0–3600)') .setStyle(TextInputStyle.Short) .setValue(String(cfg.xpCooldown ?? 60)) .setMaxLength(4) @@ -703,7 +703,7 @@ async function handleXpCooldown(selectInteraction: StringSelectMenuInteraction, const newCooldown = parseInt(raw, 10); if (isNaN(newCooldown) || newCooldown < 0 || newCooldown > 3600) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Cooldown must be a whole number between **0** and **3600** seconds.' }); + await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Der Cooldown muss eine ganze Zahl zwischen **0** und **3600** Sekunden sein.' }); return; } @@ -713,8 +713,8 @@ async function handleXpCooldown(selectInteraction: StringSelectMenuInteraction, await submitted.reply({ embeds: [ successEmbed( - '✅ Cooldown Updated', - `XP cooldown set to **${newCooldown} second${newCooldown !== 1 ? 's' : ''}**.${newCooldown === 0 ? '\n> ⚠️ A cooldown of 0 means XP is granted on every message.' : ''}`, + '✅ Cooldown aktualisiert', + `XP-Cooldown auf **${newCooldown} Sekunde${newCooldown !== 1 ? 'n' : ''}** gesetzt.${newCooldown === 0 ? '\n> ⚠️ Bei einem Cooldown von 0 gibt es für jede Nachricht XP.' : ''}`, ), ], flags: MessageFlags.Ephemeral, diff --git a/src/commands/Leveling/modules/leveladd.ts b/src/commands/Leveling/modules/leveladd.ts index 381e5e1d6d..9b9c190a4f 100644 --- a/src/commands/Leveling/modules/leveladd.ts +++ b/src/commands/Leveling/modules/leveladd.ts @@ -10,17 +10,17 @@ import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('leveladd') - .setDescription('Add levels to a user') + .setDescription('Einem Nutzer Level hinzufügen') .addUserOption((option) => option .setName('user') - .setDescription('The user to add levels to') + .setDescription('Der Nutzer, der Level erhalten soll') .setRequired(true) ) .addIntegerOption((option) => option .setName('levels') - .setDescription('Number of levels to add') + .setDescription('Anzahl der Level, die hinzugefügt werden') .setRequired(true) .setMinValue(1) ) @@ -34,7 +34,7 @@ export default { const hasPermission = await checkUserPermissions( interaction, PermissionFlagsBits.ManageGuild, - 'You need ManageGuild permission to use this command.' + 'Du brauchst die Berechtigung „Server verwalten“, um diesen Command zu nutzen.' ); if (!hasPermission) return; @@ -44,7 +44,7 @@ export default { embeds: [ new EmbedBuilder() .setColor('#f1c40f') - .setDescription('The leveling system is currently disabled on this server.') + .setDescription('Das Leveling-System ist auf diesem Server derzeit deaktiviert.') ], flags: MessageFlags.Ephemeral }); @@ -59,7 +59,7 @@ export default { throw new NexoBotError( `User ${targetUser.id} not found in this guild`, ErrorTypes.USER_INPUT, - 'The specified user is not in this server.' + 'Der angegebene Nutzer ist nicht auf diesem Server.' ); } @@ -68,8 +68,8 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [ createEmbed({ - title: 'Levels Added', - description: `Successfully added ${levelsToAdd} levels to ${targetUser.tag}.\n**New Level:** ${userData.level}`, + title: 'Level hinzugefügt', + description: `${targetUser.tag} hat ${levelsToAdd} Level erhalten.\n**Neues Level:** ${userData.level}`, color: 'success' }) ] diff --git a/src/commands/Leveling/modules/levelremove.ts b/src/commands/Leveling/modules/levelremove.ts index 5483ee17d0..dc1204b623 100644 --- a/src/commands/Leveling/modules/levelremove.ts +++ b/src/commands/Leveling/modules/levelremove.ts @@ -10,17 +10,17 @@ import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('levelremove') - .setDescription('Remove levels from a user') + .setDescription('Einem Nutzer Level abziehen') .addUserOption((option) => option .setName('user') - .setDescription('The user to remove levels from') + .setDescription('Der Nutzer, dem Level abgezogen werden') .setRequired(true) ) .addIntegerOption((option) => option .setName('levels') - .setDescription('Number of levels to remove') + .setDescription('Anzahl der Level, die abgezogen werden') .setRequired(true) .setMinValue(1) ) @@ -34,7 +34,7 @@ export default { const hasPermission = await checkUserPermissions( interaction, PermissionFlagsBits.ManageGuild, - 'You need ManageGuild permission to use this command.' + 'Du brauchst die Berechtigung „Server verwalten“, um diesen Command zu nutzen.' ); if (!hasPermission) return; @@ -44,7 +44,7 @@ export default { embeds: [ new EmbedBuilder() .setColor('#f1c40f') - .setDescription('The leveling system is currently disabled on this server.') + .setDescription('Das Leveling-System ist auf diesem Server derzeit deaktiviert.') ], flags: MessageFlags.Ephemeral }); @@ -59,7 +59,7 @@ export default { throw new NexoBotError( `User ${targetUser.id} not found in this guild`, ErrorTypes.USER_INPUT, - 'The specified user is not in this server.' + 'Der angegebene Nutzer ist nicht auf diesem Server.' ); } @@ -68,7 +68,7 @@ export default { throw new NexoBotError( `User ${targetUser.id} is already at minimum level`, ErrorTypes.VALIDATION, - `${targetUser.tag} is already at level 0 and cannot have levels removed.` + `${targetUser.tag} ist bereits auf Level 0, es können keine Level abgezogen werden.` ); } @@ -77,8 +77,8 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [ createEmbed({ - title: 'Levels Removed', - description: `Successfully removed ${levelsToRemove} levels from ${targetUser.tag}.\n**New Level:** ${updatedData.level}`, + title: 'Level abgezogen', + description: `${targetUser.tag} wurden ${levelsToRemove} Level abgezogen.\n**Neues Level:** ${updatedData.level}`, color: 'success' }) ] diff --git a/src/commands/Leveling/modules/levelset.ts b/src/commands/Leveling/modules/levelset.ts index f4fc31d7c7..108454a5c9 100644 --- a/src/commands/Leveling/modules/levelset.ts +++ b/src/commands/Leveling/modules/levelset.ts @@ -10,17 +10,17 @@ import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('levelset') - .setDescription("Set a user's level to a specific value") + .setDescription('Das Level eines Nutzers auf einen bestimmten Wert setzen') .addUserOption((option) => option .setName('user') - .setDescription('The user to set the level for') + .setDescription('Der Nutzer, dessen Level gesetzt wird') .setRequired(true) ) .addIntegerOption((option) => option .setName('level') - .setDescription('The level to set') + .setDescription('Das Level, das gesetzt wird') .setRequired(true) .setMinValue(0) ) @@ -34,7 +34,7 @@ export default { const hasPermission = await checkUserPermissions( interaction, PermissionFlagsBits.ManageGuild, - 'You need ManageGuild permission to use this command.' + 'Du brauchst die Berechtigung „Server verwalten“, um diesen Command zu nutzen.' ); if (!hasPermission) return; @@ -44,7 +44,7 @@ export default { embeds: [ new EmbedBuilder() .setColor('#f1c40f') - .setDescription('The leveling system is currently disabled on this server.') + .setDescription('Das Leveling-System ist auf diesem Server derzeit deaktiviert.') ], flags: MessageFlags.Ephemeral }); @@ -59,7 +59,7 @@ export default { throw new NexoBotError( `User ${targetUser.id} not found in this guild`, ErrorTypes.USER_INPUT, - 'The specified user is not in this server.' + 'Der angegebene Nutzer ist nicht auf diesem Server.' ); } @@ -68,8 +68,8 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [ createEmbed({ - title: 'Level Set', - description: `Successfully set ${targetUser.tag}'s level to **${newLevel}**.\n**Total XP:** ${userData.totalXp}`, + title: 'Level gesetzt', + description: `Das Level von ${targetUser.tag} ist jetzt **${newLevel}**.\n**Gesamt-XP:** ${userData.totalXp}`, color: 'success' }) ] diff --git a/src/commands/Leveling/modules/rank.ts b/src/commands/Leveling/modules/rank.ts index 6c435d54a7..db563cd17b 100644 --- a/src/commands/Leveling/modules/rank.ts +++ b/src/commands/Leveling/modules/rank.ts @@ -8,11 +8,11 @@ import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('rank') - .setDescription("Check your or another user's rank and level") + .setDescription('Zeigt deinen Rang und dein Level oder die eines anderen Nutzers') .addUserOption((option) => option .setName('user') - .setDescription('The user to check the rank of') + .setDescription('Der Nutzer, dessen Rang du sehen willst') .setRequired(false) ) .setDMPermission(false), @@ -27,7 +27,7 @@ export default { embeds: [ new EmbedBuilder() .setColor('#f1c40f') - .setDescription('The leveling system is currently disabled on this server.') + .setDescription('Das Leveling-System ist auf diesem Server derzeit deaktiviert.') ], flags: MessageFlags.Ephemeral }); @@ -43,7 +43,7 @@ export default { throw new NexoBotError( `User ${targetUser.id} not found in guild`, ErrorTypes.USER_INPUT, - 'Could not find the specified user in this server.' + 'Der angegebene Nutzer wurde auf diesem Server nicht gefunden.' ); } @@ -60,7 +60,7 @@ export default { const progressBar = createProgressBar(progress, 20); const embed = new EmbedBuilder() - .setTitle(`${member.displayName}'s Rank`) + .setTitle(`Rang von ${member.displayName}`) .setThumbnail(member.displayAvatarURL()) .addFields( { @@ -74,12 +74,12 @@ export default { inline: true }, { - name: 'Total XP', + name: 'Gesamt-XP', value: safeUserData.totalXp.toString(), inline: true }, { - name: `Progress to Level ${safeUserData.level + 1}`, + name: `Fortschritt bis Level ${safeUserData.level + 1}`, value: `${progressBar} ${progress}%` } ) diff --git a/src/commands/Leveling/rank.ts b/src/commands/Leveling/rank.ts index 9cff97ddc4..a711526025 100644 --- a/src/commands/Leveling/rank.ts +++ b/src/commands/Leveling/rank.ts @@ -4,7 +4,7 @@ import leaderboardCommand from './modules/leaderboard.js'; export default createRouterCommand({ name: 'rank', - description: 'Show ranks and the level leaderboard', + description: 'Ränge und die Level-Bestenliste anzeigen', category: 'Leveling', routes: [ { legacyName: 'rank', module: rankCommand }, diff --git a/src/commands/Logging/logging.ts b/src/commands/Logging/logging.ts index a7ed74e171..4201658ce9 100644 --- a/src/commands/Logging/logging.ts +++ b/src/commands/Logging/logging.ts @@ -10,40 +10,40 @@ import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; export default { data: new SlashCommandBuilder() .setName('logging') - .setDescription('Manage server logging — channels, filters, and event categories.') + .setDescription('Server-Logging verwalten – Kanäle, Filter und Event-Kategorien.') .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .setDMPermission(false) .addSubcommand((subcommand) => subcommand .setName('dashboard') - .setDescription('Open the logging dashboard — set channels, filters, and toggle categories.'), + .setDescription('Logging-Dashboard öffnen – Kanäle, Filter und Kategorien einstellen.'), ) .addSubcommand((subcommand) => subcommand .setName('channel') - .setDescription('Quick-set a log channel without opening the dashboard.') + .setDescription('Log-Kanal schnell setzen, ohne das Dashboard zu öffnen.') .addStringOption((option) => option .setName('destination') - .setDescription('Which log destination to configure.') + .setDescription('Welches Log-Ziel du konfigurieren willst.') .setRequired(true) .addChoices( - { name: 'Audit (moderation, messages, members…)', value: 'audit' }, - { name: 'Applications', value: 'applications' }, - { name: 'Reports', value: 'reports' }, + { name: 'Audit (Moderation, Nachrichten, Mitglieder…)', value: 'audit' }, + { name: 'Bewerbungen', value: 'applications' }, + { name: 'Meldungen', value: 'reports' }, ), ) .addChannelOption((option) => option .setName('channel') - .setDescription('The text channel for logs.') + .setDescription('Der Textkanal für Logs.') .addChannelTypes(ChannelType.GuildText) .setRequired(false), ) .addBooleanOption((option) => option .setName('disable') - .setDescription('Set to True to clear this log channel.') + .setDescription('Auf True setzen, um diesen Log-Kanal zu entfernen.') .setRequired(false), ), ), @@ -60,10 +60,10 @@ export default { return await channel.execute(interaction, config, client); } - await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'This subcommand is not recognised.' }); + await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Dieser Subcommand ist unbekannt.' }); } catch (error) { logger.error('logging command error:', error); - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An unexpected error occurred.' }).catch(() => {}); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Ein unerwarteter Fehler ist aufgetreten.' }).catch(() => {}); } }, } satisfies Command; diff --git a/src/commands/Logging/modules/logging_channel.ts b/src/commands/Logging/modules/logging_channel.ts index 3d1bf93812..01c916ca29 100644 --- a/src/commands/Logging/modules/logging_channel.ts +++ b/src/commands/Logging/modules/logging_channel.ts @@ -7,9 +7,9 @@ import { logger } from '../../../utils/logger.js'; import { replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; const DESTINATION_LABELS: Record = { - audit: 'Audit Log', - applications: 'Applications', - reports: 'Reports', + audit: 'Audit-Log', + applications: 'Bewerbungen', + reports: 'Meldungen', }; export default { @@ -17,7 +17,7 @@ export default { async execute(interaction, config, client) { try { if (!(interaction.member as GuildMember).permissions.has(PermissionsBitField.Flags.ManageGuild)) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need **Manage Server** permissions to configure logging channels.' }); + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Du brauchst die Berechtigung **Server verwalten**, um Log-Kanäle einzustellen.' }); } await InteractionHelper.safeDefer(interaction, { ephemeral: true }); @@ -30,32 +30,32 @@ export default { await setLogChannel(client, interaction.guildId!, destination, null); return InteractionHelper.safeEditReply(interaction, { embeds: [successEmbed( - 'Channel Cleared', - `The **${DESTINATION_LABELS[destination]}** channel has been removed.`, + 'Kanal entfernt', + `Der Kanal für **${DESTINATION_LABELS[destination]}** wurde entfernt.`, )], }); } if (!channel || channel.type !== ChannelType.GuildText) { - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please provide a valid text channel.' }); + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Bitte gib einen gültigen Textkanal an.' }); } const botPerms = (channel as TextChannel).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: `Ich brauche **Kanal ansehen**, **Nachrichten senden** und **Links einbetten** in ${channel}.` }); } await setLogChannel(client, interaction.guildId!, destination, channel.id); return InteractionHelper.safeEditReply(interaction, { embeds: [successEmbed( - 'Channel Updated', - `**${DESTINATION_LABELS[destination]}** logs will be sent to ${channel}.\nUse \`/logging dashboard\` to toggle event categories.`, + 'Kanal aktualisiert', + `Logs für **${DESTINATION_LABELS[destination]}** werden an ${channel} gesendet.\nNutze \`/logging dashboard\`, um Event-Kategorien umzuschalten.`, )], }); } catch (error) { logger.error('logging_channel error:', error); - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Failed to update the log channel.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Der Log-Kanal konnte nicht aktualisiert werden.' }); } }, } satisfies SubcommandModule & Pick; diff --git a/src/commands/Logging/modules/logging_dashboard.ts b/src/commands/Logging/modules/logging_dashboard.ts index 037de4dafc..f49c27d01a 100644 --- a/src/commands/Logging/modules/logging_dashboard.ts +++ b/src/commands/Logging/modules/logging_dashboard.ts @@ -30,9 +30,9 @@ export function getCategoryStatus(enabledEvents: EnabledEventsMap | null | undef } async function formatChannelMention(guild: Guild, id: string | null | undefined): Promise { - if (!id) return '`Not configured`'; + if (!id) return '`Nicht konfiguriert`'; const channel = guild.channels.cache.get(id) ?? await guild.channels.fetch(id).catch(() => null); - return channel ? channel.toString() : `⚠️ Missing (${id})`; + return channel ? channel.toString() : `⚠️ Fehlt (${id})`; } function countEnabledCategories(enabledEvents: EnabledEventsMap | null | undefined, auditEnabled: boolean) { @@ -59,44 +59,44 @@ export async function buildLoggingDashboardView(interaction: BaseInteraction, cl const { enabled: enabledCount, total } = countEnabledCategories(loggingStatus.enabledEvents, auditEnabled); const embed = new EmbedBuilder() - .setTitle('📝 Logging Dashboard') - .setDescription(`Manage server logging for **${interaction.guild!.name}**. Use the menu below to configure channels, categories, and filters.`) + .setTitle('📝 Logging-Dashboard') + .setDescription(`Verwalte das Server-Logging für **${interaction.guild!.name}**. Nutze das Menü unten, um Kanäle, Kategorien und Filter einzustellen.`) .setColor(auditEnabled ? getColor('success') : getColor('warning')) .addFields( { - name: 'Logging Status', - value: auditEnabled ? '✅ Enabled' : '❌ Disabled', + name: 'Logging-Status', + value: auditEnabled ? '✅ Aktiviert' : '❌ Deaktiviert', inline: true, }, { - name: 'Event Categories', - value: auditEnabled ? `${enabledCount}/${total} enabled` : '`Logging disabled`', + name: 'Event-Kategorien', + value: auditEnabled ? `${enabledCount}/${total} aktiviert` : '`Logging deaktiviert`', inline: true, }, { - name: 'Ignore Filters', - value: `${ignore.users?.length || 0} users · ${ignore.channels?.length || 0} channels`, + name: 'Ignorierfilter', + value: `${ignore.users?.length || 0} Nutzer · ${ignore.channels?.length || 0} Kanäle`, inline: true, }, { - name: 'Log Channels', + name: 'Log-Kanäle', value: [ `**Audit:** ${auditChannel}`, - `**Applications:** ${applicationsChannel}`, - `**Reports:** ${reportsChannel}`, + `**Bewerbungen:** ${applicationsChannel}`, + `**Meldungen:** ${reportsChannel}`, ].join('\n'), inline: false, }, { - name: 'Ticket Channels (read-only)', + name: 'Ticket-Kanäle (nur lesen)', value: [ - `**Ticket Logs:** ${lifecycleChannel}`, - `**Transcripts:** ${transcriptChannel}`, + `**Ticket-Logs:** ${lifecycleChannel}`, + `**Transkripte:** ${transcriptChannel}`, ].join('\n'), inline: false, }, ) - .setFooter({ text: 'Ticket channels: configure via /ticket dashboard' }) + .setFooter({ text: 'Ticket-Kanäle: über /ticket dashboard einstellen' }) .setTimestamp(); const components = createLoggingDashboardComponents(loggingStatus.enabledEvents, auditEnabled); @@ -114,15 +114,15 @@ export async function buildLoggingCategoriesView(interaction: BaseInteraction, c }).join('\n'); const embed = new EmbedBuilder() - .setTitle('📋 Event Categories') + .setTitle('📋 Event-Kategorien') .setDescription( auditEnabled - ? 'Toggle which types of events are logged to your audit channel.' - : '⚠️ Logging is disabled. Enable it from the main dashboard to send logs.', + ? 'Leg fest, welche Event-Arten in deinem Audit-Kanal geloggt werden.' + : '⚠️ Logging ist deaktiviert. Aktivier es im Haupt-Dashboard, damit Logs gesendet werden.', ) .setColor(getColor('info')) - .addFields({ name: 'Category Status', value: categoryLines, inline: false }) - .setFooter({ text: 'Green = logging on · Red = logging off' }) + .addFields({ name: 'Kategorie-Status', value: categoryLines, inline: false }) + .setFooter({ text: 'Grün = Logging an · Rot = Logging aus' }) .setTimestamp(); const components = createLoggingCategoryViewComponents(loggingStatus.enabledEvents, auditEnabled); @@ -134,22 +134,22 @@ export async function buildLoggingFilterView(interaction: BaseInteraction, clien const ignore = loggingStatus.ignore || { users: [], channels: [] }; const userLines = (ignore.users || []).length - ? ignore.users.map((id) => `• User \`${id}\``).join('\n') - : '*No ignored users*'; + ? ignore.users.map((id) => `• Nutzer \`${id}\``).join('\n') + : '*Keine ignorierten Nutzer*'; const channelLines = (ignore.channels || []).length - ? ignore.channels.map((id) => `• Channel \`${id}\``).join('\n') - : '*No ignored channels*'; + ? ignore.channels.map((id) => `• Kanal \`${id}\``).join('\n') + : '*Keine ignorierten Kanäle*'; const embed = new EmbedBuilder() - .setTitle('🔇 Log Ignore Filters') - .setDescription('Users and channels on this list will be skipped when sending audit logs.') + .setTitle('🔇 Log-Ignorierfilter') + .setDescription('Nutzer und Kanäle auf dieser Liste werden beim Senden von Audit-Logs übersprungen.') .setColor(getColor('info')) .addFields( - { name: 'Ignored Users', value: userLines.slice(0, 1024), inline: false }, - { name: 'Ignored Channels', value: channelLines.slice(0, 1024), inline: false }, + { name: 'Ignorierte Nutzer', value: userLines.slice(0, 1024), inline: false }, + { name: 'Ignorierte Kanäle', value: channelLines.slice(0, 1024), inline: false }, ) - .setFooter({ text: 'Use the buttons below to add or remove filters' }) + .setFooter({ text: 'Nutze die Buttons unten, um Filter hinzuzufügen oder zu entfernen' }) .setTimestamp(); const components = createLoggingFilterComponents(); @@ -157,11 +157,11 @@ export async function buildLoggingFilterView(interaction: BaseInteraction, clien } export function isCategoriesView(interaction: DashboardMessageInteraction): boolean { - return interaction.message?.embeds?.[0]?.title === '📋 Event Categories'; + return interaction.message?.embeds?.[0]?.title === '📋 Event-Kategorien'; } export function isFilterView(interaction: DashboardMessageInteraction): boolean { - return interaction.message?.embeds?.[0]?.title === '🔇 Log Ignore Filters'; + return interaction.message?.embeds?.[0]?.title === '🔇 Log-Ignorierfilter'; } export async function refreshDashboardMessage(interaction: DashboardMessageInteraction, client: NexoClient): Promise { @@ -186,7 +186,7 @@ export default { async execute(interaction, config, client) { try { if (!(interaction.member as GuildMember).permissions.has(PermissionsBitField.Flags.ManageGuild)) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need **Manage Server** permissions to view the logging dashboard.' }); + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Du brauchst die Berechtigung **Server verwalten**, um das Logging-Dashboard zu sehen.' }); } await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); @@ -194,7 +194,7 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [embed], components }); } catch (error) { logger.error('logging_dashboard error:', error); - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Failed to load the logging dashboard.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Das Logging-Dashboard konnte nicht geladen werden.' }); } }, } satisfies SubcommandModule & Pick; diff --git a/src/commands/Moderation/ban.ts b/src/commands/Moderation/ban.ts index 9537ca846d..f75359192f 100644 --- a/src/commands/Moderation/ban.ts +++ b/src/commands/Moderation/ban.ts @@ -6,7 +6,7 @@ import massbanCommand from './modules/massban.js'; export default createRouterCommand({ name: 'ban', - description: 'Ban, unban and mass ban members', + description: 'Mitglieder bannen, entbannen und massenhaft bannen', category: 'Moderation', defaultMemberPermissions: PermissionFlagsBits.BanMembers, routes: [ diff --git a/src/commands/Moderation/cases.ts b/src/commands/Moderation/cases.ts index 08e0c6f560..03c816c515 100644 --- a/src/commands/Moderation/cases.ts +++ b/src/commands/Moderation/cases.ts @@ -8,27 +8,27 @@ import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; export default { data: new SlashCommandBuilder() .setName('cases') - .setDescription('View moderation cases and audit logs') + .setDescription('Moderationsfälle und Audit-Logs ansehen') .setDefaultMemberPermissions(PermissionFlagsBits.ViewAuditLog) .setDMPermission(false) .addStringOption(option => option.setName('filter') - .setDescription('Filter cases by type or user') + .setDescription('Fälle nach Typ oder Nutzer filtern') .addChoices( - { name: 'All Cases', value: 'all' }, - { name: 'Bans', value: 'Member Banned' }, + { name: 'Alle Fälle', value: 'all' }, + { name: 'Banns', value: 'Member Banned' }, { name: 'Kicks', value: 'Member Kicked' }, { name: 'Timeouts', value: 'Member Timed Out' }, - { name: 'Warnings', value: 'User Warned' } + { name: 'Verwarnungen', value: 'User Warned' } ) ) .addUserOption(option => option.setName('user') - .setDescription('Filter cases by specific user') + .setDescription('Fälle nach einem bestimmten Nutzer filtern') ) .addIntegerOption(option => option.setName('limit') - .setDescription('Number of cases to show (default: 10)') + .setDescription('Anzahl der angezeigten Fälle (Standard: 10)') .setMinValue(1) .setMaxValue(50) ), @@ -76,8 +76,8 @@ export default { const pageCases = cases.slice(startIndex, endIndex); const embed = createEmbed({ - title: 'Moderation Cases', - description: `Showing moderation cases for **${interaction.guild!.name}**\n\n**Page ${page} of ${totalPages}**` + title: 'Moderationsfälle', + description: `Moderationsfälle für **${interaction.guild!.name}**\n\n**Seite ${page} von ${totalPages}**` }); pageCases.forEach(case_ => { @@ -85,14 +85,14 @@ export default { const time = new Date(case_.createdAt).toLocaleTimeString(); embed.addFields({ - name: `Case #${case_.caseId} - ${case_.action}`, - value: `**Target:** ${case_.target}\n**Moderator:** ${case_.executor}\n**Date:** ${date} at ${time}\n**Reason:** ${case_.reason || 'No reason provided'}`, + name: `Fall #${case_.caseId} - ${case_.action}`, + value: `**Ziel:** ${case_.target}\n**Moderator:** ${case_.executor}\n**Datum:** ${date} um ${time}\n**Grund:** ${case_.reason || 'Kein Grund angegeben'}`, inline: false }); }); embed.setFooter({ - text: `Total cases: ${cases.length} | Filter: ${filterType}${targetUser ?` | User: ${targetUser.tag}`: ''}` + text: `Fälle gesamt: ${cases.length} | Filter: ${filterType}${targetUser ?` | Nutzer: ${targetUser.tag}`: ''}` }); return embed; @@ -103,19 +103,19 @@ export default { const prevButton = new ButtonBuilder() .setCustomId('prev_page') - .setLabel('⬅️ Previous') + .setLabel('⬅️ Vorherige') .setStyle(ButtonStyle.Secondary) .setDisabled(page === 1); const pageInfoButton = new ButtonBuilder() .setCustomId('page_info') - .setLabel(`Page ${page}/${totalPages}`) + .setLabel(`Seite ${page}/${totalPages}`) .setStyle(ButtonStyle.Primary) .setDisabled(true); const nextButton = new ButtonBuilder() .setCustomId('next_page') - .setLabel('Next ➡️') + .setLabel('Weiter ➡️') .setStyle(ButtonStyle.Secondary) .setDisabled(page === totalPages); @@ -138,7 +138,7 @@ time: 120000 if (buttonInteraction.user.id !== interaction.user.id) { await buttonInteraction.followUp({ - content: 'You cannot use these buttons. Run `/cases` to get your own case view.', + content: 'Du kannst diese Buttons nicht nutzen. Nutze `/cases`, um deine eigene Fallansicht zu öffnen.', flags: MessageFlags.Ephemeral }); return; @@ -172,7 +172,7 @@ time: 120000 } catch (error) { logger.error('Error in cases command:', error); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while retrieving moderation cases. Please try again later.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Laden der Moderationsfälle ist ein Fehler aufgetreten. Bitte versuch es später noch einmal.' }); } } } satisfies Command; \ No newline at end of file diff --git a/src/commands/Moderation/channel.ts b/src/commands/Moderation/channel.ts index eaddb20ce5..e9c0bb57f7 100644 --- a/src/commands/Moderation/channel.ts +++ b/src/commands/Moderation/channel.ts @@ -5,7 +5,7 @@ import unlockCommand from './modules/unlock.js'; export default createRouterCommand({ name: 'channel', - description: 'Lock and unlock channels', + description: 'Kanäle sperren und entsperren', category: 'Moderation', defaultMemberPermissions: PermissionFlagsBits.ManageChannels, routes: [ diff --git a/src/commands/Moderation/kick.ts b/src/commands/Moderation/kick.ts index 86ed6ccdde..945b1f1cf1 100644 --- a/src/commands/Moderation/kick.ts +++ b/src/commands/Moderation/kick.ts @@ -5,7 +5,7 @@ import masskickCommand from './modules/masskick.js'; export default createRouterCommand({ name: 'kick', - description: 'Kick members', + description: 'Mitglieder kicken', category: 'Moderation', defaultMemberPermissions: PermissionFlagsBits.KickMembers, routes: [ diff --git a/src/commands/Moderation/mod.ts b/src/commands/Moderation/mod.ts index 1ae38a15aa..6df4a5e5f6 100644 --- a/src/commands/Moderation/mod.ts +++ b/src/commands/Moderation/mod.ts @@ -8,7 +8,7 @@ import dmCommand from './modules/dm.js'; export default createRouterCommand({ name: 'mod', - description: 'Timeouts, warnings and moderator DMs', + description: 'Timeouts, Verwarnungen und Moderator-DMs', category: 'Moderation', defaultMemberPermissions: PermissionFlagsBits.ModerateMembers, routes: [ diff --git a/src/commands/Moderation/modules/ban.ts b/src/commands/Moderation/modules/ban.ts index f3c79ca647..7f8f8b118d 100644 --- a/src/commands/Moderation/modules/ban.ts +++ b/src/commands/Moderation/modules/ban.ts @@ -8,28 +8,28 @@ import { NexoBotError, ErrorTypes } from '../../../utils/errorHandler.js'; export default { data: new SlashCommandBuilder() .setName("ban") - .setDescription("Ban a user from the server") + .setDescription("Einen Nutzer vom Server bannen") .addUserOption((option) => option .setName("target") - .setDescription("The user to ban") + .setDescription("Der Nutzer, der gebannt werden soll") .setRequired(true), ) .addStringOption((option) => - option.setName("reason").setDescription("Reason for the ban"), + option.setName("reason").setDescription("Grund für den Bann"), ) .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers), category: "moderation", async execute(interaction, config, client) { const user = interaction.options.getUser("target"); - const reason = interaction.options.getString("reason") || "No reason provided"; + const reason = interaction.options.getString("reason") || "Kein Grund angegeben"; if (!user) { throw new NexoBotError( 'Missing target user', ErrorTypes.USER_INPUT, - 'You must specify a user to ban.', + 'Du musst einen Nutzer zum Bannen angeben.', { subtype: 'invalid_user' }, ); } @@ -38,14 +38,14 @@ export default { throw new NexoBotError( 'Cannot ban self', ErrorTypes.VALIDATION, - 'You cannot ban yourself.', + 'Du kannst dich nicht selbst bannen.', ); } if (user.id === client.user.id) { throw new NexoBotError( 'Cannot ban bot', ErrorTypes.VALIDATION, - 'You cannot ban the bot.', + 'Du kannst den Bot nicht bannen.', ); } @@ -59,8 +59,8 @@ export default { await InteractionHelper.universalReply(interaction, { embeds: [ successEmbed( - `🚫 **Banned** ${user.tag}`, - `**Reason:** ${reason}\n**Case ID:** #${result.caseId}`, + `🚫 **Gebannt:** ${user.tag}`, + `**Grund:** ${reason}\n**Fall-ID:** #${result.caseId}`, ), ], }); diff --git a/src/commands/Moderation/modules/dm.ts b/src/commands/Moderation/modules/dm.ts index e47d275334..989dbc24d9 100644 --- a/src/commands/Moderation/modules/dm.ts +++ b/src/commands/Moderation/modules/dm.ts @@ -10,23 +10,23 @@ import { replyUserError, ErrorTypes, type ErrorLike } from '../../../utils/error export default { data: new SlashCommandBuilder() .setName("dm") - .setDescription("Send a direct message to a user (Staff only)") + .setDescription("Einem Nutzer eine Direktnachricht senden (nur Team)") .addUserOption(option => option .setName("user") - .setDescription("The user to send a DM to") + .setDescription("Der Nutzer, der die DM bekommen soll") .setRequired(true) ) .addStringOption(option => option .setName("message") - .setDescription("The message to send") + .setDescription("Die Nachricht, die gesendet werden soll") .setRequired(true) ) .addBooleanOption(option => option .setName("anonymous") - .setDescription("Send the message anonymously (default: false)") + .setDescription("Nachricht anonym senden (Standard: false)") .setRequired(false) ) .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers) @@ -51,11 +51,11 @@ export default { try { if (message.length > 2000) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Messages must be under 2000 characters.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Nachrichten müssen kürzer als 2000 Zeichen sein.' }); } if (targetUser.bot) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You cannot send DMs to bot accounts.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Du kannst Bot-Accounts keine DMs senden.' }); } const sanitized = sanitizeMarkdown(message); @@ -65,10 +65,10 @@ export default { await dmChannel.send({ embeds: [ successEmbed( - anonymous ? "Message from the Staff Team" : `Message from ${interaction.user.tag}`, + anonymous ? "Nachricht vom Team" : `Nachricht von ${interaction.user.tag}`, sanitized ).setFooter({ - text: `You cannot reply to this message. | Logger ID: ${interaction.id}` + text: `Du kannst auf diese Nachricht nicht antworten. | Logger-ID: ${interaction.id}` }) ] }); @@ -80,7 +80,7 @@ export default { action: "DM Sent", target: `${targetUser.tag} (${targetUser.id})`, executor: `${interaction.user.tag} (${interaction.user.id})`, - reason: `Anonymous: ${anonymous ? 'Yes' : 'No'}`, + reason: `Anonym: ${anonymous ? 'Ja' : 'Nein'}`, metadata: { userId: targetUser.id, moderatorId: interaction.user.id, @@ -93,8 +93,8 @@ export default { return await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( - "DM Sent", - `Successfully sent a message to ${targetUser.tag}` + "DM gesendet", + `Nachricht an ${targetUser.tag} erfolgreich gesendet` ), ], }); @@ -102,10 +102,10 @@ export default { logger.error('DM command error:', error); if ((error as ErrorLike).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: `Konnte ${targetUser.tag} keine DM senden. Eventuell sind DMs deaktiviert.` }); } - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `Failed to send DM: ${(error as ErrorLike).message}` }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `DM konnte nicht gesendet werden: ${(error as ErrorLike).message}` }); } } } satisfies Command; \ No newline at end of file diff --git a/src/commands/Moderation/modules/kick.ts b/src/commands/Moderation/modules/kick.ts index 7614fc2336..e4c3fe2480 100644 --- a/src/commands/Moderation/modules/kick.ts +++ b/src/commands/Moderation/modules/kick.ts @@ -8,15 +8,15 @@ import { NexoBotError, ErrorTypes } from '../../../utils/errorHandler.js'; export default { data: new SlashCommandBuilder() .setName("kick") - .setDescription("Kick a user from the server") + .setDescription("Einen Nutzer vom Server kicken") .addUserOption((option) => option .setName("target") - .setDescription("The user to kick") + .setDescription("Der Nutzer, der gekickt werden soll") .setRequired(true), ) .addStringOption((option) => - option.setName("reason").setDescription("Reason for the kick"), + option.setName("reason").setDescription("Grund für den Kick"), ) .setDefaultMemberPermissions(PermissionFlagsBits.KickMembers), category: "moderation", @@ -24,13 +24,13 @@ export default { 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"; + const reason = interaction.options.getString("reason") || "Kein Grund angegeben"; if (!targetUser) { throw new NexoBotError( 'Missing target user', ErrorTypes.USER_INPUT, - 'You must specify a user to kick.', + 'Du musst einen Nutzer zum Kicken angeben.', { subtype: 'invalid_user' }, ); } @@ -39,7 +39,7 @@ export default { throw new NexoBotError( "Cannot kick self", ErrorTypes.VALIDATION, - "You cannot kick yourself.", + "Du kannst dich nicht selbst kicken.", ); } @@ -47,7 +47,7 @@ export default { throw new NexoBotError( "Cannot kick bot", ErrorTypes.VALIDATION, - "You cannot kick the bot.", + "Du kannst den Bot nicht kicken.", ); } @@ -55,7 +55,7 @@ export default { throw new NexoBotError( "Target not found", ErrorTypes.USER_INPUT, - "The target user is not currently in this server.", + "Der Nutzer ist gerade nicht auf diesem Server.", { subtype: 'user_not_found' }, ); } @@ -70,8 +70,8 @@ export default { await InteractionHelper.universalReply(interaction, { embeds: [ successEmbed( - `👢 **Kicked** ${targetUser.tag}`, - `**Reason:** ${reason}\n**Case ID:** #${result.caseId}`, + `👢 **Gekickt:** ${targetUser.tag}`, + `**Grund:** ${reason}\n**Fall-ID:** #${result.caseId}`, ), ], }); diff --git a/src/commands/Moderation/modules/lock.ts b/src/commands/Moderation/modules/lock.ts index b10c8d8f60..1ca232fc8d 100644 --- a/src/commands/Moderation/modules/lock.ts +++ b/src/commands/Moderation/modules/lock.ts @@ -11,7 +11,7 @@ export default { data: new SlashCommandBuilder() .setName("lock") .setDescription( - "Locks the current channel (prevents @everyone from sending messages).", + "Sperrt den aktuellen Kanal (@everyone kann keine Nachrichten mehr senden).", ) .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), category: "moderation", @@ -33,13 +33,13 @@ 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} ist bereits gesperrt.` }); } await channel.permissionOverwrites.edit( everyoneRole, { SendMessages: false }, -{ type: 0, reason: `Channel locked by ${interaction.user.tag}` }, +{ type: 0, reason: `Kanal gesperrt von ${interaction.user.tag}` }, ); await logEvent({ @@ -60,14 +60,14 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( - `🔒 **Channel Locked**`, - `${channel} is now locked down. No one can speak here now.`, + `🔒 **Kanal gesperrt**`, + `${channel} ist jetzt gesperrt. Hier kann niemand mehr schreiben.`, ), ], }); } catch (error) { logger.error('Lock command error:', error); - await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'An unexpected error occurred while trying to lock the channel. Check my permissions (I need \'Manage Channels\').' }); + await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Beim Sperren des Kanals ist ein unerwarteter Fehler aufgetreten. Prüf meine Berechtigungen (ich brauche „Kanäle verwalten“).' }); } } } satisfies Command; \ No newline at end of file diff --git a/src/commands/Moderation/modules/massban.ts b/src/commands/Moderation/modules/massban.ts index d2c2b37c9d..86d267bd4d 100644 --- a/src/commands/Moderation/modules/massban.ts +++ b/src/commands/Moderation/modules/massban.ts @@ -16,22 +16,22 @@ interface MassActionResults { export default { data: new SlashCommandBuilder() .setName("massban") - .setDescription("Ban multiple users from the server at once") + .setDescription("Mehrere Nutzer auf einmal vom Server bannen") .addStringOption(option => option .setName("users") - .setDescription("User IDs or mentions to ban (separated by spaces or commas)") + .setDescription("Nutzer-IDs oder Erwähnungen zum Bannen (durch Leerzeichen oder Kommas getrennt)") .setRequired(true) ) .addStringOption(option => option.setName("reason") - .setDescription("Reason for the mass ban") + .setDescription("Grund für den Massenbann") .setRequired(false) ) .addIntegerOption(option => option .setName("delete_days") - .setDescription("Number of days of messages to delete (0-7)") + .setDescription("Wie viele Tage an Nachrichten gelöscht werden sollen (0-7)") .setMinValue(0) .setMaxValue(7) .setRequired(false) @@ -52,7 +52,7 @@ export default { } const usersInput = interaction.options.getString("users")!; - const reason = interaction.options.getString("reason") || "Mass ban - No reason provided"; + const reason = interaction.options.getString("reason") || "Massenbann – kein Grund angegeben"; const deleteDays = interaction.options.getInteger("delete_days") || 0; try { @@ -63,15 +63,15 @@ export default { .slice(0, 20); if (userIds.length === 0) { - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please provide valid user IDs or mentions. Maximum 20 users at once.' }); + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Bitte gib gültige Nutzer-IDs oder Erwähnungen an. Maximal 20 Nutzer auf einmal.' }); } if (userIds.includes(interaction.user.id)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You cannot include yourself in a mass ban.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Du kannst dich nicht selbst in einen Massenbann aufnehmen.' }); } if (userIds.includes(client.user.id)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You cannot include the bot in a mass ban.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Du kannst den Bot nicht in einen Massenbann aufnehmen.' }); } const results: MassActionResults = { @@ -85,7 +85,7 @@ export default { const user = await client.users.fetch(userId).catch(() => null); if (!user) { - results.failed.push({ userId, reason: "User not found" }); + results.failed.push({ userId, reason: "Nutzer nicht gefunden" }); continue; } @@ -130,7 +130,7 @@ export default { action: "Member Banned", target: `${user.tag} (${user.id})`, executor: `${interaction.user.tag} (${interaction.user.id})`, - reason: `${reason} (Mass Ban)`, + reason: `${reason} (Massenbann)`, metadata: { userId: user.id, moderatorId: interaction.user.id, @@ -144,7 +144,7 @@ export default { logger.error(`Failed to ban user ${userId}:`, error); const reason = error instanceof NexoBotError ? (error.userMessage || error.message) - : ((error as ErrorLike).message || "Unknown error"); + : ((error as ErrorLike).message || "Unbekannter Fehler"); results.failed.push({ userId, reason, @@ -152,10 +152,10 @@ export default { } } - let description = `**Mass Ban Results:**\n\n`; + let description = `**Ergebnisse des Massenbanns:**\n\n`; if (results.successful.length > 0) { - description += `✅ **Successfully Banned (${results.successful.length}):**\n`; + description += `✅ **Erfolgreich gebannt (${results.successful.length}):**\n`; results.successful.forEach(result => { description += `• ${result.user} (${result.userId})\n`; }); @@ -163,7 +163,7 @@ export default { } if (results.skipped.length > 0) { - description += `⚠️ **Skipped (${results.skipped.length}):**\n`; + description += `⚠️ **Übersprungen (${results.skipped.length}):**\n`; results.skipped.forEach(result => { description += `• ${result.user} - ${result.reason}\n`; }); @@ -171,7 +171,7 @@ export default { } if (results.failed.length > 0) { - description += `❌ **Failed (${results.failed.length}):**\n`; + description += `❌ **Fehlgeschlagen (${results.failed.length}):**\n`; results.failed.forEach(result => { description += `• ${result.userId} - ${result.reason}\n`; }); @@ -182,7 +182,7 @@ export default { return await InteractionHelper.safeEditReply(interaction, { embeds: [ embed( - `🔨 Mass Ban Completed`, + `🔨 Massenbann abgeschlossen`, description ) ] @@ -190,7 +190,7 @@ export default { } catch (error) { logger.error("Error in massban command:", error); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while processing the mass ban. Please try again later.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Massenbann ist ein Fehler aufgetreten. Bitte versuch es später noch einmal.' }); } } } satisfies Command; \ No newline at end of file diff --git a/src/commands/Moderation/modules/masskick.ts b/src/commands/Moderation/modules/masskick.ts index a7b89097ed..57704922a1 100644 --- a/src/commands/Moderation/modules/masskick.ts +++ b/src/commands/Moderation/modules/masskick.ts @@ -16,16 +16,16 @@ interface MassActionResults { export default { data: new SlashCommandBuilder() .setName("masskick") - .setDescription("Kick multiple users from the server at once") + .setDescription("Mehrere Nutzer auf einmal vom Server kicken") .addStringOption(option => option .setName("users") - .setDescription("User IDs or mentions to kick (separated by spaces or commas)") + .setDescription("Nutzer-IDs oder Erwähnungen zum Kicken (durch Leerzeichen oder Kommas getrennt)") .setRequired(true) ) .addStringOption(option => option.setName("reason") - .setDescription("Reason for the mass kick") + .setDescription("Grund für den Massenkick") .setRequired(false) ) .setDefaultMemberPermissions(PermissionFlagsBits.KickMembers), @@ -44,7 +44,7 @@ export default { } const usersInput = interaction.options.getString("users")!; - const reason = interaction.options.getString("reason") || "Mass kick - No reason provided"; + const reason = interaction.options.getString("reason") || "Massenkick – kein Grund angegeben"; try { const userIds = usersInput @@ -54,15 +54,15 @@ export default { .slice(0, 20); if (userIds.length === 0) { - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please provide valid user IDs or mentions. Maximum 20 users at once.' }); + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Bitte gib gültige Nutzer-IDs oder Erwähnungen an. Maximal 20 Nutzer auf einmal.' }); } if (userIds.includes(interaction.user.id)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You cannot include yourself in a mass kick.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Du kannst dich nicht selbst in einen Massenkick aufnehmen.' }); } if (userIds.includes(client.user.id)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You cannot include the bot in a mass kick.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Du kannst den Bot nicht in einen Massenkick aufnehmen.' }); } const results: MassActionResults = { @@ -76,7 +76,7 @@ export default { const member = await interaction.guild!.members.fetch(userId).catch(() => null); if (!member) { - results.failed.push({ userId, reason: "User not in server" }); + results.failed.push({ userId, reason: "Nutzer ist nicht auf dem Server" }); continue; } @@ -104,7 +104,7 @@ export default { results.skipped.push({ user: member.user.tag, userId, - reason: 'Target has Admin or a managed role, or bot lacks Kick Members', + reason: 'Ziel hat Admin oder eine verwaltete Rolle, oder dem Bot fehlt „Mitglieder kicken“', }); continue; } @@ -123,7 +123,7 @@ export default { action: "Member Kicked", target: `${member.user.tag} (${member.user.id})`, executor: `${interaction.user.tag} (${interaction.user.id})`, - reason: `${reason} (Mass Kick)`, + reason: `${reason} (Massenkick)`, metadata: { userId: member.user.id, moderatorId: interaction.user.id, @@ -136,7 +136,7 @@ export default { logger.error(`Failed to kick user ${userId}:`, error); const reason = error instanceof NexoBotError ? (error.userMessage || error.message) - : ((error as ErrorLike).message || "Unknown error"); + : ((error as ErrorLike).message || "Unbekannter Fehler"); results.failed.push({ userId, reason, @@ -144,10 +144,10 @@ export default { } } - let description = `**Mass Kick Results:**\n\n`; + let description = `**Ergebnisse des Massenkicks:**\n\n`; if (results.successful.length > 0) { - description += `✅ **Successfully Kicked (${results.successful.length}):**\n`; + description += `✅ **Erfolgreich gekickt (${results.successful.length}):**\n`; results.successful.forEach(result => { description += `• ${result.user} (${result.userId})\n`; }); @@ -155,7 +155,7 @@ export default { } if (results.skipped.length > 0) { - description += `⚠️ **Skipped (${results.skipped.length}):**\n`; + description += `⚠️ **Übersprungen (${results.skipped.length}):**\n`; results.skipped.forEach(result => { description += `• ${result.user} - ${result.reason}\n`; }); @@ -163,7 +163,7 @@ export default { } if (results.failed.length > 0) { - description += `❌ **Failed (${results.failed.length}):**\n`; + description += `❌ **Fehlgeschlagen (${results.failed.length}):**\n`; results.failed.forEach(result => { description += `• ${result.userId} - ${result.reason}\n`; }); @@ -174,7 +174,7 @@ export default { return await InteractionHelper.safeEditReply(interaction, { embeds: [ embed( - `👢 Mass Kick Completed`, + `👢 Massenkick abgeschlossen`, description ) ] @@ -182,7 +182,7 @@ export default { } catch (error) { logger.error("Error in masskick command:", error); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while processing the mass kick. Please try again later.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Massenkick ist ein Fehler aufgetreten. Bitte versuch es später noch einmal.' }); } } } satisfies Command; \ No newline at end of file diff --git a/src/commands/Moderation/modules/purge.ts b/src/commands/Moderation/modules/purge.ts index 577166b7be..b36c30cc7d 100644 --- a/src/commands/Moderation/modules/purge.ts +++ b/src/commands/Moderation/modules/purge.ts @@ -10,11 +10,11 @@ import { replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; export default { data: new SlashCommandBuilder() .setName("purge") - .setDescription("Delete a specific amount of messages") + .setDescription("Eine bestimmte Anzahl an Nachrichten löschen") .addIntegerOption((option) => option .setName("amount") - .setDescription("Number of messages (1-100)") + .setDescription("Anzahl der Nachrichten (1-100)") .setRequired(true), ) .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages), @@ -38,7 +38,7 @@ export default { const channel = interaction.channel as GuildTextBasedChannel; if (amount < 1 || amount > 100) - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please specify a number between 1 and 100.' }); + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Bitte gib eine Zahl zwischen 1 und 100 an.' }); try { const fetched = await channel.messages.fetch({ limit: amount }); @@ -50,9 +50,9 @@ export default { guild: interaction.guild, event: { action: "Messages Purged", - target: `${channel} (${deletedCount} messages)`, + target: `${channel} (${deletedCount} Nachrichten)`, executor: `${interaction.user.tag} (${interaction.user.id})`, - reason: `Deleted ${deletedCount} messages`, + reason: `${deletedCount} Nachrichten gelöscht`, metadata: { channelId: channel.id, messageCount: deletedCount, @@ -65,8 +65,8 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( - "Messages Purged", - `Deleted ${deletedCount} messages in ${channel}.`, + "Nachrichten gelöscht", + `${deletedCount} Nachrichten in ${channel} gelöscht.`, ), ], flags: MessageFlags.Ephemeral, @@ -79,7 +79,7 @@ export default { }, 3000); } catch (error) { logger.error('Purge command error:', error); - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An unexpected error occurred during message deletion. Note: Messages older than 14 days cannot be bulk deleted.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Löschen der Nachrichten ist ein unerwarteter Fehler aufgetreten. Hinweis: Nachrichten, die älter als 14 Tage sind, können nicht gesammelt gelöscht werden.' }); } } } satisfies Command; \ No newline at end of file diff --git a/src/commands/Moderation/modules/say.ts b/src/commands/Moderation/modules/say.ts index 07395057bf..f284040ce5 100644 --- a/src/commands/Moderation/modules/say.ts +++ b/src/commands/Moderation/modules/say.ts @@ -37,18 +37,18 @@ function resolveTargetChannel(interaction: ChatInputCommandInteraction): TextCha export default { data: new SlashCommandBuilder() .setName('say') - .setDescription('Send a plain message as the bot') + .setDescription('Eine einfache Nachricht als Bot senden') .addStringOption((option) => option .setName('message') - .setDescription('The message the bot should send') + .setDescription('Die Nachricht, die der Bot senden soll') .setRequired(true) .setMaxLength(2000), ) .addChannelOption((option) => option .setName('channel') - .setDescription('Channel to send in (defaults to the current channel)') + .setDescription('Kanal für die Nachricht (Standard: aktueller Kanal)') .addChannelTypes(...TEXT_CHANNEL_TYPES) .setRequired(false), ) @@ -76,7 +76,7 @@ export default { if (!message) { return replyUserError(interaction, { type: ErrorTypes.VALIDATION, - message: 'Message cannot be empty.', + message: 'Die Nachricht darf nicht leer sein.', }); } @@ -84,7 +84,7 @@ export default { if (!channel) { return replyUserError(interaction, { type: ErrorTypes.VALIDATION, - message: 'Choose a text channel or run this command in one.', + message: 'Wähl einen Textkanal aus oder nutze diesen Command in einem.', }); } @@ -94,14 +94,14 @@ export default { if (!memberPermissions?.has(PermissionFlagsBits.SendMessages)) { return replyUserError(interaction, { type: ErrorTypes.PERMISSION, - message: `You do not have permission to send messages in ${channel}.`, + message: `Du hast keine Berechtigung, in ${channel} Nachrichten zu senden.`, }); } if (!botPermissions?.has(PermissionFlagsBits.SendMessages)) { return replyUserError(interaction, { type: ErrorTypes.PERMISSION, - message: `I do not have permission to send messages in ${channel}.`, + message: `Ich habe keine Berechtigung, in ${channel} Nachrichten zu senden.`, }); } @@ -129,8 +129,8 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( - 'Message Sent', - `Posted in ${channel}. [Jump to message](${sentMessage.url})`, + 'Nachricht gesendet', + `In ${channel} gepostet. [Zur Nachricht springen](${sentMessage.url})`, ), ], flags: MessageFlags.Ephemeral, diff --git a/src/commands/Moderation/modules/timeout.ts b/src/commands/Moderation/modules/timeout.ts index 3f832c30e5..8fc0f2b30b 100644 --- a/src/commands/Moderation/modules/timeout.ts +++ b/src/commands/Moderation/modules/timeout.ts @@ -7,35 +7,35 @@ import { InteractionHelper } from '../../../utils/interactionHelper.js'; import { ModerationService } from '../../../services/moderation/moderationService.js'; const durationChoices = [ - { name: "5 minutes", value: 5 }, - { name: "10 minutes", value: 10 }, - { name: "30 minutes", value: 30 }, - { name: "1 hour", value: 60 }, - { name: "6 hours", value: 360 }, - { name: "1 day", value: 1440 }, - { name: "1 week", value: 10080 }, + { name: "5 Minuten", value: 5 }, + { name: "10 Minuten", value: 10 }, + { name: "30 Minuten", value: 30 }, + { name: "1 Stunde", value: 60 }, + { name: "6 Stunden", value: 360 }, + { name: "1 Tag", value: 1440 }, + { name: "1 Woche", value: 10080 }, ]; export default { data: new SlashCommandBuilder() .setName("timeout") - .setDescription("Timeout a user for a specific duration.") + .setDescription("Einem Nutzer für eine bestimmte Dauer einen Timeout geben.") .addUserOption((option) => option .setName("target") - .setDescription("User to timeout") + .setDescription("Nutzer, der einen Timeout bekommen soll") .setRequired(true), ) .addIntegerOption( (option) => option .setName("duration") - .setDescription("Duration of the timeout") + .setDescription("Dauer des Timeouts") .setRequired(true) .addChoices(...durationChoices), ) .addStringOption((option) => - option.setName("reason").setDescription("Reason for the timeout"), + option.setName("reason").setDescription("Grund für den Timeout"), ) .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), category: "moderation", @@ -54,13 +54,13 @@ export default { 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 reason = interaction.options.getString("reason") || "Kein Grund angegeben"; if (!targetUser) { throw new NexoBotError( 'Missing target user', ErrorTypes.USER_INPUT, - 'You must specify a user to timeout.', + 'Du musst einen Nutzer für den Timeout angeben.', { subtype: 'invalid_user' }, ); } @@ -69,21 +69,21 @@ export default { throw new NexoBotError( "Cannot timeout self", ErrorTypes.VALIDATION, - "You cannot timeout yourself.", + "Du kannst dir nicht selbst einen Timeout geben.", ); } if (targetUser.id === client.user.id) { throw new NexoBotError( "Cannot timeout bot", ErrorTypes.VALIDATION, - "You cannot timeout the bot.", + "Du kannst dem Bot keinen Timeout geben.", ); } if (!member) { throw new NexoBotError( "Target not found", ErrorTypes.USER_INPUT, - "The target user is not currently in this server.", + "Der Nutzer ist gerade nicht auf diesem Server.", ); } @@ -98,13 +98,13 @@ export default { const durationDisplay = durationChoices.find((c) => c.value === durationMinutes) - ?.name || `${durationMinutes} minutes`; + ?.name || `${durationMinutes} Minuten`; await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( - `⏳ **Timed out** ${targetUser.tag} for ${durationDisplay}.`, - `**Reason:** ${reason}\n**Case ID:** #${result.caseId}`, + `⏳ **Timeout** für ${targetUser.tag} (${durationDisplay}).`, + `**Grund:** ${reason}\n**Fall-ID:** #${result.caseId}`, ), ], }); diff --git a/src/commands/Moderation/modules/unban.ts b/src/commands/Moderation/modules/unban.ts index 28bcfb62db..24f209a91c 100644 --- a/src/commands/Moderation/modules/unban.ts +++ b/src/commands/Moderation/modules/unban.ts @@ -9,16 +9,16 @@ import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("unban") - .setDescription("Unban a user from the server") + .setDescription("Einen Nutzer auf dem Server entbannen") .addStringOption(option => option .setName("target") - .setDescription("The ID (or mention) of the user to unban") + .setDescription("Die ID (oder Erwähnung) des Nutzers, der entbannt werden soll") .setRequired(true), ) .addStringOption(option => option.setName("reason") - .setDescription("Reason for the unban") + .setDescription("Grund für die Entbannung") .setRequired(false), ) .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers), @@ -41,7 +41,7 @@ export default { if (!/^\d{17,20}$/.test(targetId)) { return replyUserError(interaction, { type: ErrorTypes.USER_INPUT, - message: 'Please provide a valid user ID or mention.', + message: 'Bitte gib eine gültige Nutzer-ID oder Erwähnung an.', }); } @@ -49,11 +49,11 @@ export default { if (!targetUser) { return replyUserError(interaction, { type: ErrorTypes.USER_INPUT, - message: `Could not find a user with the ID \`${targetId}\`.`, + message: `Es wurde kein Nutzer mit der ID \`${targetId}\` gefunden.`, }); } - const reason = interaction.options.getString("reason") || "No reason provided"; + const reason = interaction.options.getString("reason") || "Kein Grund angegeben"; const result = await ModerationService.unbanUser({ guild: interaction.guild!, @@ -65,8 +65,8 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( - "✅ User Unbanned", - `Successfully unbanned **${targetUser.tag}** from the server.\n\n**Reason:** ${reason}\n**Case ID:** #${result.caseId}`, + "✅ Nutzer entbannt", + `**${targetUser.tag}** wurde erfolgreich entbannt.\n\n**Grund:** ${reason}\n**Fall-ID:** #${result.caseId}`, ), ], }); diff --git a/src/commands/Moderation/modules/unlock.ts b/src/commands/Moderation/modules/unlock.ts index 95404c4a4d..3709a43ce8 100644 --- a/src/commands/Moderation/modules/unlock.ts +++ b/src/commands/Moderation/modules/unlock.ts @@ -11,7 +11,7 @@ export default { data: new SlashCommandBuilder() .setName("unlock") .setDescription( - "Unlocks the current channel (allows @everyone to send messages again).", + "Entsperrt den aktuellen Kanal (@everyone kann wieder Nachrichten senden).", ) .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), category: "moderation", @@ -38,7 +38,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} ist nicht explizit gesperrt (alle können bereits Nachrichten senden).` }); } await channel.permissionOverwrites.edit( @@ -46,7 +46,7 @@ export default { { SendMessages: true }, { type: 0, - reason: `Channel unlocked by ${interaction.user.tag}`, + reason: `Kanal entsperrt von ${interaction.user.tag}`, }, ); @@ -67,14 +67,14 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( - `🔓 **Channel Unlocked**`, - `${channel} is now unlocked. You may speak now.`, + `🔓 **Kanal entsperrt**`, + `${channel} ist jetzt entsperrt. Ihr könnt wieder schreiben.`, ), ], }); } catch (error) { logger.error('Unlock command error:', error); - await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'An unexpected error occurred while trying to unlock the channel. Check my permissions (I need \'Manage Channels\').' }); + await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Beim Entsperren des Kanals ist ein unerwarteter Fehler aufgetreten. Prüf meine Berechtigungen (ich brauche „Kanäle verwalten“).' }); } } } satisfies Command; \ No newline at end of file diff --git a/src/commands/Moderation/modules/untimeout.ts b/src/commands/Moderation/modules/untimeout.ts index 142fb8b083..447691de57 100644 --- a/src/commands/Moderation/modules/untimeout.ts +++ b/src/commands/Moderation/modules/untimeout.ts @@ -9,11 +9,11 @@ import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("untimeout") - .setDescription("Remove timeout from a user") + .setDescription("Den Timeout eines Nutzers aufheben") .addUserOption((option) => option .setName("target") - .setDescription("User to untimeout") + .setDescription("Nutzer, dessen Timeout aufgehoben werden soll") .setRequired(true), ) .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), @@ -37,7 +37,7 @@ export default { throw new NexoBotError( 'Missing target user', ErrorTypes.USER_INPUT, - 'You must specify a user to untimeout.', + 'Du musst einen Nutzer angeben, dessen Timeout aufgehoben werden soll.', { subtype: 'invalid_user' }, ); } @@ -46,7 +46,7 @@ export default { throw new NexoBotError( "Target not found", ErrorTypes.USER_INPUT, - "The target user is not currently in this server.", + "Der Nutzer ist gerade nicht auf diesem Server.", ); } @@ -59,7 +59,7 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( - `🔓 **Removed timeout** from ${targetUser.tag}`, + `🔓 **Timeout aufgehoben** für ${targetUser.tag}`, ), ], }); diff --git a/src/commands/Moderation/modules/usernotes.ts b/src/commands/Moderation/modules/usernotes.ts index 85fdba8fbc..be597aae2d 100644 --- a/src/commands/Moderation/modules/usernotes.ts +++ b/src/commands/Moderation/modules/usernotes.ts @@ -20,32 +20,32 @@ export interface UserNote { export default { data: new SlashCommandBuilder() .setName("usernotes") - .setDescription("Manage user notes for moderation purposes") + .setDescription("Nutzernotizen für die Moderation verwalten") .addSubcommand(subcommand => subcommand .setName("add") - .setDescription("Add a note to a user") + .setDescription("Eine Notiz zu einem Nutzer hinzufügen") .addUserOption(option => option .setName("target") - .setDescription("The user to add a note for") + .setDescription("Der Nutzer, zu dem du eine Notiz hinzufügen willst") .setRequired(true) ) .addStringOption(option => option .setName("note") - .setDescription("The note to add") + .setDescription("Die Notiz, die hinzugefügt werden soll") .setRequired(true) ) .addStringOption(option => option .setName("type") - .setDescription("Type of note") + .setDescription("Art der Notiz") .addChoices( - { name: "Warning", value: "warning" }, - { name: "Positive", value: "positive" }, + { name: "Warnung", value: "warning" }, + { name: "Positiv", value: "positive" }, { name: "Neutral", value: "neutral" }, - { name: "Alert", value: "alert" } + { name: "Alarm", value: "alert" } ) .setRequired(false) ) @@ -53,28 +53,28 @@ export default { .addSubcommand(subcommand => subcommand .setName("view") - .setDescription("View notes for a user") + .setDescription("Notizen zu einem Nutzer ansehen") .addUserOption(option => option .setName("target") - .setDescription("The user to view notes for") + .setDescription("Der Nutzer, dessen Notizen du sehen willst") .setRequired(true) ) ) .addSubcommand(subcommand => subcommand .setName("remove") - .setDescription("Remove a specific note from a user") + .setDescription("Eine bestimmte Notiz eines Nutzers entfernen") .addUserOption(option => option .setName("target") - .setDescription("The user to remove a note from") + .setDescription("Der Nutzer, von dem du eine Notiz entfernen willst") .setRequired(true) ) .addIntegerOption(option => option .setName("index") - .setDescription("The index of the note to remove") + .setDescription("Die Nummer der Notiz, die entfernt werden soll") .setRequired(true) .setMinValue(1) ) @@ -82,11 +82,11 @@ export default { .addSubcommand(subcommand => subcommand .setName("clear") - .setDescription("Clear all notes for a user") + .setDescription("Alle Notizen eines Nutzers löschen") .addUserOption(option => option .setName("target") - .setDescription("The user to clear notes for") + .setDescription("Der Nutzer, dessen Notizen gelöscht werden sollen") .setRequired(true) ) ) @@ -99,7 +99,7 @@ export default { const guildId = interaction.guild!.id; if (subcommand !== "view" && subcommand !== "remove" && subcommand !== "clear" && subcommand !== "add") { - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please select a valid subcommand.' }); + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Bitte wähl einen gültigen Subcommand.' }); } let notes: UserNote[] = []; @@ -119,11 +119,11 @@ export default { case "clear": return await handleClearNotes(interaction, targetUser!, notes, guildId); default: - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please select a valid subcommand.' }); + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Bitte wähl einen gültigen Subcommand.' }); } } catch (error) { logger.error(`Error in usernotes command (${subcommand}):`, error); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while processing your request. Please try again later.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Bei deiner Anfrage ist ein Fehler aufgetreten. Bitte versuch es später noch einmal.' }); } } } satisfies Command; @@ -133,11 +133,11 @@ async function handleAddNote(interaction: ChatInputCommandInteraction, targetUse const type = interaction.options.getString("type") || "neutral"; if (note.length > 1000) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Notes must be 1000 characters or less.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Notizen dürfen höchstens 1000 Zeichen lang sein.' }); } if (note.length === 0) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Note cannot be empty.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Die Notiz darf nicht leer sein.' }); } note = sanitizeInput(note); @@ -161,11 +161,11 @@ async function handleAddNote(interaction: ChatInputCommandInteraction, targetUse return InteractionHelper.safeReply(interaction, { embeds: [ successEmbed( - `${typeInfo.emoji} Note Added`, - `Added a **${type}** note for **${targetUser.tag}**:\n\n` + + `${typeInfo.emoji} Notiz hinzugefügt`, + `Notiz (**${typeInfo.label}**) für **${targetUser.tag}** hinzugefügt:\n\n` + `> ${note}\n\n` + `**Moderator:** ${interaction.user.tag}\n` + - `**Total Notes:** ${notes.length}` + `**Notizen gesamt:** ${notes.length}` ) ] }); @@ -176,8 +176,8 @@ async function handleViewNotes(interaction: ChatInputCommandInteraction, targetU return InteractionHelper.safeReply(interaction, { embeds: [ infoEmbed( - "📝 No Notes", - `There are no notes for **${targetUser.tag}**.` + "📝 Keine Notizen", + `Zu **${targetUser.tag}** gibt es keine Notizen.` ), ], }); @@ -185,24 +185,24 @@ async function handleViewNotes(interaction: ChatInputCommandInteraction, targetU const sortedNotes = [...notes].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); - let description = `**Notes for ${targetUser.tag} (${targetUser.id}):**\n\n`; + let description = `**Notizen zu ${targetUser.tag} (${targetUser.id}):**\n\n`; sortedNotes.forEach((note, index) => { const typeInfo = getNoteTypeInfo(note.type); const date = new Date(note.timestamp).toLocaleDateString(); - description += `${typeInfo.emoji} **Note #${index + 1}** (${note.type}) - ${date}\n`; + description += `${typeInfo.emoji} **Notiz #${index + 1}** (${typeInfo.label}) - ${date}\n`; description += `> ${note.content}\n`; - description += `*Added by ${note.author}*\n\n`; + description += `*Hinzugefügt von ${note.author}*\n\n`; }); if (description.length > 4000) { - description = description.substring(0, 3900) + "\n... *(truncated)*"; + description = description.substring(0, 3900) + "\n... *(gekürzt)*"; } return InteractionHelper.safeReply(interaction, { embeds: [ infoEmbed( - `📝 User Notes (${notes.length})`, + `📝 Nutzernotizen (${notes.length})`, description ) ] @@ -213,7 +213,7 @@ async function handleRemoveNote(interaction: ChatInputCommandInteraction, target 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: `Bitte gib eine gültige Notiznummer an (1-${notes.length}).` }); } // The view command displays notes sorted newest-first, so resolve the index @@ -231,10 +231,10 @@ async function handleRemoveNote(interaction: ChatInputCommandInteraction, target return InteractionHelper.safeReply(interaction, { embeds: [ successEmbed( - `${typeInfo.emoji} Note Removed`, - `Removed note #${index + 1} from **${targetUser.tag}**:\n\n` + + `${typeInfo.emoji} Notiz entfernt`, + `Notiz #${index + 1} von **${targetUser.tag}** entfernt:\n\n` + `> ${removedNote.content}\n\n` + - `**Remaining Notes:** ${notes.length}` + `**Verbleibende Notizen:** ${notes.length}` ) ] }); @@ -247,8 +247,8 @@ async function handleClearNotes(interaction: ChatInputCommandInteraction, target return InteractionHelper.safeReply(interaction, { embeds: [ infoEmbed( - "No Notes to Clear", - `There are no notes for **${targetUser.tag}** to clear.` + "Keine Notizen zum Löschen", + `Zu **${targetUser.tag}** gibt es keine Notizen zum Löschen.` ), ], }); @@ -262,19 +262,19 @@ async function handleClearNotes(interaction: ChatInputCommandInteraction, target return InteractionHelper.safeReply(interaction, { embeds: [ successEmbed( - "🗑️ Notes Cleared", - `Cleared **${noteCount}** notes from **${targetUser.tag}**.` + "🗑️ Notizen gelöscht", + `**${noteCount}** Notizen von **${targetUser.tag}** gelöscht.` ) ] }); } function getNoteTypeInfo(type: string) { - const types: Record = { - warning: { emoji: "⚠️", color: "#FF6B6B" }, - positive: { emoji: "✅", color: "#51CF66" }, - neutral: { emoji: "📝", color: "#74C0FC" }, - alert: { emoji: "🚨", color: "#FFD43B" } + const types: Record = { + warning: { emoji: "⚠️", color: "#FF6B6B", label: "Warnung" }, + positive: { emoji: "✅", color: "#51CF66", label: "Positiv" }, + neutral: { emoji: "📝", color: "#74C0FC", label: "Neutral" }, + alert: { emoji: "🚨", color: "#FFD43B", label: "Alarm" } }; return types[type] || types.neutral; diff --git a/src/commands/Moderation/modules/warn.ts b/src/commands/Moderation/modules/warn.ts index 35b3db5bc8..7ec1f7dfae 100644 --- a/src/commands/Moderation/modules/warn.ts +++ b/src/commands/Moderation/modules/warn.ts @@ -10,18 +10,18 @@ import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("warn") - .setDescription("Warn a user") + .setDescription("Einen Nutzer verwarnen") .addUserOption((o) => o .setName("target") .setRequired(true) - .setDescription("User to warn"), + .setDescription("Nutzer, der verwarnt werden soll"), ) .addStringOption((o) => o .setName("reason") .setRequired(true) - .setDescription("Reason for the warning"), + .setDescription("Grund für die Verwarnung"), ) .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), category: "moderation", @@ -47,7 +47,7 @@ export default { throw new NexoBotError( 'Missing target user', ErrorTypes.USER_INPUT, - 'You must specify a user to warn.', + 'Du musst einen Nutzer zum Verwarnen angeben.', { subtype: 'invalid_user' }, ); } @@ -56,7 +56,7 @@ export default { throw new NexoBotError( 'Missing warning reason', ErrorTypes.VALIDATION, - 'You must provide a reason for the warning.', + 'Du musst einen Grund für die Verwarnung angeben.', { subtype: 'missing_required' }, ); } @@ -65,7 +65,7 @@ export default { throw new NexoBotError( "Target not found", ErrorTypes.USER_INPUT, - "The target user is not currently in this server." + "Der Nutzer ist gerade nicht auf diesem Server." ); } @@ -100,8 +100,8 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( - `⚠️ **Warned** ${target.tag}`, - `**Reason:** ${reason}\n**Total Warns:** ${totalCount}`, + `⚠️ **Verwarnt:** ${target.tag}`, + `**Grund:** ${reason}\n**Verwarnungen gesamt:** ${totalCount}`, ), ], }); diff --git a/src/commands/Moderation/modules/warnings.ts b/src/commands/Moderation/modules/warnings.ts index 9076c91ea1..05fb4a88c3 100644 --- a/src/commands/Moderation/modules/warnings.ts +++ b/src/commands/Moderation/modules/warnings.ts @@ -10,12 +10,12 @@ import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("warnings") - .setDescription("View all warnings for a user") + .setDescription("Alle Verwarnungen eines Nutzers ansehen") .addUserOption((o) => o .setName("target") .setRequired(true) - .setDescription("User to check warnings for"), + .setDescription("Nutzer, dessen Verwarnungen du sehen willst"), ) .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), category: "moderation", @@ -41,8 +41,8 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [ createEmbed({ - title: `Warnings: ${target.tag}`, - description: "This user has no recorded warnings.", + title: `Verwarnungen: ${target.tag}`, + description: "Dieser Nutzer hat keine Verwarnungen.", }).setColor(getColor('success')), ], }); @@ -50,16 +50,16 @@ export default { } const embed = createEmbed({ - title: `Warnings: ${target.tag}`, - description: `Total Warnings: **${totalWarns}**`, + title: `Verwarnungen: ${target.tag}`, + description: `Verwarnungen gesamt: **${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:** ()`, + name: `[#${i + 1}] Grund: ${w.reason.substring(0, 100)}`, + value: `**Moderator:** <@${w.moderatorId}>\n**Datum:** ()`, inline: false, }; }) @@ -70,11 +70,11 @@ export default { const actionRow = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`warning_delete_specific:${target.id}:${interaction.user.id}`) - .setLabel('Delete Specific Warning') + .setLabel('Bestimmte Verwarnung löschen') .setStyle(ButtonStyle.Danger), new ButtonBuilder() .setCustomId(`warning_clear_all:${target.id}:${interaction.user.id}`) - .setLabel('Clear All Warnings') + .setLabel('Alle Verwarnungen löschen') .setStyle(ButtonStyle.Danger), ); @@ -85,7 +85,7 @@ export default { action: "Warnings Viewed", target: `${target.tag} (${target.id})`, executor: `${interaction.user.tag} (${interaction.user.id})`, - reason: `Viewed ${totalWarns} warnings`, + reason: `${totalWarns} Verwarnungen angesehen`, metadata: { userId: target.id, moderatorId: interaction.user.id, diff --git a/src/commands/Moderation/msg.ts b/src/commands/Moderation/msg.ts index b2d89d7ec1..3048155bbd 100644 --- a/src/commands/Moderation/msg.ts +++ b/src/commands/Moderation/msg.ts @@ -7,7 +7,7 @@ import usernotesCommand from './modules/usernotes.js'; export default createRouterCommand({ name: 'msg', - description: 'Purge, send and build messages, user notes', + description: 'Nachrichten löschen, senden und erstellen, Nutzernotizen', category: 'Moderation', defaultMemberPermissions: PermissionFlagsBits.ManageMessages, routes: [ diff --git a/src/commands/Music/modules/join.ts b/src/commands/Music/modules/join.ts index 6474ab296b..e7909d8ddf 100644 --- a/src/commands/Music/modules/join.ts +++ b/src/commands/Music/modules/join.ts @@ -8,7 +8,7 @@ export default { category: 'Music', data: new SlashCommandBuilder() .setName('join') - .setDescription('Join your voice channel without starting playback'), + .setDescription('Deinem Sprachkanal beitreten, ohne die Wiedergabe zu starten'), async execute(interaction, config, client) { const deferred = await deferMusicCommand(interaction); diff --git a/src/commands/Music/modules/music.ts b/src/commands/Music/modules/music.ts index 21d0f0cbb0..95833f897b 100644 --- a/src/commands/Music/modules/music.ts +++ b/src/commands/Music/modules/music.ts @@ -24,85 +24,85 @@ export default { category: 'Music', data: new SlashCommandBuilder() .setName('music') - .setDescription('Manage playback, queue, and voice session settings') + .setDescription('Wiedergabe, Warteschlange und Sprachsitzung verwalten') .addSubcommand((sub) => - sub.setName('pause').setDescription('Pause playback'), + sub.setName('pause').setDescription('Wiedergabe pausieren'), ) .addSubcommand((sub) => - sub.setName('resume').setDescription('Resume playback'), + sub.setName('resume').setDescription('Wiedergabe fortsetzen'), ) .addSubcommand((sub) => - sub.setName('skip').setDescription('Skip the current track'), + sub.setName('skip').setDescription('Aktuellen Titel überspringen'), ) .addSubcommand((sub) => - sub.setName('stop').setDescription('Stop playback and clear the queue'), + sub.setName('stop').setDescription('Wiedergabe stoppen und Warteschlange leeren'), ) .addSubcommand((sub) => - sub.setName('shuffle').setDescription('Shuffle the queue'), + sub.setName('shuffle').setDescription('Warteschlange mischen'), ) .addSubcommand((sub) => sub .setName('loop') - .setDescription('Set loop mode') + .setDescription('Wiederholungsmodus festlegen') .addStringOption((opt) => opt .setName('mode') - .setDescription('Loop mode') + .setDescription('Wiederholungsmodus') .setRequired(true) .addChoices( - { name: 'Off', value: 'none' }, - { name: 'Track', value: 'track' }, - { name: 'Queue', value: 'queue' }, + { name: 'Aus', value: 'none' }, + { name: 'Titel', value: 'track' }, + { name: 'Warteschlange', value: 'queue' }, ), ), ) .addSubcommand((sub) => sub .setName('volume') - .setDescription('Set playback volume') + .setDescription('Lautstärke einstellen') .addIntegerOption((opt) => - opt.setName('level').setDescription('Volume (0-100)').setRequired(true).setMinValue(0).setMaxValue(100), + opt.setName('level').setDescription('Lautstärke (0-100)').setRequired(true).setMinValue(0).setMaxValue(100), ), ) .addSubcommand((sub) => sub .setName('seek') - .setDescription('Seek to a position in the current track') + .setDescription('Zu einer Position im aktuellen Titel springen') .addIntegerOption((opt) => - opt.setName('seconds').setDescription('Position in seconds').setRequired(true).setMinValue(0), + opt.setName('seconds').setDescription('Position in Sekunden').setRequired(true).setMinValue(0), ), ) .addSubcommand((sub) => sub .setName('remove') - .setDescription('Remove a track from the queue') + .setDescription('Titel aus der Warteschlange entfernen') .addIntegerOption((opt) => - opt.setName('position').setDescription('Queue position').setRequired(true).setMinValue(1), + opt.setName('position').setDescription('Position in der Warteschlange').setRequired(true).setMinValue(1), ), ) .addSubcommand((sub) => sub .setName('move') - .setDescription('Move a track in the queue') + .setDescription('Titel in der Warteschlange verschieben') .addIntegerOption((opt) => - opt.setName('from').setDescription('Current position').setRequired(true).setMinValue(1), + opt.setName('from').setDescription('Aktuelle Position').setRequired(true).setMinValue(1), ) .addIntegerOption((opt) => - opt.setName('to').setDescription('New position').setRequired(true).setMinValue(1), + opt.setName('to').setDescription('Neue Position').setRequired(true).setMinValue(1), ), ) .addSubcommand((sub) => - sub.setName('clear').setDescription('Clear the queue'), + sub.setName('clear').setDescription('Warteschlange leeren'), ) .addSubcommand((sub) => - sub.setName('leave').setDescription('Disconnect the bot from the voice channel'), + sub.setName('leave').setDescription('Bot vom Sprachkanal trennen'), ) .addSubcommand((sub) => sub .setName('247') - .setDescription('Toggle 24/7 mode (stay in voice channel when idle)') + .setDescription('24/7-Modus umschalten (auch ohne Wiedergabe im Sprachkanal bleiben)') .addBooleanOption((opt) => - opt.setName('enabled').setDescription('Enable or disable 24/7 mode').setRequired(true), + opt.setName('enabled').setDescription('24/7-Modus aktivieren oder deaktivieren').setRequired(true), ), ), @@ -183,7 +183,7 @@ export default { } default: await InteractionHelper.safeEditReply(interaction, { - content: 'Unknown music subcommand.', + content: 'Unbekannter Musik-Subcommand.', }); } }, diff --git a/src/commands/Music/modules/nowplaying.ts b/src/commands/Music/modules/nowplaying.ts index f165a7e438..1a0c2d5005 100644 --- a/src/commands/Music/modules/nowplaying.ts +++ b/src/commands/Music/modules/nowplaying.ts @@ -8,7 +8,7 @@ export default { category: 'Music', data: new SlashCommandBuilder() .setName('nowplaying') - .setDescription('Show the currently playing track'), + .setDescription('Aktuell laufenden Titel anzeigen'), async execute(interaction, config, client) { await deferMusicCommand(interaction); diff --git a/src/commands/Music/modules/play.ts b/src/commands/Music/modules/play.ts index fb878e9e50..5149ced60c 100644 --- a/src/commands/Music/modules/play.ts +++ b/src/commands/Music/modules/play.ts @@ -8,9 +8,9 @@ export default { category: 'Music', data: new SlashCommandBuilder() .setName('play') - .setDescription('Play a song or add it to the queue') + .setDescription('Song abspielen oder zur Warteschlange hinzufügen') .addStringOption((opt) => - opt.setName('query').setDescription('Song name or URL').setRequired(true), + opt.setName('query').setDescription('Songname oder URL').setRequired(true), ), async execute(interaction, config, client) { diff --git a/src/commands/Music/modules/queue.ts b/src/commands/Music/modules/queue.ts index 91c3d16140..367859695d 100644 --- a/src/commands/Music/modules/queue.ts +++ b/src/commands/Music/modules/queue.ts @@ -8,9 +8,9 @@ export default { category: 'Music', data: new SlashCommandBuilder() .setName('queue') - .setDescription('Show the current music queue') + .setDescription('Aktuelle Musik-Warteschlange anzeigen') .addIntegerOption((opt) => - opt.setName('page').setDescription('Page number').setMinValue(1), + opt.setName('page').setDescription('Seitenzahl').setMinValue(1), ), async execute(interaction, config, client) { diff --git a/src/commands/Music/music.ts b/src/commands/Music/music.ts index c4f2aa35cc..995ed19386 100644 --- a/src/commands/Music/music.ts +++ b/src/commands/Music/music.ts @@ -7,7 +7,7 @@ import nowplayingCommand from './modules/nowplaying.js'; export default createRouterCommand({ name: 'music', - description: 'Play and control music', + description: 'Musik abspielen und steuern', category: 'Music', routes: [ { legacyName: 'music', module: musicCommand, spread: true }, diff --git a/src/commands/Reaction_roles/reactroles.ts b/src/commands/Reaction_roles/reactroles.ts index 32d4323c44..95cfa48139 100644 --- a/src/commands/Reaction_roles/reactroles.ts +++ b/src/commands/Reaction_roles/reactroles.ts @@ -36,62 +36,62 @@ function truncateText(value: unknown, maxLength: number) { export default { data: new SlashCommandBuilder() .setName('reactroles') - .setDescription('Manage reaction role assignments') + .setDescription('Reaktionsrollen verwalten') .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) .addSubcommand(subcommand => subcommand .setName('setup') - .setDescription('Set up a new reaction role panel') + .setDescription('Neues Reaktionsrollen-Panel einrichten') .addChannelOption(option => option.setName('channel') - .setDescription('The channel to send the reaction role message to') + .setDescription('Kanal, in den das Reaktionsrollen-Panel gesendet wird') .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement) .setRequired(true) ) .addStringOption(option => option.setName('title') - .setDescription('Title for the reaction role panel') + .setDescription('Titel des Reaktionsrollen-Panels') .setRequired(true) ) .addStringOption(option => option.setName('description') - .setDescription('Description for the reaction role panel') + .setDescription('Beschreibung des Reaktionsrollen-Panels') .setRequired(true) ) .addRoleOption(option => option.setName('role1') - .setDescription('First role to add') + .setDescription('Erste Rolle') .setRequired(true) ) .addRoleOption(option => option.setName('role2') - .setDescription('Second role to add') + .setDescription('Zweite Rolle') .setRequired(false) ) .addRoleOption(option => option.setName('role3') - .setDescription('Third role to add') + .setDescription('Dritte Rolle') .setRequired(false) ) .addRoleOption(option => option.setName('role4') - .setDescription('Fourth role to add') + .setDescription('Vierte Rolle') .setRequired(false) ) .addRoleOption(option => option.setName('role5') - .setDescription('Fifth role to add') + .setDescription('Fünfte Rolle') .setRequired(false) ) ) .addSubcommand(subcommand => subcommand .setName('dashboard') - .setDescription('Manage and configure your reaction role panels') + .setDescription('Deine Reaktionsrollen-Panels verwalten und konfigurieren') .addStringOption(option => option .setName('panel') - .setDescription('Select a reaction role panel to manage') + .setDescription('Reaktionsrollen-Panel zum Verwalten auswählen') .setRequired(false) .setAutocomplete(true) ) @@ -143,7 +143,7 @@ export default { const roleCount = Array.isArray(panel.roles) ? panel.roles.length : 0; const label = cachedTitle ? `${cachedTitle} (#${channel.name})` - : `#${channel.name} · ${roleCount} role${roleCount === 1 ? '' : 's'}`; + : `#${channel.name} · ${roleCount} Rolle${roleCount === 1 ? '' : 'n'}`; choices.push({ name: label.substring(0, 100), value: panel.messageId }); if (choices.length >= 25) break; @@ -170,7 +170,7 @@ async function handleSetup(interaction: ChatInputCommandInteraction) { throw createError( `Invalid channel type: ${channel.type}`, ErrorTypes.VALIDATION, - 'Please select a text or announcement channel.', + 'Bitte wähle einen Text- oder Ankündigungskanal.', { channelType: channel.type } ); } @@ -179,7 +179,7 @@ async function handleSetup(interaction: ChatInputCommandInteraction) { throw createError( 'Bot missing ManageRoles permission', ErrorTypes.PERMISSION, - 'I need the "Manage Roles" permission to set up reaction roles.', + 'Ich brauche die Berechtigung „Rollen verwalten“, um Reaktionsrollen einzurichten.', { permission: 'ManageRoles' } ); } @@ -188,7 +188,7 @@ async function handleSetup(interaction: ChatInputCommandInteraction) { throw createError( `Bot cannot send messages in ${channel.name}`, ErrorTypes.PERMISSION, - `I don't have permission to send messages in ${channel}.`, + `Ich habe keine Berechtigung, Nachrichten in ${channel} zu senden.`, { channelId: channel.id } ); } @@ -198,7 +198,7 @@ async function handleSetup(interaction: ChatInputCommandInteraction) { throw createError( 'Panel limit reached', ErrorTypes.VALIDATION, - 'Your guild has reached the maximum of 5 reaction role panels. Delete an existing panel to create a new one.', + 'Dein Server hat das Maximum von 5 Reaktionsrollen-Panels erreicht. Lösche ein bestehendes Panel, um ein neues zu erstellen.', { maxPanels: 5, currentPanels: existingPanels.length } ); } @@ -212,27 +212,27 @@ async function handleSetup(interaction: ChatInputCommandInteraction) { const role = interaction.options.getRole(`role${i}`) as Role | null; if (role) { if (seenRoleIds.has(role.id)) { - roleValidationErrors.push(`**${role.name}** - This role was selected more than once`); + roleValidationErrors.push(`**${role.name}** - Diese Rolle wurde mehrfach ausgewählt`); 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`); + roleValidationErrors.push(`**${role.name}** - Meine Rolle steht in der Rollenhierarchie unter dieser Rolle, daher kann ich sie nicht vergeben`); continue; } if (hasDangerousPermissions(role)) { - roleValidationErrors.push(`**${role.name}** - This role has dangerous permissions (Administrator, Manage Server, etc.)`); + roleValidationErrors.push(`**${role.name}** - Diese Rolle hat gefährliche Berechtigungen (Administrator, Server verwalten usw.)`); continue; } if (role.managed) { - roleValidationErrors.push(`**${role.name}** - This is a managed role (integration/bot role)`); + roleValidationErrors.push(`**${role.name}** - Das ist eine verwaltete Rolle (Integrations-/Bot-Rolle)`); continue; } if (role.id === interaction.guild!.id) { - roleValidationErrors.push(`**${role.name}** - Cannot use the @everyone role`); + roleValidationErrors.push(`**${role.name}** - Die @everyone-Rolle kann nicht verwendet werden`); continue; } @@ -242,7 +242,7 @@ async function handleSetup(interaction: ChatInputCommandInteraction) { } if (roleValidationErrors.length > 0) { - const errorMsg = `The following roles cannot be added:\n${roleValidationErrors.join('\n')}`; + const errorMsg = `Folgende Rollen können nicht hinzugefügt werden:\n${roleValidationErrors.join('\n')}`; if (roles.length === 0) { throw createError( @@ -254,7 +254,7 @@ async function handleSetup(interaction: ChatInputCommandInteraction) { } await interaction.followUp({ - embeds: [warningEmbed('Role Validation Warning', errorMsg)], + embeds: [warningEmbed('Warnung zur Rollenprüfung', errorMsg)], flags: MessageFlags.Ephemeral }); } @@ -263,7 +263,7 @@ async function handleSetup(interaction: ChatInputCommandInteraction) { throw createError( 'No roles provided', ErrorTypes.VALIDATION, - 'You must provide at least one valid role.', + 'Du musst mindestens eine gültige Rolle angeben.', {} ); } @@ -271,13 +271,13 @@ async function handleSetup(interaction: ChatInputCommandInteraction) { const row = new ActionRowBuilder().addComponents( new StringSelectMenuBuilder() .setCustomId('reaction_roles') - .setPlaceholder('Select your roles') + .setPlaceholder('Wähle deine Rollen') .setMinValues(0) .setMaxValues(roles.length) .addOptions( roles.map(role => ({ label: truncateText(role.name, SELECT_OPTION_LABEL_LIMIT), - description: truncateText(`Add/remove the ${role.name} role`, SELECT_OPTION_DESCRIPTION_LIMIT), + description: truncateText(`Rolle ${role.name} hinzufügen/entfernen`, SELECT_OPTION_DESCRIPTION_LIMIT), value: role.id, emoji: '🎭' })) @@ -289,10 +289,10 @@ async function handleSetup(interaction: ChatInputCommandInteraction) { .setDescription(description) .setColor(getColor('info')) .addFields({ - name: 'Available Roles', + name: 'Verfügbare Rollen', value: roles.map(role => `• ${role}`).join('\n') }) - .setFooter({ text: 'Select roles from the dropdown menu below' }); + .setFooter({ text: 'Wähle deine Rollen im Dropdown-Menü unten' }); const message = await channel.send({ embeds: [panelEmbed], @@ -323,32 +323,32 @@ async function handleSetup(interaction: ChatInputCommandInteraction) { guildId: interaction.guildId!, eventType: EVENT_TYPES.REACTION_ROLE_CREATE, data: { - description: `Reaction role panel created by ${interaction.user.tag}`, + description: `Reaktionsrollen-Panel erstellt von ${interaction.user.tag}`, userId: interaction.user.id, channelId: channel.id, fields: [ { - name: 'Title', + name: 'Titel', value: title, inline: false }, { - name: 'Channel', + name: 'Kanal', value: channel.toString(), inline: true }, { - name: 'Roles', - value: `${roles.length} roles`, + name: 'Rollen', + value: `${roles.length} Rollen`, inline: true }, { - name: 'Role List', + name: 'Rollenliste', value: roles.map(r => r.toString()).join(','), inline: false }, { - name: 'Message Link', + name: 'Nachrichtenlink', value: message.url, inline: false } @@ -360,7 +360,7 @@ async function handleSetup(interaction: ChatInputCommandInteraction) { } await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed('Success', `✅ Reaction role panel created in ${channel}!\n\n${message.url}`)] + embeds: [successEmbed('Erfolg', `✅ Reaktionsrollen-Panel in ${channel} erstellt!\n\n${message.url}`)] }); } @@ -390,25 +390,25 @@ async function rebuildLivePanelMessage(guild: Guild, panelData: SelectMenuPanel) const currentEmbed = msg.embeds[0]; const updatedEmbed = EmbedBuilder.from(currentEmbed); const fields = currentEmbed.fields.map(f => ({ name: f.name, value: f.value, inline: f.inline })); - const roleFieldIdx = fields.findIndex(f => f.name === 'Available Roles'); + const roleFieldIdx = fields.findIndex(f => (f.name === 'Verfügbare Rollen' || f.name === 'Available Roles')); const newRoleValue = roleObjects.map(r => `• ${r}`).join('\n'); if (roleFieldIdx !== -1) { - fields[roleFieldIdx] = { name: 'Available Roles', value: newRoleValue, inline: false }; + fields[roleFieldIdx] = { name: 'Verfügbare Rollen', value: newRoleValue, inline: false }; } else { - fields.push({ name: 'Available Roles', value: newRoleValue, inline: false }); + fields.push({ name: 'Verfügbare Rollen', value: newRoleValue, inline: false }); } updatedEmbed.setFields(fields); const selectRow = new ActionRowBuilder().addComponents( new StringSelectMenuBuilder() .setCustomId('reaction_roles') - .setPlaceholder('Select your roles') + .setPlaceholder('Wähle deine Rollen') .setMinValues(0) .setMaxValues(roleObjects.length) .addOptions( roleObjects.map(r => ({ label: r.name.substring(0, 100), - description: `Add/remove the ${r.name} role`.substring(0, 100), + description: `Rolle ${r.name} hinzufügen/entfernen`.substring(0, 100), value: r.id, emoji: '🎭', })), @@ -450,28 +450,28 @@ function buildReactionRoleDashboardPayload( panelStatus: PanelStatus | null = null, ) { const channel = guild.channels.cache.get(panelData.channelId) as GuildTextBasedChannel | undefined; - const title = discordMsg?.embeds?.[0]?.title ?? 'Untitled Panel'; + const title = discordMsg?.embeds?.[0]?.title ?? 'Panel ohne Titel'; const roleList = panelData.roles.length > 0 ? panelData.roles.map(id => `<@&${id}>`).join(',') - : '`None`'; + : '`Keine`'; const showRepost = panelStatus?.exists === false && panelStatus?.reason === 'panel_deleted'; const embed = new EmbedBuilder() - .setTitle('Reaction Roles Dashboard') + .setTitle('Reaktionsrollen-Dashboard') .setDescription( - `**Title:** ${title}\n\nSelect an option below to modify a setting.${discordMsg ? `\n[Click Here to View Panel](${discordMsg.url})` : ''}`, + `**Titel:** ${title}\n\nWähle unten eine Option, um eine Einstellung zu ändern.${discordMsg ? `\n[Hier klicken, um das Panel anzusehen](${discordMsg.url})` : ''}`, ) .setColor(getColor('info')) .addFields( - { name: 'Panel Status', value: formatPanelStatusField(panelStatus), inline: false }, - { name: 'Channel', value: channel ? `<#${channel.id}>` : '`Not found`', inline: true }, - { name: 'Roles', value: `\`${panelData.roles.length} / 25\``, inline: true }, + { name: 'Panel-Status', value: formatPanelStatusField(panelStatus), inline: false }, + { name: 'Kanal', value: channel ? `<#${channel.id}>` : '`Nicht gefunden`', inline: true }, + { name: 'Rollen', value: `\`${panelData.roles.length} / 25\``, inline: true }, { name: '\u200B', value: '\u200B', inline: true }, - { name: 'Role List', value: roleList, inline: false }, + { name: 'Rollenliste', value: roleList, inline: false }, ) - .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) + .setFooter({ text: 'Das Dashboard schließt sich nach 10 Minuten Inaktivität' }) .setTimestamp(); const buttons: ButtonBuilder[] = []; @@ -480,7 +480,7 @@ function buildReactionRoleDashboardPayload( buttons.push( new ButtonBuilder() .setCustomId(`rr_repost_${guildId}`) - .setLabel('Repost Panel') + .setLabel('Panel erneut posten') .setStyle(ButtonStyle.Primary) .setEmoji('📌'), ); @@ -489,30 +489,30 @@ function buildReactionRoleDashboardPayload( buttons.push( new ButtonBuilder() .setCustomId(`rr_edit_text_${guildId}`) - .setLabel('Edit Panel Text') + .setLabel('Panel-Text bearbeiten') .setStyle(ButtonStyle.Primary) .setEmoji('✏️'), new ButtonBuilder() .setCustomId(`rr_delete_${guildId}`) - .setLabel('Delete Panel') + .setLabel('Panel löschen') .setStyle(ButtonStyle.Danger) .setEmoji('🗑️'), ); const optionsSelect = new StringSelectMenuBuilder() .setCustomId(`rr_opts_${guildId}`) - .setPlaceholder('Select an action...') + .setPlaceholder('Wähle eine Aktion...') .addOptions( new StringSelectMenuOptionBuilder() - .setLabel('Add Role') - .setDescription('Add a role to this panel (up to 25 total)') + .setLabel('Rolle hinzufügen') + .setDescription('Rolle zu diesem Panel hinzufügen (max. 25)') .setValue('add_role') .setEmoji('➕'), ...(panelData.roles.length > 0 ? [ new StringSelectMenuOptionBuilder() - .setLabel('Remove Role') - .setDescription('Remove a role from this panel') + .setLabel('Rolle entfernen') + .setDescription('Rolle aus diesem Panel entfernen') .setValue('remove_role') .setEmoji('➖'), ] @@ -542,7 +542,7 @@ async function repostReactionRolePanel(guild: Guild, panelData: SelectMenuPanel, throw createError( 'Panel channel missing', ErrorTypes.CONFIGURATION, - 'The configured panel channel no longer exists.', + 'Der eingestellte Panel-Kanal existiert nicht mehr.', ); } @@ -551,32 +551,32 @@ async function repostReactionRolePanel(guild: Guild, panelData: SelectMenuPanel, throw createError( 'No valid roles', ErrorTypes.VALIDATION, - 'This panel has no valid roles left to repost.', + 'Dieses Panel hat keine gültigen Rollen mehr, die erneut gepostet werden können.', ); } - const title = fallbackEmbed?.title || 'Reaction Roles'; - const description = fallbackEmbed?.description || 'Select your roles using the menu below.'; + const title = fallbackEmbed?.title || 'Reaktionsrollen'; + const description = fallbackEmbed?.description || 'Wähle deine Rollen über das Menü unten.'; const panelEmbed = new EmbedBuilder() .setTitle(title) .setDescription(description) .setColor(getColor('info')) .addFields({ - name: 'Available Roles', + name: 'Verfügbare Rollen', value: roleObjects.map(role => `• ${role}`).join('\n'), }); const row = new ActionRowBuilder().addComponents( new StringSelectMenuBuilder() .setCustomId('reaction_roles') - .setPlaceholder('Select your roles') + .setPlaceholder('Wähle deine Rollen') .setMinValues(0) .setMaxValues(roleObjects.length) .addOptions( roleObjects.map(role => ({ label: role.name.substring(0, 100), - description: `Add/remove the ${role.name} role`.substring(0, 100), + description: `Rolle ${role.name} hinzufügen/entfernen`.substring(0, 100), value: role.id, emoji: '🎭', })), @@ -601,7 +601,7 @@ async function handleDashboard(interaction: ChatInputCommandInteraction, selecte throw createError( 'No panels', ErrorTypes.CONFIGURATION, - 'No reaction role panels found. Use `/reactroles setup` first.', + 'Keine Reaktionsrollen-Panels gefunden. Nutze zuerst `/reactroles setup`.', ); } @@ -614,7 +614,7 @@ async function handleDashboard(interaction: ChatInputCommandInteraction, selecte throw createError( 'Panel required', ErrorTypes.VALIDATION, - 'Multiple panels exist. Choose one using the **panel** option.', + 'Es gibt mehrere Panels. Wähle eins über die Option **panel**.', ); } } @@ -657,7 +657,7 @@ async function handleDashboard(interaction: ChatInputCommandInteraction, selecte fallbackEmbed, ); await btnInteraction.followUp({ - embeds: [successEmbed('Panel Reposted', `Reaction role panel restored in ${newMsg.channel}.`)], + embeds: [successEmbed('Panel erneut gepostet', `Reaktionsrollen-Panel in ${newMsg.channel} wiederhergestellt.`)], flags: MessageFlags.Ephemeral, }); await showPanelDashboard( @@ -695,12 +695,12 @@ async function handleEditText(buttonInteraction: ButtonInteraction, rootInteract const modal = new ModalBuilder() .setCustomId('rr_edit_text') - .setTitle('Edit Panel Text') + .setTitle('Panel-Text bearbeiten') .addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('panel_title') - .setLabel('Title') + .setLabel('Titel') .setStyle(TextInputStyle.Short) .setValue(currentTitle) .setMaxLength(256) @@ -710,7 +710,7 @@ async function handleEditText(buttonInteraction: ButtonInteraction, rootInteract new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('panel_description') - .setLabel('Description') + .setLabel('Beschreibung') .setStyle(TextInputStyle.Paragraph) .setValue(currentDesc) .setMaxLength(2048) @@ -725,7 +725,7 @@ async function handleEditText(buttonInteraction: ButtonInteraction, rootInteract logger.error('Error showing edit text modal:', error); await replyUserError(buttonInteraction, { type: ErrorTypes.UNKNOWN, - message: 'Failed to show the edit panel text modal. Please try again.', + message: 'Das Formular zum Bearbeiten konnte nicht geöffnet werden. Bitte versuche es erneut.', }).catch(() => {}); return; } @@ -751,12 +751,12 @@ async function handleEditText(buttonInteraction: ButtonInteraction, rootInteract .setDescription(newDescription); if (roleObjects.length > 0) { const fields = discordMsg.embeds[0].fields?.map(f => ({ name: f.name, value: f.value, inline: f.inline })) || []; - const roleFieldIdx = fields.findIndex(f => f.name === 'Available Roles'); + const roleFieldIdx = fields.findIndex(f => (f.name === 'Verfügbare Rollen' || f.name === 'Available Roles')); const newRoleValue = roleObjects.map(r => `• ${r}`).join('\n'); if (roleFieldIdx !== -1) { - fields[roleFieldIdx] = { name: 'Available Roles', value: newRoleValue, inline: false }; + fields[roleFieldIdx] = { name: 'Verfügbare Rollen', value: newRoleValue, inline: false }; } else { - fields.push({ name: 'Available Roles', value: newRoleValue, inline: false }); + fields.push({ name: 'Verfügbare Rollen', value: newRoleValue, inline: false }); } updatedEmbed.setFields(fields); } @@ -764,7 +764,7 @@ async function handleEditText(buttonInteraction: ButtonInteraction, rootInteract } await submitted.reply({ - embeds: [successEmbed('Panel Updated', 'The title and description have been updated.')], + embeds: [successEmbed('Panel aktualisiert', 'Titel und Beschreibung wurden aktualisiert.')], flags: MessageFlags.Ephemeral, }); @@ -780,22 +780,22 @@ async function handleAddRole(selectInteraction: StringSelectMenuInteraction, roo if (panelData.roles.length >= 25) { await replyUserError(selectInteraction, { type: ErrorTypes.VALIDATION, - message: 'This panel already has the maximum of 25 roles.', + message: 'Dieses Panel hat bereits das Maximum von 25 Rollen.', }); return; } const roleSelect = new RoleSelectMenuBuilder() .setCustomId('rr_add_role_pick') - .setPlaceholder('Select a role to add...') + .setPlaceholder('Wähle eine Rolle zum Hinzufügen...') .setMaxValues(1); await selectInteraction.followUp({ embeds: [ new EmbedBuilder() - .setTitle('Add Role') + .setTitle('Rolle hinzufügen') .setDescription( - `**Current roles:** ${panelData.roles.length}/25\n\nSelect a role to add to this panel.`, + `**Aktuelle Rollen:** ${panelData.roles.length}/25\n\nWähle eine Rolle, die zu diesem Panel hinzugefügt werden soll.`, ) .setColor(getColor('info')), ], @@ -818,35 +818,35 @@ async function handleAddRole(selectInteraction: StringSelectMenuInteraction, roo if (panelData.roles.includes(role.id)) { await replyUserError(roleInteraction, { type: ErrorTypes.VALIDATION, - message: `${role} is already in this panel.`, + message: `${role} ist bereits in diesem Panel.`, }); return; } if (role.id === guild.id) { await replyUserError(roleInteraction, { type: ErrorTypes.VALIDATION, - message: 'You cannot use @everyone.', + message: 'Du kannst @everyone nicht verwenden.', }); return; } if (role.managed) { await replyUserError(roleInteraction, { type: ErrorTypes.VALIDATION, - message: 'Managed/bot roles cannot be used.', + message: 'Verwaltete Rollen und Bot-Rollen können nicht verwendet werden.', }); return; } if (hasDangerousPermissions(role)) { await replyUserError(roleInteraction, { type: ErrorTypes.PERMISSION, - message: 'That role has sensitive permissions (Administrator, Manage Server, etc.) and cannot be used.', + message: 'Diese Rolle hat sensible Berechtigungen (Administrator, Server verwalten usw.) und kann nicht verwendet werden.', }); return; } if (role.position >= guild.members.me!.roles.highest.position) { await replyUserError(roleInteraction, { type: ErrorTypes.PERMISSION, - message: "That role is above my highest role in the hierarchy. Move my role above it first.", + message: "Diese Rolle steht in der Hierarchie über meiner höchsten Rolle. Verschiebe meine Rolle zuerst darüber.", }); return; } @@ -858,7 +858,7 @@ async function handleAddRole(selectInteraction: StringSelectMenuInteraction, roo await rebuildLivePanelMessage(guild, panelData); await roleInteraction.followUp({ - embeds: [successEmbed('Role Added', `${role} has been added to the panel.`)], + embeds: [successEmbed('Rolle hinzugefügt', `${role} wurde zum Panel hinzugefügt.`)], flags: MessageFlags.Ephemeral, }); @@ -873,7 +873,7 @@ async function handleAddRole(selectInteraction: StringSelectMenuInteraction, roo if (reason === 'time' && collected.size === 0) { replyUserError(selectInteraction, { type: ErrorTypes.RATE_LIMIT, - message: 'No role selected. Nothing was changed.', + message: 'Keine Rolle ausgewählt. Es wurde nichts geändert.', }).catch(() => {}); } }); @@ -892,14 +892,14 @@ async function handleRemoveRole(selectInteraction: StringSelectMenuInteraction, if (roleOptions.length === 0) { await replyUserError(selectInteraction, { type: ErrorTypes.USER_INPUT, - message: 'The roles on this panel no longer exist in the server.', + message: 'Die Rollen dieses Panels existieren auf dem Server nicht mehr.', }); return; } const removeSelect = new StringSelectMenuBuilder() .setCustomId('rr_remove_role_pick') - .setPlaceholder('Select a role to remove...') + .setPlaceholder('Wähle eine Rolle zum Entfernen...') .setMaxValues(1) .addOptions( roleOptions.map(r => @@ -910,8 +910,8 @@ async function handleRemoveRole(selectInteraction: StringSelectMenuInteraction, await selectInteraction.followUp({ embeds: [ new EmbedBuilder() - .setTitle('Remove Role') - .setDescription('Select the role you want to remove from this panel.') + .setTitle('Rolle entfernen') + .setDescription('Wähle die Rolle, die du aus diesem Panel entfernen willst.') .setColor(getColor('info')), ], components: [new ActionRowBuilder().addComponents(removeSelect)], @@ -944,8 +944,8 @@ async function handleRemoveRole(selectInteraction: StringSelectMenuInteraction, await removeInteraction.followUp({ embeds: [ successEmbed( - '✅ Role Removed', - 'That was the last role on the panel. The panel has been deleted.', + '✅ Rolle entfernt', + 'Das war die letzte Rolle im Panel. Das Panel wurde gelöscht.', ), ], flags: MessageFlags.Ephemeral, @@ -960,8 +960,8 @@ async function handleRemoveRole(selectInteraction: StringSelectMenuInteraction, await InteractionHelper.safeEditReply(rootInteraction, { embeds: [ new EmbedBuilder() - .setTitle('Reaction Roles Dashboard') - .setDescription('No panels remain. Use `/reactroles setup` to create one.') + .setTitle('Reaktionsrollen-Dashboard') + .setDescription('Keine Panels mehr vorhanden. Nutze `/reactroles setup`, um eins zu erstellen.') .setColor(getColor('info')), ], components: [], @@ -972,8 +972,8 @@ async function handleRemoveRole(selectInteraction: StringSelectMenuInteraction, await InteractionHelper.safeEditReply(rootInteraction, { embeds: [ new EmbedBuilder() - .setTitle('Reaction Roles Dashboard') - .setDescription('Panel deleted. Run `/reactroles dashboard` to manage another panel.') + .setTitle('Reaktionsrollen-Dashboard') + .setDescription('Panel gelöscht. Führe `/reactroles dashboard` aus, um ein anderes Panel zu verwalten.') .setColor(getColor('success')), ], components: [], @@ -988,8 +988,8 @@ async function handleRemoveRole(selectInteraction: StringSelectMenuInteraction, await removeInteraction.followUp({ embeds: [ successEmbed( - '✅ Role Removed', - `${role ? role.toString() :`<@&${roleId}>`} has been removed from the panel.`, + '✅ Rolle entfernt', + `${role ? role.toString() :`<@&${roleId}>`} wurde aus dem Panel entfernt.`, ), ], flags: MessageFlags.Ephemeral, @@ -1007,7 +1007,7 @@ async function handleRemoveRole(selectInteraction: StringSelectMenuInteraction, if (reason === 'time' && collected.size === 0) { replyUserError(selectInteraction, { type: ErrorTypes.RATE_LIMIT, - message: 'No role selected. Nothing was changed.', + message: 'Keine Rolle ausgewählt. Es wurde nichts geändert.', }).catch(() => {}); } }); @@ -1018,21 +1018,21 @@ async function handleDeletePanel(btnInteraction: ButtonInteraction, rootInteract const discordMsg = channel ? await channel.messages.fetch(panelData.messageId).catch(() => null) : null; - const title = discordMsg?.embeds?.[0]?.title ?? 'this panel'; + const title = discordMsg?.embeds?.[0]?.title ?? 'dieses Panel'; const deleteModal = new ModalBuilder() .setCustomId('rr_delete_confirm_modal') - .setTitle('Delete Reaction Role Panel'); + .setTitle('Reaktionsrollen-Panel löschen'); const deleteWarningText = new TextDisplayBuilder() - .setContent(`⚠️ You are about to permanently delete the panel **${title}**. This will remove the Discord message and all associated reaction role assignments.`); + .setContent(`⚠️ Du bist dabei, das Panel **${title}** dauerhaft zu löschen. Dabei werden die Discord-Nachricht und alle zugehörigen Reaktionsrollen-Zuweisungen entfernt.`); const deleteCheckbox = new CheckboxBuilder() .setCustomId('delete_confirmation') .setDefault(false); const deleteCheckboxLabel = new LabelBuilder() - .setLabel('I confirm — this cannot be undone') + .setLabel('Ich bestätige – das ist endgültig') .setCheckboxComponent(deleteCheckbox); deleteModal @@ -1056,7 +1056,7 @@ async function handleDeletePanel(btnInteraction: ButtonInteraction, rootInteract const confirmed = submitted.fields.getCheckbox('delete_confirmation'); if (!confirmed) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'You must tick the confirmation checkbox to delete the panel.' }); + await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Du musst das Bestätigungskästchen anhaken, um das Panel zu löschen.' }); await showPanelDashboard(rootInteraction, panelData, discordMsg, guildId, guild, client); return; } @@ -1074,12 +1074,12 @@ async function handleDeletePanel(btnInteraction: ButtonInteraction, rootInteract guildId, eventType: EVENT_TYPES.REACTION_ROLE_DELETE, data: { - description: `Reaction role panel deleted by ${submitted.user.tag}`, + description: `Reaktionsrollen-Panel gelöscht von ${submitted.user.tag}`, userId: submitted.user.id, channelId: panelData.channelId, fields: [ { name: 'Panel', value: title, inline: true }, - { name: 'Channel', value: channel ? channel.toString() : 'Unknown', inline: true }, + { name: 'Kanal', value: channel ? channel.toString() : 'Unbekannt', inline: true }, ], }, }); @@ -1088,7 +1088,7 @@ async function handleDeletePanel(btnInteraction: ButtonInteraction, rootInteract } await submitted.followUp({ - embeds: [successEmbed('Panel Deleted', `**${title}** has been deleted.`)], + embeds: [successEmbed('Panel gelöscht', `**${title}** wurde gelöscht.`)], flags: MessageFlags.Ephemeral, }); @@ -1101,8 +1101,8 @@ async function handleDeletePanel(btnInteraction: ButtonInteraction, rootInteract await InteractionHelper.safeEditReply(rootInteraction, { embeds: [ new EmbedBuilder() - .setTitle('Reaction Roles Dashboard') - .setDescription('No panels remain. Use `/reactroles setup` to create one.') + .setTitle('Reaktionsrollen-Dashboard') + .setDescription('Keine Panels mehr vorhanden. Nutze `/reactroles setup`, um eins zu erstellen.') .setColor(getColor('info')), ], components: [], @@ -1112,8 +1112,8 @@ async function handleDeletePanel(btnInteraction: ButtonInteraction, rootInteract await InteractionHelper.safeEditReply(rootInteraction, { embeds: [ new EmbedBuilder() - .setTitle('Reaction Roles Dashboard') - .setDescription('Panel deleted. Run `/reactroles dashboard` to manage another panel.') + .setTitle('Reaktionsrollen-Dashboard') + .setDescription('Panel gelöscht. Führe `/reactroles dashboard` aus, um ein anderes Panel zu verwalten.') .setColor(getColor('success')), ], components: [], diff --git a/src/commands/Search/modules/search_define.ts b/src/commands/Search/modules/search_define.ts index 9d2e0fb517..dba8276325 100644 --- a/src/commands/Search/modules/search_define.ts +++ b/src/commands/Search/modules/search_define.ts @@ -37,7 +37,7 @@ export default { word: word, guildId: interaction.guildId }); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Please enter a word with at least 2 characters.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Bitte gib ein Wort mit mindestens 2 Zeichen ein.' }); } const response = await axios.get( @@ -46,7 +46,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: `Keine Definitionen für „${word}“ gefunden.` }); } const data = response.data[0]; @@ -62,7 +62,7 @@ export default { .map((def, idx) => { let text = `${idx + 1}. ${def.definition}`; if (def.example) { - text += `\n *Example: ${def.example}*`; + text += `\n *Beispiel: ${def.example}*`; } return text; }) @@ -77,7 +77,7 @@ export default { } }); - embed.setFooter({ text: 'Powered by Free Dictionary API' }); + embed.setFooter({ text: 'Bereitgestellt von der Free Dictionary API' }); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); @@ -100,7 +100,7 @@ export default { }); if (err.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: `Keine Definitionen für „${interaction.options.getString('word')}“ gefunden.` }); } else { await handleInteractionError(interaction, error, { commandName: 'define', diff --git a/src/commands/Search/modules/search_google.ts b/src/commands/Search/modules/search_google.ts index 179d5e0872..4ae900a16e 100644 --- a/src/commands/Search/modules/search_google.ts +++ b/src/commands/Search/modules/search_google.ts @@ -9,11 +9,11 @@ export default { const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}`; const embed = createEmbed({ - title: 'Google Search', - description: `[Search for "${query}"](${searchUrl})`, + title: 'Google-Suche', + description: `[Nach „${query}“ suchen](${searchUrl})`, color: 'info' }) - .setFooter({ text: 'Google Search Results' }); + .setFooter({ text: 'Google-Suchergebnisse' }); await InteractionHelper.safeReply(interaction, { embeds: [embed] }); diff --git a/src/commands/Search/modules/search_urban.ts b/src/commands/Search/modules/search_urban.ts index 3bc36280da..22331b839c 100644 --- a/src/commands/Search/modules/search_urban.ts +++ b/src/commands/Search/modules/search_urban.ts @@ -30,7 +30,7 @@ export default { term: term, guildId: interaction.guildId }); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Please enter a term with at least 2 characters.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Bitte gib einen Begriff mit mindestens 2 Zeichen ein.' }); } let deferTimer: ReturnType | null = null; @@ -58,7 +58,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: `Keine Definitionen für „${term}“ im Urban Dictionary gefunden.` }); } const definition = response.data.list![0]; @@ -71,7 +71,7 @@ export default { const formattedExample = cleanExample ? `*"${cleanExample.replace(/\n/g, ' ').slice(0, 500)}..."*` - : '*No example provided*'; + : '*Kein Beispiel vorhanden*'; const embed = createEmbed({ title: definition.word, @@ -81,18 +81,18 @@ export default { .setURL(definition.permalink) .addFields( { - name: 'Example', + name: 'Beispiel', value: formattedExample, inline: false }, { - name: 'Stats', + name: 'Statistik', value: `${definition.thumbs_up.toLocaleString()} • ${definition.thumbs_down.toLocaleString()}`, inline: true }, { - name: 'Author', - value: definition.author || 'Anonymous', + name: 'Autor', + value: definition.author || 'Anonym', inline: true } ) @@ -123,9 +123,9 @@ export default { }); if (err.response?.status === 404 || !err.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: `Keine Definitionen für „${interaction.options.getString('term')}“ im Urban Dictionary gefunden.` }); } else if (err.response?.status === 429) { - await replyUserError(interaction, { type: ErrorTypes.RATE_LIMIT, message: 'Too many requests to Urban Dictionary. Please try again in a few minutes.' }); + await replyUserError(interaction, { type: ErrorTypes.RATE_LIMIT, message: 'Zu viele Anfragen an das Urban Dictionary. Bitte versuch es in ein paar Minuten noch einmal.' }); } else { await handleInteractionError(interaction, error, { commandName: 'urban', diff --git a/src/commands/Search/search.ts b/src/commands/Search/search.ts index 0d3b3defeb..6861792048 100644 --- a/src/commands/Search/search.ts +++ b/src/commands/Search/search.ts @@ -9,32 +9,32 @@ import searchUrban from './modules/search_urban.js'; export default { data: new SlashCommandBuilder() .setName('search') - .setDescription('Search the web and dictionaries') + .setDescription('Im Web und in Wörterbüchern suchen') .addSubcommand(subcommand => subcommand .setName('define') - .setDescription('Look up a word definition') + .setDescription('Die englische Definition eines Worts nachschlagen') .addStringOption(option => option.setName('word') - .setDescription('The word to look up') + .setDescription('Das (englische) Wort, das du nachschlagen willst') .setRequired(true)) ) .addSubcommand(subcommand => subcommand .setName('google') - .setDescription('Search Google') + .setDescription('Bei Google suchen') .addStringOption(option => option.setName('query') - .setDescription('What would you like to search for?') + .setDescription('Wonach möchtest du suchen?') .setRequired(true)) ) .addSubcommand(subcommand => subcommand .setName('urban') - .setDescription('Search Urban Dictionary for definitions') + .setDescription('Definitionen im Urban Dictionary suchen') .addStringOption(option => option.setName('term') - .setDescription('The term to look up on Urban Dictionary') + .setDescription('Der Begriff, den du im Urban Dictionary suchen willst') .setRequired(true)) ), @@ -49,7 +49,7 @@ export default { case 'urban': return await searchUrban.execute(interaction); default: - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Unknown subcommand' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Unbekannter Subcommand' }); } } } satisfies Command; diff --git a/src/commands/ServerStats/modules/serverstats_create.ts b/src/commands/ServerStats/modules/serverstats_create.ts index ce092aeabb..44e6f00771 100644 --- a/src/commands/ServerStats/modules/serverstats_create.ts +++ b/src/commands/ServerStats/modules/serverstats_create.ts @@ -22,13 +22,13 @@ export async function handleCreate(interaction: ChatInputCommandInteraction, cli } if (!(interaction.member as GuildMember).permissions.has(PermissionFlagsBits.ManageChannels)) { - await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need **Manage Channels** permission to create counters.' }).catch(logger.error); + await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Du brauchst die Berechtigung **Kanäle verwalten**, um Zähler zu erstellen.' }).catch(logger.error); return; } try { if (!category || category.type !== ChannelType.GuildCategory) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Please select a valid category for the counter channel.' }).catch(logger.error); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Bitte wähle eine gültige Kategorie für den Zählerkanal.' }).catch(logger.error); return; } @@ -41,7 +41,7 @@ export async function handleCreate(interaction: ChatInputCommandInteraction, cli 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: `Auf diesem Server gibt es bereits einen **${getCounterTypeLabel(type)}**-Zähler${duplicateChannel ? ` in ${duplicateChannel}` : ''}. Lösche ihn zuerst, bevor du einen neuen erstellst.` }).catch(logger.error); return; } @@ -49,12 +49,12 @@ export async function handleCreate(interaction: ChatInputCommandInteraction, cli name: baseChannelName, type: targetChannelType, parent: category.id, - reason: `Counter channel created by ${interaction.user.tag}` + reason: `Zählerkanal erstellt von ${interaction.user.tag}` }); 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: `Für den Kanal **${targetChannel.name}** gibt es bereits einen Zähler. Lösche ihn zuerst oder wähle einen anderen Typ.` }).catch(logger.error); return; } @@ -71,23 +71,23 @@ export async function handleCreate(interaction: ChatInputCommandInteraction, cli const saved = await saveServerCounters(client, guild.id, counters); if (!saved) { - await targetChannel.delete('Counter creation failed during save').catch(() => null); - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Failed to save counter data. Please try again.' }).catch(logger.error); + await targetChannel.delete('Zählererstellung beim Speichern fehlgeschlagen').catch(() => null); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Zählerdaten konnten nicht gespeichert werden. Bitte versuche es erneut.' }).catch(logger.error); return; } const updated = await updateCounter(client, guild, newCounter); if (!updated) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Counter created but failed to update channel name. The counter will update on the next scheduled run.' }).catch(logger.error); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Zähler erstellt, aber der Kanalname konnte nicht aktualisiert werden. Er wird beim nächsten geplanten Durchlauf aktualisiert.' }).catch(logger.error); return; } 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 \`/serverstats list\` to view all counters.`)] + embeds: [successEmbed(`**Zähler erfolgreich erstellt!**\n\n**Typ:** ${getCounterTypeLabel(type)}\n**Kanaltyp:** ${targetChannel.type === ChannelType.GuildVoice ? 'Sprachkanal' : 'Textkanal'}\n**Kategorie:** ${category}\n**Kanal:** ${targetChannel}\n**Kanalname:** ${targetChannel.name}\n**Zähler-ID:** \`${newCounter.id}\`\n\nDer Zähler wird automatisch alle 15 Minuten aktualisiert.\n\nNutze \`/serverstats list\`, um alle Zähler anzusehen.`)] }).catch(logger.error); } catch (error) { logger.error("Error creating counter:", error); - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while creating the counter. Please try again.' }).catch(logger.error); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Erstellen des Zählers ist ein Fehler aufgetreten. Bitte versuche es erneut.' }).catch(logger.error); } } \ No newline at end of file diff --git a/src/commands/ServerStats/modules/serverstats_delete.ts b/src/commands/ServerStats/modules/serverstats_delete.ts index dd47ce7f7b..5fe3892668 100644 --- a/src/commands/ServerStats/modules/serverstats_delete.ts +++ b/src/commands/ServerStats/modules/serverstats_delete.ts @@ -20,7 +20,7 @@ export async function handleDelete(interaction: ChatInputCommandInteraction, cli } if (!(interaction.member as GuildMember).permissions.has(PermissionFlagsBits.ManageChannels)) { - await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need **Manage Channels** permission to delete counters.' }).catch(logger.error); + await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Du brauchst die Berechtigung **Kanäle verwalten**, um Zähler zu löschen.' }).catch(logger.error); return; } @@ -28,32 +28,32 @@ export async function handleDelete(interaction: ChatInputCommandInteraction, cli const counters = await getServerCounters(client, guild.id); if (counters.length === 0) { - await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'No counters found to delete.' }).catch(logger.error); + await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Keine Zähler zum Löschen gefunden.' }).catch(logger.error); return; } 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 \`/serverstats list\` to see all counters.` }).catch(logger.error); + await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: `Zähler mit der ID \`${counterId}\` nicht gefunden. Nutze \`/serverstats list\`, um alle Zähler anzusehen.` }).catch(logger.error); return; } const channel = guild.channels.cache.get(counterToDelete.channelId); const embed = createEmbed({ - title: "Delete Counter & Channel", - description: `Are you sure you want to delete this counter and its channel?\n\n**ID:** \`${counterToDelete.id}\`\n**Type:** ${getCounterTypeDisplay(counterToDelete.type)}\n**Channel:** ${channel || 'Deleted Channel'}\n\n **The channel will be permanently deleted!**`, + title: "Zähler & Kanal löschen", + description: `Willst du diesen Zähler und seinen Kanal wirklich löschen?\n\n**ID:** \`${counterToDelete.id}\`\n**Typ:** ${getCounterTypeDisplay(counterToDelete.type)}\n**Kanal:** ${channel || 'Gelöschter Kanal'}\n\n **Der Kanal wird dauerhaft gelöscht!**`, color: getColor('error') }); const row = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`counter-delete:confirm:${counterToDelete.id}:${interaction.user.id}`) - .setLabel("Confirm Delete") + .setLabel("Löschen bestätigen") .setStyle(ButtonStyle.Danger), new ButtonBuilder() .setCustomId(`counter-delete:cancel:${counterToDelete.id}:${interaction.user.id}`) - .setLabel("Cancel") + .setLabel("Abbrechen") .setStyle(ButtonStyle.Secondary) ); @@ -61,7 +61,7 @@ export async function handleDelete(interaction: ChatInputCommandInteraction, cli } catch (error) { logger.error("Error in handleDelete:", error); - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while fetching counters. Please try again.' }).catch(logger.error); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Abrufen der Zähler ist ein Fehler aufgetreten. Bitte versuche es erneut.' }).catch(logger.error); } } @@ -73,7 +73,7 @@ export const performDeletionByCounterId = wrapServiceBoundary(async function per throw createError( 'Counter not found', ErrorTypes.USER_INPUT, - `Counter with ID \`${counterId}\` was not found.`, + `Zähler mit der ID \`${counterId}\` wurde nicht gefunden.`, { guildId: guild.id, counterId, operation: 'performDeletionByCounterId' } ); } @@ -85,7 +85,7 @@ export const performDeletionByCounterId = wrapServiceBoundary(async function per throw createError( 'Counter delete failed', ErrorTypes.DATABASE, - 'Failed to delete counter. Please try again.', + 'Zähler konnte nicht gelöscht werden. Bitte versuche es erneut.', { guildId: guild.id, counterId, operation: 'performDeletionByCounterId' } ); } @@ -95,28 +95,28 @@ export const performDeletionByCounterId = wrapServiceBoundary(async function per if (channel) { try { - await channel.delete(`Counter deleted - removing channel: ${counter.id}`); + await channel.delete(`Zähler gelöscht – Kanal wird entfernt: ${counter.id}`); channelDeleted = true; } catch (error) { logger.error("Error deleting channel:", error); } } - let message = `✅ **Counter Deleted Successfully!**\n\n**ID:** \`${counter.id}\`\n**Type:** ${getCounterTypeDisplay(counter.type)}`; + let message = `✅ **Zähler erfolgreich gelöscht!**\n\n**ID:** \`${counter.id}\`\n**Typ:** ${getCounterTypeDisplay(counter.type)}`; if (channelDeleted) { - message += `\n**Channel:** ${channel!.name} (deleted)`; + message += `\n**Kanal:** ${channel!.name} (gelöscht)`; } else if (channel) { - message += `\n**Channel:** ${channel.name} (failed to delete)`; + message += `\n**Kanal:** ${channel.name} (Löschen fehlgeschlagen)`; } else { - message += `\n**Channel:** Already deleted`; + message += `\n**Kanal:** Bereits gelöscht`; } return { message }; }, { service: 'serverstats', operation: 'performDeletionByCounterId', - userMessage: 'An error occurred while deleting the counter. Please try again.', + userMessage: 'Beim Löschen des Zählers ist ein Fehler aufgetreten. Bitte versuche es erneut.', }); function getCounterTypeDisplay(type: string) { diff --git a/src/commands/ServerStats/modules/serverstats_list.ts b/src/commands/ServerStats/modules/serverstats_list.ts index 1eb61b2d1a..9a22827888 100644 --- a/src/commands/ServerStats/modules/serverstats_list.ts +++ b/src/commands/ServerStats/modules/serverstats_list.ts @@ -20,7 +20,7 @@ export async function handleList(interaction: ChatInputCommandInteraction, clien } if (!(interaction.member as GuildMember).permissions.has(PermissionFlagsBits.ManageChannels)) { - await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need **Manage Channels** permission to view counters.' }).catch(logger.error); + await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Du brauchst die Berechtigung **Kanäle verwalten**, um Zähler anzusehen.' }).catch(logger.error); return; } @@ -48,25 +48,25 @@ export async function handleList(interaction: ChatInputCommandInteraction, clien if (validCounters.length === 0) { const embed = createEmbed({ - title: "Server Counters", - description: "No counters have been set up for this server yet.\n\nUse `/serverstats create` to set up your first counter!", + title: "Server-Zähler", + description: "Für diesen Server wurden noch keine Zähler eingerichtet.\n\nNutze `/serverstats create`, um deinen ersten Zähler einzurichten!", color: getColor('warning') }); embed.addFields({ - name: "**Available Counter Types**", - value: "**Members + Bots** - Total server members\n **Members Only** - Human members only\n **Bots Only** - Bot members only", + name: "**Verfügbare Zählertypen**", + value: "**Mitglieder + Bots** - Alle Servermitglieder\n **Nur Mitglieder** - Nur menschliche Mitglieder\n **Nur Bots** - Nur Bot-Mitglieder", inline: false }); embed.addFields({ - name: "**Usage Examples**", + name: "**Beispiele**", 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 }); embed.setFooter({ - text: "Counter System • Automatic updates every 15 minutes" + text: "Zählersystem • Automatische Aktualisierung alle 15 Minuten" }); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }).catch(logger.error); @@ -74,8 +74,8 @@ export async function handleList(interaction: ChatInputCommandInteraction, clien } const embed = createEmbed({ - title: `Server Counters (${validCounters.length})`, - description: "Here are all the active counters for this server.\n\nCounters automatically update every 15 minutes.", + title: `Server-Zähler (${validCounters.length})`, + description: "Hier sind alle aktiven Zähler dieses Servers.\n\nZähler werden automatisch alle 15 Minuten aktualisiert.", color: getColor('info') }); @@ -90,32 +90,32 @@ export async function handleList(interaction: ChatInputCommandInteraction, clien } const currentCount = getCurrentCount(stats, counter.type); - const status = channel.name.includes(':') ? '✅ Active' : '⚠️ Not Updated'; + const status = channel.name.includes(':') ? '✅ Aktiv' : '⚠️ Nicht aktualisiert'; embed.addFields({ - name: `${getCounterTypeEmoji(counter.type)} Counter #${i + 1} - ${channel.name}`, - value: `**ID:** \`${counter.id}\`\n**Type:** ${getCounterTypeDisplay(counter.type)}\n**Channel:** ${channel}\n**Current Count:** ${currentCount}\n**Status:** ${status}\n**Created:** ${new Date(counter.createdAt).toLocaleDateString()}`, + name: `${getCounterTypeEmoji(counter.type)} Zähler #${i + 1} - ${channel.name}`, + value: `**ID:** \`${counter.id}\`\n**Typ:** ${getCounterTypeDisplay(counter.type)}\n**Kanal:** ${channel}\n**Aktueller Stand:** ${currentCount}\n**Status:** ${status}\n**Erstellt:** ${new Date(counter.createdAt).toLocaleDateString()}`, inline: false }); } embed.addFields({ - name: "**Statistics**", - value: `**Total Counters:** ${validCounters.length}\n**Active Counters:** ${validCounters.filter(c => { + name: "**Statistiken**", + value: `**Zähler gesamt:** ${validCounters.length}\n**Aktive Zähler:** ${validCounters.filter(c => { const channel = guild.channels.cache.get(c.channelId); return channel && channel.name.includes(':'); - }).length}\n**Next Update:** `, + }).length}\n**Nächste Aktualisierung:** `, inline: false }); embed.addFields({ - name: "**Management Commands**", - value: "`/serverstats create` - Create new counter\n`/serverstats update` - Update existing counter\n`/serverstats delete` - Delete counter", + name: "**Verwaltungs-Commands**", + value: "`/serverstats create` - Neuen Zähler erstellen\n`/serverstats update` - Bestehenden Zähler aktualisieren\n`/serverstats delete` - Zähler löschen", inline: false }); embed.setFooter({ - text: "Counter System • Automatic updates every 15 minutes" + text: "Zählersystem • Automatische Aktualisierung alle 15 Minuten" }); embed.setTimestamp(); @@ -123,7 +123,7 @@ export async function handleList(interaction: ChatInputCommandInteraction, clien } catch (error) { logger.error("Error displaying counters:", error); - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while fetching counters. Please try again.' }).catch(logger.error); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Abrufen der Zähler ist ein Fehler aufgetreten. Bitte versuche es erneut.' }).catch(logger.error); } } diff --git a/src/commands/ServerStats/modules/serverstats_update.ts b/src/commands/ServerStats/modules/serverstats_update.ts index 46c69bffae..f28113c94c 100644 --- a/src/commands/ServerStats/modules/serverstats_update.ts +++ b/src/commands/ServerStats/modules/serverstats_update.ts @@ -20,12 +20,12 @@ export async function handleUpdate(interaction: ChatInputCommandInteraction, cli } if (!(interaction.member as GuildMember).permissions.has(PermissionFlagsBits.ManageChannels)) { - await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need **Manage Channels** permission to update counters.' }).catch(logger.error); + await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Du brauchst die Berechtigung **Kanäle verwalten**, um Zähler zu aktualisieren.' }).catch(logger.error); return; } if (!newType) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You must provide a new counter type to update.' }).catch(logger.error); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Du musst einen neuen Zählertyp angeben.' }).catch(logger.error); return; } @@ -34,7 +34,7 @@ export async function handleUpdate(interaction: ChatInputCommandInteraction, cli 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 \`/serverstats list\` to see all counters.` }).catch(logger.error); + await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: `Zähler mit der ID \`${counterId}\` nicht gefunden. Nutze \`/serverstats list\`, um alle Zähler anzusehen.` }).catch(logger.error); return; } @@ -42,7 +42,7 @@ export async function handleUpdate(interaction: ChatInputCommandInteraction, cli const oldChannel = guild.channels.cache.get(counter.channelId); if (!oldChannel) { - await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'The channel for this counter no longer exists. You cannot update a counter for a deleted channel.' }).catch(logger.error); + await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Der Kanal dieses Zählers existiert nicht mehr. Zähler gelöschter Kanäle können nicht aktualisiert werden.' }).catch(logger.error); return; } @@ -50,7 +50,7 @@ export async function handleUpdate(interaction: ChatInputCommandInteraction, cli 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: `Auf diesem Server gibt es bereits einen **${getCounterTypeLabel(newType)}**-Zähler${existingChannel ? ` in ${existingChannel}` : ''}. Lösche ihn zuerst, bevor du diesen Typ erneut verwendest.` }).catch(logger.error); return; } } @@ -62,25 +62,25 @@ export async function handleUpdate(interaction: ChatInputCommandInteraction, cli const saved = await saveServerCounters(client, guild.id, counters); if (!saved) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Failed to save updated counter data. Please try again.' }).catch(logger.error); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Die aktualisierten Zählerdaten konnten nicht gespeichert werden. Bitte versuche es erneut.' }).catch(logger.error); return; } const updatedCounter = counters[counterIndex]; const updated = await updateCounter(client, guild, updatedCounter); if (!updated) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Counter updated but failed to update channel name. The counter will update on the next scheduled run.' }).catch(logger.error); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Zähler aktualisiert, aber der Kanalname konnte nicht aktualisiert werden. Er wird beim nächsten geplanten Durchlauf aktualisiert.' }).catch(logger.error); return; } const finalChannel = guild.channels.cache.get(updatedCounter.channelId); await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed(`**Counter Updated Successfully!**\n\n**Counter ID:** \`${counterId}\`\n**Type Changed:** ${getCounterEmoji(oldType)} ${getCounterTypeLabel(oldType)} → ${getCounterEmoji(newType)} ${getCounterTypeLabel(newType)}\n\n**Current Settings:**\n**Type:** ${getCounterEmoji(updatedCounter.type)} ${getCounterTypeLabel(updatedCounter.type)}\n**Channel:** ${finalChannel}\n**Channel Name:** ${finalChannel!.name}\n\nThe counter will automatically update every 15 minutes.`)] + embeds: [successEmbed(`**Zähler erfolgreich aktualisiert!**\n\n**Zähler-ID:** \`${counterId}\`\n**Typ geändert:** ${getCounterEmoji(oldType)} ${getCounterTypeLabel(oldType)} → ${getCounterEmoji(newType)} ${getCounterTypeLabel(newType)}\n\n**Aktuelle Einstellungen:**\n**Typ:** ${getCounterEmoji(updatedCounter.type)} ${getCounterTypeLabel(updatedCounter.type)}\n**Kanal:** ${finalChannel}\n**Kanalname:** ${finalChannel!.name}\n\nDer Zähler wird automatisch alle 15 Minuten aktualisiert.`)] }).catch(logger.error); } catch (error) { logger.error("Error updating counter:", error); - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while updating the counter. Please try again.' }).catch(logger.error); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Aktualisieren des Zählers ist ein Fehler aufgetreten. Bitte versuche es erneut.' }).catch(logger.error); } } \ No newline at end of file diff --git a/src/commands/ServerStats/serverstats.ts b/src/commands/ServerStats/serverstats.ts index ba1c6cdfa2..2557fb87e5 100644 --- a/src/commands/ServerStats/serverstats.ts +++ b/src/commands/ServerStats/serverstats.ts @@ -14,37 +14,37 @@ import type { Command } from '../../types/index.js'; export default { data: new SlashCommandBuilder() .setName("serverstats") - .setDescription("Manage server statistics that track member counts and channel data") + .setDescription("Serverstatistiken verwalten, die Mitgliederzahlen und Kanaldaten erfassen") .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels) .addSubcommand(subcommand => subcommand .setName("create") - .setDescription("Create a new statistics tracker channel in a category") + .setDescription("Neuen Statistik-Kanal in einer Kategorie erstellen") .addStringOption(option => option .setName("type") - .setDescription("The type of statistics to track") + .setDescription("Art der zu erfassenden Statistik") .setRequired(true) .addChoices( - { name: "members + bots", value: "members" }, - { name: "members only", value: "members_only" }, - { name: "bots only", value: "bots" } + { name: "Mitglieder + Bots", value: "members" }, + { name: "nur Mitglieder", value: "members_only" }, + { name: "nur Bots", value: "bots" } ) ) .addStringOption(option => option .setName("channel_type") - .setDescription("The channel type to create for this tracker") + .setDescription("Kanaltyp, der für diesen Zähler erstellt wird") .setRequired(true) .addChoices( - { name: "voice channel (recommended)", value: "voice" }, - { name: "text channel", value: "text" } + { name: "Sprachkanal (empfohlen)", value: "voice" }, + { name: "Textkanal", value: "text" } ) ) .addChannelOption(option => option .setName("category") - .setDescription("The category where the statistics tracker channel will be created") + .setDescription("Kategorie, in der der Statistik-Kanal erstellt wird") .setRequired(true) .addChannelTypes(ChannelType.GuildCategory) ) @@ -52,38 +52,38 @@ export default { .addSubcommand(subcommand => subcommand .setName("list") - .setDescription("List all statistics trackers for this server") + .setDescription("Alle Statistik-Zähler dieses Servers anzeigen") ) .addSubcommand(subcommand => subcommand .setName("update") - .setDescription("Update an existing statistics tracker") + .setDescription("Bestehenden Statistik-Zähler aktualisieren") .addStringOption(option => option .setName("counter-id") - .setDescription("The ID of the tracker to update") + .setDescription("ID des zu aktualisierenden Zählers") .setRequired(true) ) .addStringOption(option => option .setName("type") - .setDescription("The new tracker type") + .setDescription("Neuer Zählertyp") .setRequired(false) .addChoices( - { name: "members + bots", value: "members" }, - { name: "members only", value: "members_only" }, - { name: "bots only", value: "bots" } + { name: "Mitglieder + Bots", value: "members" }, + { name: "nur Mitglieder", value: "members_only" }, + { name: "nur Bots", value: "bots" } ) ) ) .addSubcommand(subcommand => subcommand .setName("delete") - .setDescription("Delete an existing statistics tracker") + .setDescription("Bestehenden Statistik-Zähler löschen") .addStringOption(option => option .setName("counter-id") - .setDescription("The ID of the tracker to delete") + .setDescription("ID des zu löschenden Zählers") .setRequired(true) ) ), @@ -105,7 +105,7 @@ export default { await handleDelete(interaction, client); break; default: - await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Unknown subcommand.' }); + await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Unbekannter Subcommand.' }); } } } satisfies Command; \ No newline at end of file diff --git a/src/commands/Ticket/modules/claim.ts b/src/commands/Ticket/modules/claim.ts index 9a0284f690..a411e682c8 100644 --- a/src/commands/Ticket/modules/claim.ts +++ b/src/commands/Ticket/modules/claim.ts @@ -10,7 +10,7 @@ import { claimTicket } from '../../../services/ticket.js'; export default { data: new SlashCommandBuilder() .setName("claim") - .setDescription("Claims an open ticket, assigning it to you.") + .setDescription("Ein offenes Ticket übernehmen und dir zuweisen.") .setDMPermission(false), async execute(interaction, guildConfig, client) { @@ -21,11 +21,11 @@ export default { 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.' }); + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Dieser Command funktioniert nur in einem gültigen Ticket-Kanal.' }); } 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.' }); + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Du brauchst die Berechtigung „Kanäle verwalten“ oder die eingestellte Ticket-Team-Rolle, um Tickets zu übernehmen.' }); } await claimTicket(interaction.channel as TextChannel, interaction.user); @@ -33,8 +33,8 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( - "Ticket Claimed!", - "You have successfully claimed this ticket.", + "Ticket übernommen!", + "Du hast dieses Ticket erfolgreich übernommen.", ), ], }); diff --git a/src/commands/Ticket/modules/close.ts b/src/commands/Ticket/modules/close.ts index 00857ede36..bebb0c5184 100644 --- a/src/commands/Ticket/modules/close.ts +++ b/src/commands/Ticket/modules/close.ts @@ -10,12 +10,12 @@ import { closeTicket } from '../../../services/ticket.js'; export default { data: new SlashCommandBuilder() .setName("close") - .setDescription("Closes the current ticket.") + .setDescription("Schließt das aktuelle Ticket.") .setDMPermission(false) .addStringOption((option) => option .setName("reason") - .setDescription("The reason for closing the ticket.") + .setDescription("Der Grund für das Schließen des Tickets.") .setRequired(false), ), @@ -27,24 +27,24 @@ export default { 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.' }); + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Dieser Command funktioniert nur in einem gültigen Ticket-Kanal.' }); } 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.' }); + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Du brauchst die Berechtigung „Kanäle verwalten“, die eingestellte Ticket-Team-Rolle oder musst das Ticket erstellt haben, um es zu schließen.' }); } const reason = interaction.options?.getString("reason") || - "Closed via command without a specific reason."; + "Per Command ohne bestimmten Grund geschlossen."; await closeTicket(interaction.channel as TextChannel, interaction.user, reason); await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( - "Ticket Closed!", - "This ticket has been closed successfully.", + "Ticket geschlossen!", + "Dieses Ticket wurde erfolgreich geschlossen.", ), ], }); diff --git a/src/commands/Ticket/modules/priority.ts b/src/commands/Ticket/modules/priority.ts index b156d6469d..75a310c5d7 100644 --- a/src/commands/Ticket/modules/priority.ts +++ b/src/commands/Ticket/modules/priority.ts @@ -1,5 +1,5 @@ import type { Command } from '../../../types/index.js'; -import { getColor } from '../../../config/bot.js'; +import { getColor, botConfig } from '../../../config/bot.js'; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, type TextChannel } from 'discord.js'; import { successEmbed } from '../../../utils/embeds.js'; import { logger } from '../../../utils/logger.js'; @@ -11,18 +11,18 @@ import { updateTicketPriority } from '../../../services/ticket.js'; export default { data: new SlashCommandBuilder() .setName("priority") - .setDescription("Sets the priority level for the current support ticket.") + .setDescription("Legt die Priorität des aktuellen Support-Tickets fest.") .addStringOption((option) => option .setName("level") - .setDescription("The priority level for the ticket.") + .setDescription("Die Priorität für das Ticket.") .setRequired(true) .addChoices( - { name: "Urgent", value: "urgent" }, - { name: "High", value: "high" }, - { name: "Medium", value: "medium" }, - { name: "Low", value: "low" }, - { name: "None", value: "none" }, + { name: "Dringend", value: "urgent" }, + { name: "Hoch", value: "high" }, + { name: "Mittel", value: "medium" }, + { name: "Niedrig", value: "low" }, + { name: "Keine", value: "none" }, ), ) .setDMPermission(false), @@ -36,11 +36,11 @@ export default { 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.' }); + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Dieser Command funktioniert nur in einem gültigen Ticket-Kanal.' }); } 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.' }); + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Du brauchst die Berechtigung „Kanäle verwalten“ oder die eingestellte Ticket-Team-Rolle, um die Ticket-Priorität zu ändern.' }); } const priorityLevel = interaction.options.getString("level")!; @@ -49,8 +49,8 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( - "Priority Updated", - `Ticket priority set to **${priorityLevel.toUpperCase()}**.`, + "Priorität aktualisiert", + `Ticket-Priorität auf **${(botConfig.tickets.priorities[priorityLevel as keyof typeof botConfig.tickets.priorities]?.label ?? priorityLevel).toUpperCase()}** gesetzt.`, ), ], }); diff --git a/src/commands/Ticket/modules/ticket_dashboard.ts b/src/commands/Ticket/modules/ticket_dashboard.ts index 0c8ec7a9f0..b69bed1e9c 100644 --- a/src/commands/Ticket/modules/ticket_dashboard.ts +++ b/src/commands/Ticket/modules/ticket_dashboard.ts @@ -48,7 +48,7 @@ function buildButtonRow(guildConfig: GuildConfig, guildId: string, disabled = fa buttons.push( new ButtonBuilder() .setCustomId(`ticket_cfg_repost_${guildId}`) - .setLabel('Repost Panel') + .setLabel('Panel neu posten') .setStyle(ButtonStyle.Primary) .setEmoji('📌') .setDisabled(disabled), @@ -58,19 +58,19 @@ function buildButtonRow(guildConfig: GuildConfig, guildId: string, disabled = fa buttons.push( new ButtonBuilder() .setCustomId(`ticket_cfg_dm_toggle_${guildId}`) - .setLabel('DM on Close') + .setLabel('DM beim Schließen') .setStyle(dmEnabled ? ButtonStyle.Success : ButtonStyle.Danger) .setEmoji(dmEnabled ? '📬' : '📭') .setDisabled(disabled), new ButtonBuilder() .setCustomId(`ticket_cfg_staff_role_btn_${guildId}`) - .setLabel('Staff Role') + .setLabel('Team-Rolle') .setStyle(ButtonStyle.Secondary) .setEmoji('🛡️') .setDisabled(disabled), new ButtonBuilder() .setCustomId(`ticket_cfg_delete_${guildId}`) - .setLabel('Delete System') + .setLabel('System löschen') .setStyle(ButtonStyle.Danger) .setEmoji('🗑️') .setDisabled(disabled), @@ -89,8 +89,8 @@ async function persistPanelMessageId(client: NexoClient, guildId: string, guildC function buildPanelEmbed(config: GuildConfig) { return new EmbedBuilder() - .setTitle('Support Tickets') - .setDescription(config.ticketPanelMessage || 'Click the button below to create a support ticket.') + .setTitle('Support-Tickets') + .setDescription(config.ticketPanelMessage || 'Klick auf den Button unten, um ein Support-Ticket zu erstellen.') .setColor(getColor('info')); } @@ -98,7 +98,7 @@ function buildPanelButtonRow(config: GuildConfig) { return new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('create_ticket') - .setLabel(config.ticketButtonLabel || 'Create Ticket') + .setLabel(config.ticketButtonLabel || 'Ticket erstellen') .setStyle(ButtonStyle.Primary) .setEmoji('📩'), ); @@ -110,7 +110,7 @@ async function repostTicketPanel(client: NexoClient, guild: Guild, guildConfig: throw new NexoBotError( 'Panel channel missing', ErrorTypes.CONFIGURATION, - 'The configured ticket panel channel no longer exists. Set a new panel channel from the dashboard.', + 'Der eingestellte Ticket-Panel-Kanal existiert nicht mehr. Leg im Dashboard einen neuen Panel-Kanal fest.', ); } @@ -124,7 +124,7 @@ async function repostTicketPanel(client: NexoClient, guild: Guild, guildConfig: } function formatCloseDuration(ms: number | null | undefined) { - if (ms == null) return '`N/A`'; + if (ms == null) return '`–`'; const hours = Math.floor(ms / 3_600_000); const minutes = Math.floor((ms % 3_600_000) / 60_000); if (hours > 0) return `${hours}h ${minutes}m`; @@ -132,94 +132,94 @@ function formatCloseDuration(ms: number | null | undefined) { } function buildDashboardEmbed(config: GuildConfig, guild: Guild, panelStatus: PanelStatus | null = null, ticketStats: GuildTicketStats | null = null) { - const panelChannel = config.ticketPanelChannelId ? `<#${config.ticketPanelChannelId}>` : '`Not set`'; - const staffRole = config.ticketStaffRoleId ? `<@&${config.ticketStaffRoleId}>` : '`Not set`'; - const ticketLogsChannel = config.ticketLogsChannelId ? `<#${config.ticketLogsChannelId}>` : '`Not set`'; - const transcriptChannel = config.ticketTranscriptChannelId ? `<#${config.ticketTranscriptChannelId}>` : '`Not set`'; + const panelChannel = config.ticketPanelChannelId ? `<#${config.ticketPanelChannelId}>` : '`Nicht gesetzt`'; + const staffRole = config.ticketStaffRoleId ? `<@&${config.ticketStaffRoleId}>` : '`Nicht gesetzt`'; + const ticketLogsChannel = config.ticketLogsChannelId ? `<#${config.ticketLogsChannelId}>` : '`Nicht gesetzt`'; + const transcriptChannel = config.ticketTranscriptChannelId ? `<#${config.ticketTranscriptChannelId}>` : '`Nicht gesetzt`'; const openCategoryChannel = config.ticketCategoryId ? guild.channels.cache.get(config.ticketCategoryId) : null; - const openCategory = openCategoryChannel ? openCategoryChannel.toString() : '`Not set`'; + const openCategory = openCategoryChannel ? openCategoryChannel.toString() : '`Nicht gesetzt`'; const closedCategoryChannel = config.ticketClosedCategoryId ? guild.channels.cache.get(config.ticketClosedCategoryId) : null; - const closedCategory = closedCategoryChannel ? closedCategoryChannel.toString() : '`Not set`'; + const closedCategory = closedCategoryChannel ? closedCategoryChannel.toString() : '`Nicht gesetzt`'; - const rawMsg = config.ticketPanelMessage || 'Click the button below to create a support ticket.'; + const rawMsg = config.ticketPanelMessage || 'Klick auf den Button unten, um ein Support-Ticket zu erstellen.'; const panelMsg = `\`${rawMsg.length > 60 ? rawMsg.substring(0, 60) + '…' : rawMsg}\``; - const btnLabel = `\`${config.ticketButtonLabel || 'Create Ticket'}\``; + const btnLabel = `\`${config.ticketButtonLabel || 'Ticket erstellen'}\``; let panelStatusValue = formatPanelStatusField(panelStatus); const openTickets = ticketStats ? String(ticketStats.openCount) : '`—`'; const avgCloseTime = ticketStats ? formatCloseDuration(ticketStats.avgCloseTimeMs) : '`—`'; const feedbackSummary = ticketStats?.feedbackCount - ? `${ticketStats.avgRating}/5 (${ticketStats.feedbackCount} rating${ticketStats.feedbackCount !== 1 ? 's' : ''})` - : '`No ratings yet`'; + ? `${ticketStats.avgRating}/5 (${ticketStats.feedbackCount} Bewertung${ticketStats.feedbackCount !== 1 ? 'en' : ''})` + : '`Noch keine Bewertungen`'; return new EmbedBuilder() - .setTitle('🎫 Ticket System Dashboard') - .setDescription(`Manage ticket system settings for **${guild.name}**.\nSelect an option below to modify a setting.`) + .setTitle('🎫 Ticket-System-Dashboard') + .setDescription(`Verwalte die Ticket-Einstellungen für **${guild.name}**.\nWähl unten eine Option, um eine Einstellung zu ändern.`) .setColor(getColor('info')) .addFields( - { name: 'Panel Status', value: panelStatusValue, inline: false }, - { name: 'Panel Channel', value: panelChannel, inline: true }, - { name: 'Staff Role', value: staffRole, inline: true }, + { name: 'Panel-Status', value: panelStatusValue, inline: false }, + { name: 'Panel-Kanal', value: panelChannel, inline: true }, + { name: 'Team-Rolle', value: staffRole, inline: true }, { name: '\u200B', value: '\u200B', inline: true }, - { name: 'Open Tickets Category', value: openCategory, inline: true }, - { name: 'Closed Tickets Category', value: closedCategory, inline: true }, + { name: 'Kategorie offene Tickets', value: openCategory, inline: true }, + { name: 'Kategorie geschlossene Tickets', value: closedCategory, inline: true }, { name: '\u200B', value: '\u200B', inline: true }, - { name: 'Panel Message', value: panelMsg, inline: false }, - { name: 'Button Label', value: btnLabel, inline: true }, - { name: 'Max Tickets/User', value: String(config.maxTicketsPerUser || 3), inline: true }, - { name: 'DM on Close', value: config.dmOnClose !== false ? 'Enabled' : 'Disabled', inline: true }, - { name: 'Ticket Logs Channel', value: ticketLogsChannel, inline: true }, - { name: 'Transcript Channel', value: transcriptChannel, inline: true }, + { name: 'Panel-Nachricht', value: panelMsg, inline: false }, + { name: 'Button-Beschriftung', value: btnLabel, inline: true }, + { name: 'Max. Tickets/Nutzer', value: String(config.maxTicketsPerUser || 3), inline: true }, + { name: 'DM beim Schließen', value: config.dmOnClose !== false ? 'Aktiviert' : 'Deaktiviert', inline: true }, + { name: 'Ticket-Log-Kanal', value: ticketLogsChannel, inline: true }, + { name: 'Transkript-Kanal', value: transcriptChannel, inline: true }, { name: '\u200B', value: '\u200B', inline: true }, - { name: 'Open Tickets', value: openTickets, inline: true }, - { name: 'Avg Close Time', value: avgCloseTime, inline: true }, - { name: 'Feedback Rating', value: feedbackSummary, inline: true }, + { name: 'Offene Tickets', value: openTickets, inline: true }, + { name: 'Ø Zeit bis Schließen', value: avgCloseTime, inline: true }, + { name: 'Feedback-Bewertung', value: feedbackSummary, inline: true }, ) - .setFooter({ text: 'Select an option below • Dashboard closes after 10 minutes of inactivity' }) + .setFooter({ text: 'Wähl unten eine Option • Das Dashboard schließt nach 10 Minuten Inaktivität' }) .setTimestamp(); } function buildSelectMenu(guildId: string) { return new StringSelectMenuBuilder() .setCustomId(`ticket_config_${guildId}`) - .setPlaceholder('Select a setting to configure...') + .setPlaceholder('Einstellung zum Konfigurieren auswählen...') .addOptions( new StringSelectMenuOptionBuilder() - .setLabel('Edit Panel Message') - .setDescription('Change the message displayed on the ticket creation panel') + .setLabel('Panel-Nachricht bearbeiten') + .setDescription('Die Nachricht auf dem Ticket-Panel ändern') .setValue('panel_message') .setEmoji('📝'), new StringSelectMenuOptionBuilder() - .setLabel('Edit Button Label') - .setDescription('Change the label on the Create Ticket button') + .setLabel('Button-Beschriftung bearbeiten') + .setDescription('Die Beschriftung des Buttons „Ticket erstellen“ ändern') .setValue('button_label') .setEmoji('🏷️'), new StringSelectMenuOptionBuilder() - .setLabel('Change Open Tickets Category') - .setDescription('Category where new tickets are created') + .setLabel('Kategorie für offene Tickets ändern') + .setDescription('Kategorie, in der neue Tickets erstellt werden') .setValue('open_category') .setEmoji('📁'), new StringSelectMenuOptionBuilder() - .setLabel('Change Closed Tickets Category') - .setDescription('Category where closed tickets are moved') + .setLabel('Kategorie für geschlossene Tickets ändern') + .setDescription('Kategorie, in die geschlossene Tickets verschoben werden') .setValue('closed_category') .setEmoji('📂'), new StringSelectMenuOptionBuilder() - .setLabel('Set Max Tickets per User') - .setDescription('Limit how many open tickets one user can have at once') + .setLabel('Max. Tickets pro Nutzer festlegen') + .setDescription('Begrenzen, wie viele offene Tickets ein Nutzer gleichzeitig haben kann') .setValue('max_tickets') .setEmoji('🔢'), new StringSelectMenuOptionBuilder() - .setLabel('Set Ticket Logs Channel') - .setDescription('Channel to receive ticket feedback, lifecycle events, and logs') + .setLabel('Ticket-Log-Kanal festlegen') + .setDescription('Kanal für Ticket-Feedback, Ticket-Ereignisse und Logs') .setValue('logs_channel') .setEmoji('🎫'), new StringSelectMenuOptionBuilder() - .setLabel('Set Transcript Channel') - .setDescription('Channel to receive auto-generated transcripts on deletion') + .setLabel('Transkript-Kanal festlegen') + .setDescription('Kanal für automatisch erstellte Transkripte beim Löschen') .setValue('transcript_channel') .setEmoji('📜'), ); @@ -274,7 +274,7 @@ export default { throw new NexoBotError( 'Ticket system not configured', ErrorTypes.CONFIGURATION, - 'The ticket system has not been set up yet. Run `/ticket setup` first to configure it.', + 'Das Ticket-System ist noch nicht eingerichtet. Nutze zuerst `/ticket setup`, um es einzurichten.', ); } @@ -345,7 +345,7 @@ export default { throw new NexoBotError( `Ticket config failed: ${(error as ErrorLike).message}`, ErrorTypes.UNKNOWN, - 'Failed to open the ticket configuration dashboard.', + 'Das Ticket-Dashboard konnte nicht geöffnet werden.', ); } }, @@ -354,21 +354,21 @@ export default { async function handlePanelMessage(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, guildConfig: GuildConfig, guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId('ticket_cfg_panel_msg') - .setTitle('📝 Edit Panel Message') + .setTitle('📝 Panel-Nachricht bearbeiten') .addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('panel_msg_input') - .setLabel('Panel Message') + .setLabel('Panel-Nachricht') .setStyle(TextInputStyle.Paragraph) .setValue( guildConfig.ticketPanelMessage || - 'Click the button below to create a support ticket.', + 'Klick auf den Button unten, um ein Support-Ticket zu erstellen.', ) .setMaxLength(2000) .setMinLength(1) .setRequired(true) - .setPlaceholder('Click the button below to create a support ticket.'), + .setPlaceholder('Klick auf den Button unten, um ein Support-Ticket zu erstellen.'), ), ); @@ -393,11 +393,11 @@ async function handlePanelMessage(selectInteraction: StringSelectMenuInteraction await submitted.reply({ embeds: [ successEmbed( - '✅ Panel Message Updated', - `The panel message has been updated.${ + '✅ Panel-Nachricht aktualisiert', + `Die Panel-Nachricht wurde aktualisiert.${ panelUpdated - ? '\nThe live ticket panel has also been refreshed.' - : '\n> **Note:** The live panel could not be located. Use **Repost Panel** on the dashboard to restore it.' + ? '\nDas Live-Ticket-Panel wurde ebenfalls aktualisiert.' + : '\n> **Hinweis:** Das Live-Panel wurde nicht gefunden. Nutze **Panel neu posten** im Dashboard, um es wiederherzustellen.' }`, ), ], @@ -410,18 +410,18 @@ async function handlePanelMessage(selectInteraction: StringSelectMenuInteraction async function handleButtonLabel(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, guildConfig: GuildConfig, guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId('ticket_cfg_btn_label') - .setTitle('🏷️ Edit Button Label') + .setTitle('🏷️ Button-Beschriftung bearbeiten') .addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('btn_label_input') - .setLabel('Button Label (max 80 characters)') + .setLabel('Button-Beschriftung (max. 80 Zeichen)') .setStyle(TextInputStyle.Short) - .setValue(guildConfig.ticketButtonLabel || 'Create Ticket') + .setValue(guildConfig.ticketButtonLabel || 'Ticket erstellen') .setMaxLength(80) .setMinLength(1) .setRequired(true) - .setPlaceholder('Create Ticket'), + .setPlaceholder('Ticket erstellen'), ), ); @@ -446,11 +446,11 @@ async function handleButtonLabel(selectInteraction: StringSelectMenuInteraction, await submitted.reply({ embeds: [ successEmbed( - '✅ Button Label Updated', - `Button label changed to \`${newLabel}\`.${ + '✅ Button-Beschriftung aktualisiert', + `Button-Beschriftung auf \`${newLabel}\` geändert.${ panelUpdated - ? '\nThe live ticket panel button has also been updated.' - : '\n> **Note:** The live panel could not be located. Use **Repost Panel** on the dashboard to restore it.' + ? '\nDer Button im Live-Ticket-Panel wurde ebenfalls aktualisiert.' + : '\n> **Hinweis:** Das Live-Panel wurde nicht gefunden. Nutze **Panel neu posten** im Dashboard, um es wiederherzustellen.' }`, ), ], @@ -465,7 +465,7 @@ async function handleStaffRole(selectInteraction: StringSelectMenuInteraction | const roleSelect = new RoleSelectMenuBuilder() .setCustomId('ticket_cfg_staff_role') - .setPlaceholder('Select the staff role...') + .setPlaceholder('Team-Rolle auswählen...') .setMaxValues(1); const row = new ActionRowBuilder().addComponents(roleSelect); @@ -473,9 +473,9 @@ async function handleStaffRole(selectInteraction: StringSelectMenuInteraction | await selectInteraction.followUp({ embeds: [ new EmbedBuilder() - .setTitle('🛡️ Change Staff Role') + .setTitle('🛡️ Team-Rolle ändern') .setDescription( - `**Current:** ${guildConfig.ticketStaffRoleId ? `<@&${guildConfig.ticketStaffRoleId}>` : '`Not set`'}\n\nSelect the role that should have staff access to manage tickets.`, + `**Aktuell:** ${guildConfig.ticketStaffRoleId ? `<@&${guildConfig.ticketStaffRoleId}>` : '`Nicht gesetzt`'}\n\nWähl die Rolle, die als Team Tickets verwalten darf.`, ) .setColor(getColor('info')), ], @@ -499,7 +499,7 @@ async function handleStaffRole(selectInteraction: StringSelectMenuInteraction | await setGuildConfig(client, guildId, guildConfig); await roleInteraction.followUp({ - embeds: [successEmbed('Staff Role Updated', `Staff role set to ${role}.`)], + embeds: [successEmbed('Team-Rolle aktualisiert', `Team-Rolle auf ${role} gesetzt.`)], flags: MessageFlags.Ephemeral, }); @@ -510,7 +510,7 @@ async function handleStaffRole(selectInteraction: StringSelectMenuInteraction | if (reason === 'time' && collected.size === 0) { replyUserError(selectInteraction, { type: ErrorTypes.RATE_LIMIT, - message: 'No role was selected. The staff role was not changed.', + message: 'Es wurde keine Rolle ausgewählt. Die Team-Rolle wurde nicht geändert.', }).catch(() => {}); } }); @@ -521,16 +521,16 @@ async function handleOpenCategory(selectInteraction: StringSelectMenuInteraction const channelSelect = new ChannelSelectMenuBuilder() .setCustomId('ticket_cfg_open_cat') - .setPlaceholder('Select a category...') + .setPlaceholder('Kategorie auswählen...') .addChannelTypes(ChannelType.GuildCategory) .setMaxValues(1); await selectInteraction.followUp({ embeds: [ new EmbedBuilder() - .setTitle('📁 Change Open Tickets Category') + .setTitle('📁 Kategorie für offene Tickets ändern') .setDescription( - `**Current:** ${guildConfig.ticketCategoryId ? `<#${guildConfig.ticketCategoryId}>` : '`Not set`'}\n\nSelect the category where new tickets will be created.`, + `**Aktuell:** ${guildConfig.ticketCategoryId ? `<#${guildConfig.ticketCategoryId}>` : '`Nicht gesetzt`'}\n\nWähl die Kategorie, in der neue Tickets erstellt werden.`, ) .setColor(getColor('info')), ], @@ -556,8 +556,8 @@ async function handleOpenCategory(selectInteraction: StringSelectMenuInteraction await catInteraction.followUp({ embeds: [ successEmbed( - 'Open Category Updated', - `New tickets will now be created in **${category.name}**.`, + 'Kategorie für offene Tickets aktualisiert', + `Neue Tickets werden jetzt in **${category.name}** erstellt.`, ), ], flags: MessageFlags.Ephemeral, @@ -570,7 +570,7 @@ async function handleOpenCategory(selectInteraction: StringSelectMenuInteraction if (reason === 'time' && collected.size === 0) { replyUserError(selectInteraction, { type: ErrorTypes.RATE_LIMIT, - message: 'No category was selected. The setting was not changed.', + message: 'Es wurde keine Kategorie ausgewählt. Die Einstellung wurde nicht geändert.', }).catch(() => {}); } }); @@ -581,16 +581,16 @@ async function handleClosedCategory(selectInteraction: StringSelectMenuInteracti const channelSelect = new ChannelSelectMenuBuilder() .setCustomId('ticket_cfg_closed_cat') - .setPlaceholder('Select a category...') + .setPlaceholder('Kategorie auswählen...') .addChannelTypes(ChannelType.GuildCategory) .setMaxValues(1); await selectInteraction.followUp({ embeds: [ new EmbedBuilder() - .setTitle('📂 Change Closed Tickets Category') + .setTitle('📂 Kategorie für geschlossene Tickets ändern') .setDescription( - `**Current:** ${guildConfig.ticketClosedCategoryId ? `<#${guildConfig.ticketClosedCategoryId}>` : '`Not set`'}\n\nSelect the category where closed tickets will be moved.`, + `**Aktuell:** ${guildConfig.ticketClosedCategoryId ? `<#${guildConfig.ticketClosedCategoryId}>` : '`Nicht gesetzt`'}\n\nWähl die Kategorie, in die geschlossene Tickets verschoben werden.`, ) .setColor(getColor('info')), ], @@ -616,8 +616,8 @@ async function handleClosedCategory(selectInteraction: StringSelectMenuInteracti await catInteraction.followUp({ embeds: [ successEmbed( - 'Closed Category Updated', - `Closed tickets will now be moved to **${category.name}**.`, + 'Kategorie für geschlossene Tickets aktualisiert', + `Geschlossene Tickets werden jetzt nach **${category.name}** verschoben.`, ), ], flags: MessageFlags.Ephemeral, @@ -630,7 +630,7 @@ async function handleClosedCategory(selectInteraction: StringSelectMenuInteracti if (reason === 'time' && collected.size === 0) { replyUserError(selectInteraction, { type: ErrorTypes.RATE_LIMIT, - message: 'No category was selected. The setting was not changed.', + message: 'Es wurde keine Kategorie ausgewählt. Die Einstellung wurde nicht geändert.', }).catch(() => {}); } }); @@ -639,12 +639,12 @@ async function handleClosedCategory(selectInteraction: StringSelectMenuInteracti async function handleMaxTickets(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, guildConfig: GuildConfig, guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId('ticket_cfg_max_tickets') - .setTitle('Set Max Tickets per User') + .setTitle('Max. Tickets pro Nutzer festlegen') .addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('max_tickets_input') - .setLabel('Max Open Tickets (1–10)') + .setLabel('Max. offene Tickets (1–10)') .setStyle(TextInputStyle.Short) .setValue(String(guildConfig.maxTicketsPerUser || 3)) .setMaxLength(2) @@ -672,7 +672,7 @@ async function handleMaxTickets(selectInteraction: StringSelectMenuInteraction, if (Number.isNaN(newMax) || newMax < 1 || newMax > 10) { await replyUserError(submitted, { type: ErrorTypes.VALIDATION, - message: 'Max tickets must be a whole number between **1** and **10**.', + message: 'Die maximale Ticketanzahl muss eine ganze Zahl zwischen **1** und **10** sein.', }); return; } @@ -683,8 +683,8 @@ async function handleMaxTickets(selectInteraction: StringSelectMenuInteraction, await submitted.reply({ embeds: [ successEmbed( - 'Max Tickets Updated', - `Users can now have at most **${newMax}** open ticket${newMax !== 1 ? 's' : ''} at a time.`, + 'Max. Tickets aktualisiert', + `Nutzer können jetzt höchstens **${newMax}** offene${newMax !== 1 ? '' : 's'} Ticket${newMax !== 1 ? 's' : ''} gleichzeitig haben.`, ), ], flags: MessageFlags.Ephemeral, @@ -703,8 +703,8 @@ async function handleDmOnClose(btnInteraction: ButtonInteraction, rootInteractio await btnInteraction.followUp({ embeds: [ successEmbed( - 'DM on Close Updated', - `Users will **${newState ? 'now' : 'no longer'}** receive a DM when their ticket is closed.`, + 'DM beim Schließen aktualisiert', + `Nutzer bekommen ${newState ? '**jetzt** eine DM' : '**keine** DM mehr'}, wenn ihr Ticket geschlossen wird.`, ), ], flags: MessageFlags.Ephemeral, @@ -718,15 +718,15 @@ async function handleLogsChannel(selectInteraction: StringSelectMenuInteraction, const channelSelect = new ChannelSelectMenuBuilder() .setCustomId('ticket_cfg_logs_channel') - .setPlaceholder('Select a channel...') + .setPlaceholder('Kanal auswählen...') .addChannelTypes(ChannelType.GuildText) .setMaxValues(1); await selectInteraction.followUp({ embeds: [ new EmbedBuilder() - .setTitle('🎫 Select Ticket Logs Channel') - .setDescription('Choose where ticket feedback, lifecycle events (open, close, claim, etc.), and other logs will be sent.') + .setTitle('🎫 Ticket-Log-Kanal auswählen') + .setDescription('Wähl, wohin Ticket-Feedback, Ticket-Ereignisse (Öffnen, Schließen, Übernehmen usw.) und andere Logs gesendet werden.') .setColor(getColor('info')), ], components: [new ActionRowBuilder().addComponents(channelSelect)], @@ -748,7 +748,7 @@ async function handleLogsChannel(selectInteraction: StringSelectMenuInteraction, await setGuildConfig(client, guildId, guildConfig); await channelInteraction.followUp({ - embeds: [successEmbed('Logs Channel Updated', `Ticket logs will be sent to ${channel}`)], + embeds: [successEmbed('Log-Kanal aktualisiert', `Ticket-Logs werden an ${channel} gesendet`)], flags: MessageFlags.Ephemeral, }); @@ -759,7 +759,7 @@ async function handleLogsChannel(selectInteraction: StringSelectMenuInteraction, if (reason === 'time' && collected.size === 0) { replyUserError(selectInteraction, { type: ErrorTypes.RATE_LIMIT, - message: 'No channel selected. No changes were made.', + message: 'Kein Kanal ausgewählt. Es wurde nichts geändert.', }).catch(() => {}); } }); @@ -770,15 +770,15 @@ async function handleTranscriptChannel(selectInteraction: StringSelectMenuIntera const channelSelect = new ChannelSelectMenuBuilder() .setCustomId('ticket_cfg_transcript_channel') - .setPlaceholder('Select a channel...') + .setPlaceholder('Kanal auswählen...') .addChannelTypes(ChannelType.GuildText) .setMaxValues(1); await selectInteraction.followUp({ embeds: [ new EmbedBuilder() - .setTitle('📜 Select Transcript Channel') - .setDescription('Choose where auto-generated transcripts will be sent when tickets are deleted.') + .setTitle('📜 Transkript-Kanal auswählen') + .setDescription('Wähl, wohin automatisch erstellte Transkripte beim Löschen von Tickets gesendet werden.') .setColor(getColor('info')) ], components: [new ActionRowBuilder().addComponents(channelSelect)], @@ -800,7 +800,7 @@ async function handleTranscriptChannel(selectInteraction: StringSelectMenuIntera await setGuildConfig(client, guildId, guildConfig); await channelInteraction.followUp({ - embeds: [successEmbed('Transcript Channel Updated', `Transcripts will be sent to ${channel}`)], + embeds: [successEmbed('Transkript-Kanal aktualisiert', `Transkripte werden an ${channel} gesendet`)], flags: MessageFlags.Ephemeral }); @@ -811,7 +811,7 @@ async function handleTranscriptChannel(selectInteraction: StringSelectMenuIntera if (reason === 'time' && collected.size === 0) { replyUserError(selectInteraction, { type: ErrorTypes.RATE_LIMIT, - message: 'No channel selected. No changes were made.', + message: 'Kein Kanal ausgewählt. Es wurde nichts geändert.', }).catch(() => {}); } }); @@ -822,7 +822,7 @@ async function handleCheckUser(selectInteraction: StringSelectMenuInteraction, r const userSelect = new UserSelectMenuBuilder() .setCustomId('ticket_cfg_check_user') - .setPlaceholder('Select a user to check...') + .setPlaceholder('Nutzer zum Prüfen auswählen...') .setMaxValues(1); const row = new ActionRowBuilder().addComponents(userSelect); @@ -830,8 +830,8 @@ async function handleCheckUser(selectInteraction: StringSelectMenuInteraction, r await selectInteraction.followUp({ embeds: [ new EmbedBuilder() - .setTitle('Check User Tickets') - .setDescription('Select a user to view their current open ticket count.') + .setTitle('Tickets eines Nutzers prüfen') + .setDescription('Wähl einen Nutzer, um seine aktuell offenen Tickets zu sehen.') .setColor(getColor('info')), ], components: [row], @@ -856,13 +856,13 @@ async function handleCheckUser(selectInteraction: StringSelectMenuInteraction, r await userInteraction.followUp({ embeds: [ new EmbedBuilder() - .setTitle(`Ticket Check — ${targetUser.username}`) + .setTitle(`Ticket-Check – ${targetUser.username}`) .setDescription( - `**Open Tickets:** ${openCount} / ${maxTickets}\n` + - `**Remaining:** ${Math.max(0, maxTickets - openCount)}\n\n` + + `**Offene Tickets:** ${openCount} / ${maxTickets}\n` + + `**Verbleibend:** ${Math.max(0, maxTickets - openCount)}\n\n` + (atLimit - ? '⚠️ This user has reached their ticket limit.' - : '✅ This user can still open more tickets.'), + ? '⚠️ Dieser Nutzer hat sein Ticket-Limit erreicht.' + : '✅ Dieser Nutzer kann noch weitere Tickets öffnen.'), ) .setColor(atLimit ? getColor('error') : getColor('success')) .setThumbnail(targetUser.displayAvatarURL({ size: 64 })) @@ -876,7 +876,7 @@ async function handleCheckUser(selectInteraction: StringSelectMenuInteraction, r if (reason === 'time' && collected.size === 0) { replyUserError(selectInteraction, { type: ErrorTypes.RATE_LIMIT, - message: 'No user was selected.', + message: 'Es wurde kein Nutzer ausgewählt.', }).catch(() => {}); } }); @@ -888,7 +888,7 @@ async function handleRepostPanel(btnInteraction: ButtonInteraction, rootInteract const panelStatus = await getTicketPanelStatus(client, rootInteraction.guild!, guildConfig); if (panelStatus.exists) { await btnInteraction.followUp({ - embeds: [infoEmbed('Panel Already Active', 'The ticket panel is already posted in the configured channel.')], + embeds: [infoEmbed('Panel bereits aktiv', 'Das Ticket-Panel ist im eingestellten Kanal bereits gepostet.')], flags: MessageFlags.Ephemeral, }).catch(() => {}); await refreshDashboard(rootInteraction, guildConfig, guildId, client); @@ -900,9 +900,9 @@ async function handleRepostPanel(btnInteraction: ButtonInteraction, rootInteract await btnInteraction.followUp({ embeds: [ successEmbed( - 'Panel Reposted', - `A new ticket panel was posted in <#${guildConfig.ticketPanelChannelId}>.${ - sentPanel.url ? `\n[Open panel message](${sentPanel.url})` : '' + 'Panel neu gepostet', + `Ein neues Ticket-Panel wurde in <#${guildConfig.ticketPanelChannelId}> gepostet.${ + sentPanel.url ? `\n[Panel-Nachricht öffnen](${sentPanel.url})` : '' }`, ), ], @@ -915,12 +915,12 @@ async function handleRepostPanel(btnInteraction: ButtonInteraction, rootInteract async function handleDeleteSystem(btnInteraction: ButtonInteraction, rootInteraction: ChatInputCommandInteraction, guildConfig: GuildConfig, guildId: string, client: NexoClient) { const deleteModal = new ModalBuilder() .setCustomId('ticket_delete_confirm_modal') - .setTitle('Delete Ticket System') + .setTitle('Ticket-System löschen') .addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('delete_confirmation') - .setLabel('Type "DELETE" to confirm') + .setLabel('Gib „DELETE“ zur Bestätigung ein') .setStyle(TextInputStyle.Short) .setPlaceholder('DELETE') .setMaxLength(6) @@ -946,7 +946,7 @@ async function handleDeleteSystem(btnInteraction: ButtonInteraction, rootInterac const confirmation = submitted.fields.getTextInputValue('delete_confirmation').trim(); if (confirmation !== 'DELETE') { - await replyUserError(submitted, { type: ErrorTypes.UNKNOWN, message: 'You must type "DELETE" exactly to confirm deletion.' }); + await replyUserError(submitted, { type: ErrorTypes.UNKNOWN, message: 'Du musst genau „DELETE“ eingeben, um das Löschen zu bestätigen.' }); await refreshDashboard(rootInteraction, guildConfig, guildId, client); return; } @@ -1008,8 +1008,8 @@ async function handleDeleteSystem(btnInteraction: ButtonInteraction, rootInterac await submitted.followUp({ embeds: [ successEmbed( - '✅ Ticket System Deleted', - 'All ticket system configuration has been cleared. Run `/ticket setup` to set it up again.', + '✅ Ticket-System gelöscht', + 'Die gesamte Ticket-Konfiguration wurde entfernt. Nutze `/ticket setup`, um es neu einzurichten.', ), ], flags: MessageFlags.Ephemeral, @@ -1018,8 +1018,8 @@ async function handleDeleteSystem(btnInteraction: ButtonInteraction, rootInterac await InteractionHelper.safeEditReply(rootInteraction, { embeds: [ new EmbedBuilder() - .setTitle('Ticket System Deleted') - .setDescription('The ticket system configuration has been cleared.') + .setTitle('Ticket-System gelöscht') + .setDescription('Die Ticket-Konfiguration wurde entfernt.') .setColor(getColor('error')) .setTimestamp(), ], diff --git a/src/commands/Ticket/ticket.ts b/src/commands/Ticket/ticket.ts index 6e25d5b573..b3ce45164d 100644 --- a/src/commands/Ticket/ticket.ts +++ b/src/commands/Ticket/ticket.ts @@ -12,19 +12,19 @@ import ticketConfig from './modules/ticket_dashboard.js'; export default { data: new SlashCommandBuilder() .setName("ticket") - .setDescription("Manages the server's ticket system.") + .setDescription("Verwaltet das Ticket-System des Servers.") .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels) .addSubcommand((subcommand) => subcommand .setName("setup") .setDescription( - "Sets up the ticket creation panel in a specified channel.", + "Richtet das Ticket-Panel in einem bestimmten Kanal ein.", ) .addChannelOption((option) => option .setName("panel_channel") .setDescription( - "The channel where the ticket panel will be sent.", + "Der Kanal, in den das Ticket-Panel gesendet wird.", ) .addChannelTypes(ChannelType.GuildText) .setRequired(true), @@ -34,7 +34,7 @@ export default { option .setName("panel_message") .setDescription( - "The main message/description for the ticket panel.", + "Die Hauptnachricht/Beschreibung für das Ticket-Panel.", ) .setRequired(true), ) @@ -42,7 +42,7 @@ export default { option .setName("button_label") .setDescription( - "The label for the ticket creation button (default: Create Ticket)", + "Beschriftung des Buttons zum Erstellen (Standard: Ticket erstellen)", ) .setRequired(false), ) @@ -50,7 +50,7 @@ export default { option .setName("category") .setDescription( - "The category where new tickets will be created (optional).", + "Die Kategorie, in der neue Tickets erstellt werden (optional).", ) .addChannelTypes(ChannelType.GuildCategory) .setRequired(false), @@ -59,7 +59,7 @@ export default { option .setName("closed_category") .setDescription( - "The category where closed tickets will be moved (optional).", + "Die Kategorie, in die geschlossene Tickets verschoben werden (optional).", ) .addChannelTypes(ChannelType.GuildCategory) .setRequired(false), @@ -68,14 +68,14 @@ export default { option .setName("staff_role") .setDescription( - "The role that can access tickets (optional).", + "Die Rolle, die Zugriff auf Tickets hat (optional).", ) .setRequired(false), ) .addIntegerOption((option) => option .setName("max_tickets_per_user") - .setDescription("Maximum number of tickets a user can create (default: 3)") + .setDescription("Maximale Anzahl an Tickets pro Nutzer (Standard: 3)") .setMinValue(1) .setMaxValue(10) .setRequired(false), @@ -83,14 +83,14 @@ export default { .addBooleanOption((option) => option .setName("dm_on_close") - .setDescription("Send DM to user when their ticket is closed (default: true)") + .setDescription("Nutzer per DM informieren, wenn das Ticket geschlossen wird (Standard: true)") .setRequired(false), ), ) .addSubcommand((subcommand) => subcommand .setName("dashboard") - .setDescription("Open the interactive ticket system dashboard"), + .setDescription("Das interaktive Ticket-Dashboard öffnen"), ), category: "ticket", @@ -110,7 +110,7 @@ export default { guildId: interaction.guildId, commandName: 'ticket' }); - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the `Manage Channels` permission for this action.' }); + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Du brauchst für diese Aktion die Berechtigung „Kanäle verwalten“.' }); } const subcommand = interaction.options.getSubcommand(); @@ -122,7 +122,7 @@ export default { if (subcommand === "setup") { const existingConfig = await getGuildConfig(client, interaction.guildId!); if (existingConfig?.ticketPanelChannelId) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `This server already has a ticket system set up (panel in <#${existingConfig.ticketPanelChannelId}>).\n\nOnly one ticket system is supported per server. Use \`/ticket dashboard\` to edit or update the existing setup, or select **Delete System** from the dashboard to remove it and start fresh.` }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `Auf diesem Server ist bereits ein Ticket-System eingerichtet (Panel in <#${existingConfig.ticketPanelChannelId}>).\n\nPro Server wird nur ein Ticket-System unterstützt. Nutze \`/ticket dashboard\`, um die Einrichtung zu bearbeiten, oder wähl im Dashboard **System löschen**, um neu zu starten.` }); } const panelChannel = @@ -130,15 +130,15 @@ export default { const categoryChannel = interaction.options.getChannel("category"); const closedCategoryChannel = interaction.options.getChannel("closed_category"); const staffRole = interaction.options.getRole("staff_role"); -const panelMessage = interaction.options.getString("panel_message") || "Click the button below to create a support ticket."; +const panelMessage = interaction.options.getString("panel_message") || "Klick auf den Button unten, um ein Support-Ticket zu erstellen."; const buttonLabel = interaction.options.getString("button_label") || -"Create Ticket"; +"Ticket erstellen"; const maxTicketsPerUser = interaction.options.getInteger("max_tickets_per_user") || 3; const dmOnClose = interaction.options.getBoolean("dm_on_close") !== false; const setupEmbed = createEmbed({ - title: "Support Tickets", + title: "Support-Tickets", description: panelMessage, color: getColor('info') }); @@ -184,28 +184,28 @@ description: panelMessage, }); } - let successMessage = `The ticket creation panel has been sent to ${panelChannel}.`; + let successMessage = `Das Ticket-Panel wurde in ${panelChannel} gesendet.`; if (categoryChannel) { - successMessage += `New tickets will be created in the **${categoryChannel.name}** category.`; + successMessage += ` Neue Tickets werden in der Kategorie **${categoryChannel.name}** erstellt.`; } else { - successMessage += 'New tickets will be created in a new "Tickets" category.'; + successMessage += ' Neue Tickets werden in einer neuen Kategorie „Tickets“ erstellt.'; } if (closedCategoryChannel) { - successMessage += `Closed tickets will be moved to **${closedCategoryChannel.name}**.`; + successMessage += ` Geschlossene Tickets werden nach **${closedCategoryChannel.name}** verschoben.`; } if (staffRole) { - successMessage += `**${staffRole.name}** role will have access to tickets.`; + successMessage += ` Die Rolle **${staffRole.name}** hat Zugriff auf Tickets.`; } - successMessage += `\n\n**Max Tickets Per User:** ${maxTicketsPerUser}\n**DM on Close:** ${dmOnClose ? 'Enabled' : 'Disabled'}`; + successMessage += `\n\n**Max. Tickets pro Nutzer:** ${maxTicketsPerUser}\n**DM beim Schließen:** ${dmOnClose ? 'Aktiviert' : 'Deaktiviert'}`; await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( - "Ticket Panel Set Up", + "Ticket-Panel eingerichtet", successMessage, ), ], @@ -225,45 +225,45 @@ description: panelMessage, }); const logEmbed = createEmbed({ - title: "Ticket System Setup (Configuration Log)", - description: `The ticket panel was set up in ${panelChannel} by ${interaction.user}.`, + title: "Einrichtung des Ticket-Systems (Konfigurations-Log)", + description: `Das Ticket-Panel wurde von ${interaction.user} in ${panelChannel} eingerichtet.`, color: getColor('warning') }) .addFields( { - name: "Panel Channel", + name: "Panel-Kanal", value: panelChannel.toString(), inline: true, }, { - name: "Ticket Category", + name: "Ticket-Kategorie", value: categoryChannel ? categoryChannel.toString() - : "None specified.", + : "Keine angegeben.", inline: true, }, { - name: "Closed Category", + name: "Kategorie für geschlossene Tickets", value: closedCategoryChannel ? closedCategoryChannel.toString() - : "None specified.", + : "Keine angegeben.", inline: true, }, { - name: "Staff Role", + name: "Team-Rolle", value: staffRole ? staffRole.toString() - : "None specified.", + : "Keine angegeben.", inline: true, }, { - name: "Max Tickets Per User", + name: "Max. Tickets pro Nutzer", value: maxTicketsPerUser.toString(), inline: true, }, { - name: "DM on Close", - value: dmOnClose ? 'Enabled' : 'Disabled', + name: "DM beim Schließen", + value: dmOnClose ? 'Aktiviert' : 'Deaktiviert', inline: true, }, { @@ -282,7 +282,7 @@ description: panelMessage, commandName: 'ticket_setup' }); if (interaction.deferred || interaction.replied) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Could not send the ticket panel or save configuration. Check the bot\'s permissions (especially the ability to send messages in the target channel) and database connection.' }).catch(err => { + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Das Ticket-Panel konnte nicht gesendet oder die Konfiguration nicht gespeichert werden. Prüf die Berechtigungen des Bots (vor allem Nachrichten senden im Zielkanal) und die Datenbankverbindung.' }).catch(err => { logger.error('Failed to send error reply', { error: err.message, guildId: interaction.guildId diff --git a/src/commands/Ticket/tickets.ts b/src/commands/Ticket/tickets.ts index 051651ea81..0f33187b2a 100644 --- a/src/commands/Ticket/tickets.ts +++ b/src/commands/Ticket/tickets.ts @@ -5,7 +5,7 @@ import priorityCommand from './modules/priority.js'; export default createRouterCommand({ name: 'tickets', - description: 'Ticket actions inside a ticket channel', + description: 'Ticket-Aktionen in einem Ticket-Kanal', category: 'Ticket', routes: [ { legacyName: 'claim', module: claimCommand }, diff --git a/src/commands/Tools/modules/baseconvert.ts b/src/commands/Tools/modules/baseconvert.ts index f0b8cb5fc5..c71ec604e7 100644 --- a/src/commands/Tools/modules/baseconvert.ts +++ b/src/commands/Tools/modules/baseconvert.ts @@ -14,10 +14,10 @@ interface BaseInfo { } const BASE_ALPHABETS: Record = { - 'BIN': { base: 2, prefix: '0b', name: 'Binary', alphabet: '01' }, - 'OCT': { base: 8, prefix: '0o', name: 'Octal', alphabet: '0-7' }, - 'DEC': { base: 10, prefix: '', name: 'Decimal', alphabet: '0-9' }, - 'HEX': { base: 16, prefix: '0x', name: 'Hexadecimal', alphabet: '0-9A-F' }, + 'BIN': { base: 2, prefix: '0b', name: 'Binär', alphabet: '01' }, + 'OCT': { base: 8, prefix: '0o', name: 'Oktal', alphabet: '0-7' }, + 'DEC': { base: 10, prefix: '', name: 'Dezimal', alphabet: '0-9' }, + 'HEX': { base: 16, prefix: '0x', name: 'Hexadezimal', alphabet: '0-9A-F' }, 'B64': { base: 64, prefix: 'b64:', name: 'Base64', alphabet: 'A-Za-z0-9+/=' }, 'B36': { base: 36, prefix: '', name: 'Base36', alphabet: '0-9A-Z' }, 'B58': { base: 58, prefix: '', name: 'Base58', alphabet: '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' }, @@ -105,19 +105,19 @@ function formatBigIntToBase(value: bigint, baseKey: string): string { export default { data: new SlashCommandBuilder() .setName('baseconvert') - .setDescription('Convert numbers between different bases') + .setDescription('Rechne Zahlen zwischen verschiedenen Zahlensystemen um') .addStringOption(option => option.setName('number') - .setDescription('The number to convert') + .setDescription('Die Zahl, die umgerechnet werden soll') .setRequired(true)) .addStringOption(option => option.setName('from') - .setDescription('Source base/format') + .setDescription('Ausgangssystem/-format') .setRequired(true) .addChoices(...BASE_NAMES)) .addStringOption(option => option.setName('to') - .setDescription('Target base/format (default: all)') + .setDescription('Zielsystem/-format (Standard: alle)') .setRequired(false) .addChoices(...BASE_NAMES)), @@ -145,7 +145,7 @@ export default { if (!cleanNumber) { return replyUserError(interaction, { type: ErrorTypes.VALIDATION, - message: 'You must provide a number to convert.\n\n**Example:** `/tools baseconvert number:1010 from:BIN to:DEC`', + message: 'Gib eine Zahl zum Umrechnen an.\n\n**Beispiel:** `/tools baseconvert number:1010 from:BIN to:DEC`', }); } @@ -155,18 +155,18 @@ export default { if (!regex.test(cleanNumber)) { let examples = ''; if (fromBase === 'BIN') { - examples = '\n\n**Valid:** 101, 1010, 11111 | **Invalid:** 5 (digit 5 not allowed)'; + examples = '\n\n**Gültig:** 101, 1010, 11111 | **Ungültig:** 5 (Ziffer 5 nicht erlaubt)'; } else if (fromBase === 'OCT') { - examples = '\n\n**Valid:** 77, 123, 755 | **Invalid:** 8 (only 0-7 allowed)'; + examples = '\n\n**Gültig:** 77, 123, 755 | **Ungültig:** 8 (nur 0-7 erlaubt)'; } else if (fromBase === 'DEC') { - examples = '\n\n**Valid:** 42, 123, 999 | **Invalid:** 12.34 (no decimals)'; + examples = '\n\n**Gültig:** 42, 123, 999 | **Ungültig:** 12.34 (keine Nachkommastellen)'; } else if (fromBase === 'HEX') { - examples = '\n\n**Valid:** FF, A1B2, DEADBEEF | **Invalid:** G (only 0-9, A-F)'; + examples = '\n\n**Gültig:** FF, A1B2, DEADBEEF | **Ungültig:** G (nur 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}`, + message: `Deine Eingabe: \`${cleanNumber}\`\n\nGültige Zeichen: \`${alphabet}\`${examples}`, }); } @@ -181,7 +181,7 @@ export default { 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.', + message: 'Die Zahl ist zu groß zum Verarbeiten.\n\nVersuch es mit einer kleineren Zahl.', }); } @@ -193,10 +193,10 @@ export default { 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()}\`` + '🔄 Ergebnis der Umrechnung', + `**Von ${fromName} (${fromBase}):** \`${fromPrefix}${cleanNumber}\`\n` + + `**Nach ${toName} (${toBase}):** \`${toPrefix}${result}\`\n` + + `**Dezimal:** \`${decimalValue.toLocaleString()}\`` ); embed.setColor(getColor('success')); @@ -206,13 +206,13 @@ export default { 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.', + message: 'Das Ergebnis wäre zu groß oder nicht kompatibel.\n\nVersuch es mit einer kleineren Zahl oder einem anderen Zielsystem.', }); } } else { - let description = `**Input (${fromName}):** \`${fromPrefix}${cleanNumber}\`\n`; - description += `**Decimal:** \`${decimalValue.toLocaleString()}\`\n\n`; + let description = `**Eingabe (${fromName}):** \`${fromPrefix}${cleanNumber}\`\n`; + description += `**Dezimal:** \`${decimalValue.toLocaleString()}\`\n\n`; for (const [baseKey, { prefix, name }] of Object.entries(BASE_ALPHABETS)) { if (baseKey === fromBase) continue; @@ -222,12 +222,12 @@ export default { description += `**${name} (${baseKey}):** \`${prefix}${value}\`\n`; } catch (error) { - description += `**${name} (${baseKey}):** *Too large to convert*\n`; + description += `**${name} (${baseKey}):** *Zu groß zum Umrechnen*\n`; } } const embed = successEmbed( - '🔄 Base Conversion Results', + '🔄 Ergebnisse der Umrechnung', description ); embed.setColor(getColor('primary')); diff --git a/src/commands/Tools/modules/calculate.ts b/src/commands/Tools/modules/calculate.ts index 303dcddfcf..14cb44ff75 100644 --- a/src/commands/Tools/modules/calculate.ts +++ b/src/commands/Tools/modules/calculate.ts @@ -36,12 +36,12 @@ export { calculationContexts }; export default { data: new SlashCommandBuilder() .setName("calculate") - .setDescription("Evaluate a mathematical expression") + .setDescription("Berechnet einen mathematischen Ausdruck") .addStringOption((option) => option .setName("expression") .setDescription( - "The mathematical expression to evaluate (e.g., 2+2*3, sin(45 deg), 16^0.5)", + "Der Ausdruck, der berechnet werden soll (z. B. 2+2*3, sin(45 deg), 16^0.5)", ) .setRequired(true), ), @@ -64,9 +64,9 @@ export default { ) { 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' + message: '**Enthält nicht unterstützte Zeichen.**\n\n' + + '✅ Unterstützt: Zahlen, Dezimalzahlen, + - * / ^ %, sin cos tan sqrt abs log exp, pi e, ()\n' + + '❌ Nicht unterstützt: eckige und geschweifte Klammern sowie andere Symbole' }); } @@ -83,9 +83,9 @@ export default { 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.' + message: '**Enthält gesperrte Code-Muster.**\n\n' + + '🚫 **Gesperrt:** import, require, eval, Function, setTimeout, setInterval, process, fs, document, window, fetch, Schleifen, async/await\n\n' + + 'Code-ähnliche Syntax ist in Berechnungen nicht erlaubt.' }); } } @@ -109,7 +109,7 @@ export default { } else if (typeof result === "boolean") { formattedResult = result ? "true" : "false"; } else if (result === null || result === undefined) { - formattedResult = "No result"; + formattedResult = "Kein Ergebnis"; } else if ( Array.isArray(result) || typeof result === "object" @@ -155,15 +155,15 @@ export default { .setStyle(ButtonStyle.Primary), new ButtonBuilder() .setCustomId(`calc_${interaction.id}_history`) - .setLabel("History") + .setLabel("Verlauf") .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.*`, + "🧮 Rechenergebnis", + `**Ausdruck:** \`${expression.replace(/`/g, "\`")}\`\n` + + `**Ergebnis:** \`${formattedResult}\`\n\n` + + `*Nutze die Buttons unten, um mit dem Ergebnis weiterzurechnen.*`, ); await InteractionHelper.safeEditReply(interaction, { @@ -195,7 +195,7 @@ export default { if (userHistory.length === 0) { await i.followUp({ - content: "No calculation history found.", + content: "Kein Rechenverlauf gefunden.", flags: ["Ephemeral"], }); return; @@ -210,7 +210,7 @@ export default { .join("\n\n"); await i.followUp({ - content: `📜 **Your Calculation History**\n\n${historyText}`, + content: `📜 **Dein Rechenverlauf**\n\n${historyText}`, flags: ["Ephemeral"], }); return; @@ -247,7 +247,7 @@ export default { await i.showModal({ customId: `calc_modal:${operation}`, - title: `Enter a number to ${operation}`, + title: `Zahl eingeben (${operator})`, components: [ { type: 1, @@ -255,8 +255,8 @@ export default { { type: 4, customId: `operand:${contextKey}`, - label: `Number to ${operator} with ${formattedResult}`, - placeholder: "Enter a number...", + label: `Rechnung: ${formattedResult} ${operator} …`, + placeholder: "Zahl eingeben …", style: 1, required: true, maxLength: 50, @@ -269,7 +269,7 @@ export default { logger.error("Failed to show modal:", modalError); if (!i.replied && !i.deferred) { await i.reply({ - content: "Failed to open calculator. Please try again.", + content: "Der Rechner konnte nicht geöffnet werden. Versuch es noch mal.", flags: ["Ephemeral"], }).catch(console.error); } @@ -280,7 +280,7 @@ export default { logger.error("Button interaction error:", error); if (!i.deferred && !i.replied) { await i.followUp({ - content: "An error occurred while processing your request.", + content: "Beim Verarbeiten deiner Anfrage ist ein Fehler aufgetreten.", flags: ["Ephemeral"], }).catch(console.error); } @@ -295,7 +295,7 @@ export default { .setCustomId( `calc_${interaction.id}_expired`, ) - .setLabel("Calculator Expired") + .setLabel("Rechner abgelaufen") .setStyle(ButtonStyle.Secondary) .setDisabled(true), ); @@ -304,7 +304,7 @@ export default { .editReply({ components: [disabledRow], content: - "⏱️ This calculator has expired. Use the command again to perform more calculations.", + "⏱️ Dieser Rechner ist abgelaufen. Nutze den Command erneut, um weiterzurechnen.", }) .catch(console.error); } else { @@ -324,25 +324,25 @@ export default { } catch (error) { logger.error('Calculation error:', error); - let errorMessage = 'Failed to evaluate the expression.'; + let errorMessage = 'Der Ausdruck konnte nicht berechnet werden. '; const errorText = error instanceof Error ? error.message : String(error); if (errorText.includes('Unexpected type')) { errorMessage += - 'The expression contains an unsupported operation or function.'; + 'Er enthält eine nicht unterstützte Operation oder Funktion.'; } else if (errorText.includes('Undefined symbol')) { errorMessage += - 'The expression contains an undefined variable or function.'; + 'Er enthält eine unbekannte Variable oder Funktion.'; } else if (errorText.includes('Brackets not balanced')) { - errorMessage += 'The expression has unbalanced brackets.'; + errorMessage += 'Die Klammern sind nicht ausgeglichen.'; } else if ( errorText.includes('Unexpected operator') || errorText.includes('Unexpected character') ) { errorMessage += - 'The expression contains an invalid operator or character.'; + 'Er enthält einen ungültigen Operator oder ein ungültiges Zeichen.'; } else { - errorMessage += 'Please check the syntax and try again.'; + errorMessage += 'Bitte prüf die Syntax und versuch es noch mal.'; } await replyUserError(interaction, { diff --git a/src/commands/Tools/modules/countdown.ts b/src/commands/Tools/modules/countdown.ts index 0e06893fe1..8ed3251fd9 100644 --- a/src/commands/Tools/modules/countdown.ts +++ b/src/commands/Tools/modules/countdown.ts @@ -1,5 +1,6 @@ import { SlashCommandBuilder, MessageFlags, type ActionRowBuilder, type ButtonBuilder, type GuildTextBasedChannel, type Message } from 'discord.js'; import { successEmbed } from '../../../utils/embeds.js'; +import { createError, ErrorTypes } from '../../../utils/errorHandler.js'; import { logger } from '../../../utils/logger.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; import { createControlButtons, formatTime, startCountdown } from '../../../handlers/countdownButtons.js'; @@ -23,11 +24,11 @@ export { activeCountdowns }; export default { data: new SlashCommandBuilder() .setName("countdown") - .setDescription("Start a countdown timer") + .setDescription("Starte einen Countdown") .addIntegerOption((option) => option .setName("minutes") - .setDescription("Number of minutes to count down (0-1440)") + .setDescription("Anzahl der Minuten (0-1440)") .setMinValue(0) .setMaxValue(1440) .setRequired(false), @@ -35,7 +36,7 @@ export default { .addIntegerOption((option) => option .setName("seconds") - .setDescription("Number of seconds to count down (0-59)") + .setDescription("Anzahl der Sekunden (0-59)") .setMinValue(0) .setMaxValue(59) .setRequired(false), @@ -43,7 +44,7 @@ export default { .addStringOption((option) => option .setName("title") - .setDescription("Optional title for the countdown") + .setDescription("Optionaler Titel für den Countdown") .setRequired(false), ), @@ -60,16 +61,16 @@ export default { const minutes = interaction.options.getInteger("minutes") || 0; const seconds = interaction.options.getInteger("seconds") || 0; - const title = interaction.options.getString("title") || "Countdown Timer"; + const title = interaction.options.getString("title") || "Countdown"; const totalSeconds = minutes * 60 + seconds; if (totalSeconds <= 0) { - throw new Error("Please specify a duration of at least 1 second."); + throw createError("Please specify a duration of at least 1 second.", ErrorTypes.VALIDATION, "Bitte gib eine Dauer von mindestens 1 Sekunde an.", { expected: true }); } if (totalSeconds > 86400) { - throw new Error("Countdown cannot be longer than 24 hours."); + throw createError("Countdown cannot be longer than 24 hours.", ErrorTypes.VALIDATION, "Ein Countdown darf höchstens 24 Stunden dauern.", { expected: true }); } const endTime = Date.now() + totalSeconds * 1000; @@ -79,7 +80,7 @@ export default { const initialEmbed = successEmbed( `⏱️ ${title}`, - `Time remaining: **${formatTime(totalSeconds)}**`, + `Verbleibende Zeit: **${formatTime(totalSeconds)}**`, ); const message = await (interaction.channel as GuildTextBasedChannel).send({ @@ -101,7 +102,7 @@ export default { startCountdown(countdownId, countdownData, activeCountdowns); await InteractionHelper.safeEditReply(interaction, { - content: "✅ Countdown started!", + content: "✅ Countdown gestartet!", flags: MessageFlags.Ephemeral, }); }, diff --git a/src/commands/Tools/modules/embedbuilder.ts b/src/commands/Tools/modules/embedbuilder.ts index 1619b9ef2e..ec05005840 100644 --- a/src/commands/Tools/modules/embedbuilder.ts +++ b/src/commands/Tools/modules/embedbuilder.ts @@ -64,17 +64,17 @@ const MAX_FIELDS = 25; const IDLE_TIMEOUT = 900_000; const COLOR_PRESETS = [ - { label: 'Primary (Blue)', value: '#336699', emoji: '' }, - { label: 'Success (Green)', value: '#57F287', emoji: '' }, - { label: 'Error (Red)', value: '#ED4245', emoji: '' }, - { label: 'Warning (Yellow)', value: '#FEE75C', emoji: '' }, - { label: 'Info (Bright Blue)', value: '#3498DB', emoji: '' }, + { label: 'Primär (Blau)', value: '#336699', emoji: '' }, + { label: 'Erfolg (Grün)', value: '#57F287', emoji: '' }, + { label: 'Fehler (Rot)', value: '#ED4245', emoji: '' }, + { label: 'Warnung (Gelb)', value: '#FEE75C', emoji: '' }, + { label: 'Info (Hellblau)', value: '#3498DB', emoji: '' }, { label: 'Blurple (Discord)', value: '#5865F2', emoji: '' }, { label: 'Fuchsia', value: '#EB459E', emoji: '' }, { label: 'Gold', value: '#F1C40F', emoji: '' }, - { label: 'White', value: '#FFFFFF', emoji: '' }, - { label: 'Dark', value: '#202225', emoji: '' }, - { label: 'Custom Hex...', value: '__custom__', emoji: '' }, + { label: 'Weiß', value: '#FFFFFF', emoji: '' }, + { label: 'Dunkel', value: '#202225', emoji: '' }, + { label: 'Eigener Hex-Wert …', value: '__custom__', emoji: '' }, ]; function isValidUrl(str: string) { @@ -135,7 +135,7 @@ function buildPreviewEmbed(state: EmbedBuilderState) { state.fields.length === 0 && !state.author?.name ) { - embed.setDescription('*(Empty — use the menu below to add content)*'); + embed.setDescription('*(Leer – nutze das Menü unten, um Inhalte hinzuzufügen)*'); } return embed; @@ -146,44 +146,44 @@ function buildDashboardEmbed(state: EmbedBuilderState) { str.length > n ? str.substring(0, n) + '…' : str; const lines = [ - `**Title** › ${state.title ?`\`${trunc(state.title, 40)}\`` : '`Not set`'}`, - `**Description** › ${state.description ?`${state.description.length} character(s)`: '`Not set`'}`, - `**Color** › ${state.color ?`\`${state.color}\`` : '`Default`'}`, - `**Author** › ${state.author?.name ?`\`${trunc(state.author.name, 30)}\`` : '`Not set`'}`, - `**Footer** › ${state.footer?.text ?`\`${trunc(state.footer.text, 30)}\`` : '`Not set`'}`, - `**Thumbnail** › ${state.thumbnail ? '✅ Set' : '`Not set`'}`, - `**Image** › ${state.image ? '✅ Set' : '`Not set`'}`, - `**Timestamp** › ${state.timestamp ? '✅ Enabled' : '`Disabled`'}`, - `**Fields** › ${state.fields.length} / ${MAX_FIELDS}`, + `**Titel** › ${state.title ?`\`${trunc(state.title, 40)}\`` : '`Nicht gesetzt`'}`, + `**Beschreibung** › ${state.description ?`${state.description.length} Zeichen`: '`Nicht gesetzt`'}`, + `**Farbe** › ${state.color ?`\`${state.color}\`` : '`Standard`'}`, + `**Autor** › ${state.author?.name ?`\`${trunc(state.author.name, 30)}\`` : '`Nicht gesetzt`'}`, + `**Footer** › ${state.footer?.text ?`\`${trunc(state.footer.text, 30)}\`` : '`Nicht gesetzt`'}`, + `**Vorschaubild** › ${state.thumbnail ? '✅ Gesetzt' : '`Nicht gesetzt`'}`, + `**Bild** › ${state.image ? '✅ Gesetzt' : '`Nicht gesetzt`'}`, + `**Zeitstempel** › ${state.timestamp ? '✅ Aktiviert' : '`Deaktiviert`'}`, + `**Felder** › ${state.fields.length} / ${MAX_FIELDS}`, ]; return new EmbedBuilder() - .setTitle('Embed Builder — Control Panel') + .setTitle('Embed-Builder – Steuerung') .setDescription(lines.join('\n')) .setColor(getColor('info')) - .setFooter({ text: 'The preview above updates live · Closes after 5 min of inactivity' }); + .setFooter({ text: 'Die Vorschau oben aktualisiert sich live · Schließt nach 15 Min. Inaktivität' }); } function buildMainMenu(state: EmbedBuilderState) { const primaryRow = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('eb_main_edit_content') - .setLabel('Edit Content') + .setLabel('Inhalt bearbeiten') .setStyle(ButtonStyle.Primary) .setEmoji('✏️'), new ButtonBuilder() .setCustomId('eb_main_set_color') - .setLabel('Set Color') + .setLabel('Farbe festlegen') .setStyle(ButtonStyle.Secondary) .setEmoji('🎨'), new ButtonBuilder() .setCustomId('eb_main_set_images') - .setLabel('Set Images') + .setLabel('Bilder festlegen') .setStyle(ButtonStyle.Secondary) .setEmoji('🖼️'), new ButtonBuilder() .setCustomId('eb_main_post_embed') - .setLabel('Post Embed') + .setLabel('Embed posten') .setStyle(ButtonStyle.Success) .setEmoji('📤'), ); @@ -191,24 +191,24 @@ function buildMainMenu(state: EmbedBuilderState) { const secondaryRow = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('eb_main_add_field') - .setLabel(`Add Field (${state.fields.length}/${MAX_FIELDS})`) + .setLabel(`Feld hinzufügen (${state.fields.length}/${MAX_FIELDS})`) .setStyle(ButtonStyle.Primary) .setEmoji('➕'), new ButtonBuilder() .setCustomId('eb_main_edit_field') - .setLabel('Edit Field') + .setLabel('Feld bearbeiten') .setStyle(ButtonStyle.Secondary) .setEmoji('📝') .setDisabled(state.fields.length === 0), new ButtonBuilder() .setCustomId('eb_main_remove_field') - .setLabel('Remove Field') + .setLabel('Feld entfernen') .setStyle(ButtonStyle.Danger) .setEmoji('➖') .setDisabled(state.fields.length === 0), new ButtonBuilder() .setCustomId('eb_main_toggle_timestamp') - .setLabel(state.timestamp ? 'Disable Timestamp' : 'Enable Timestamp') + .setLabel(state.timestamp ? 'Zeitstempel ausschalten' : 'Zeitstempel einschalten') .setStyle(ButtonStyle.Secondary) .setEmoji('🕐'), ); @@ -216,18 +216,18 @@ function buildMainMenu(state: EmbedBuilderState) { const tertiaryRow = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('eb_main_reorder_fields') - .setLabel('Reorder Fields') + .setLabel('Felder sortieren') .setStyle(ButtonStyle.Secondary) .setEmoji('↕️') .setDisabled(state.fields.length < 2), new ButtonBuilder() .setCustomId('eb_main_json_export') - .setLabel('JSON / Raw Data') + .setLabel('JSON / Rohdaten') .setStyle(ButtonStyle.Secondary) .setEmoji('📋'), new ButtonBuilder() .setCustomId('eb_main_reset_all') - .setLabel('Reset Everything') + .setLabel('Alles zurücksetzen') .setStyle(ButtonStyle.Danger) .setEmoji('🗑️'), ); @@ -245,27 +245,27 @@ async function refreshDashboard(interaction: ChatInputCommandInteraction, state: async function handleEditContent(selectInteraction: ButtonInteraction, rootInteraction: ChatInputCommandInteraction, state: EmbedBuilderState) { const modal = new ModalBuilder() .setCustomId('eb_content') - .setTitle('Edit Content') + .setTitle('Inhalt bearbeiten') .addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('eb_title') - .setLabel('Title (max 256 characters)') + .setLabel('Titel (max. 256 Zeichen)') .setStyle(TextInputStyle.Short) .setValue(state.title || '') .setMaxLength(256) .setRequired(false) - .setPlaceholder('My Embed Title'), + .setPlaceholder('Mein Embed-Titel'), ), new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('eb_description') - .setLabel('Description (max 4000 characters)') + .setLabel('Beschreibung (max. 4000 Zeichen)') .setStyle(TextInputStyle.Paragraph) .setValue(state.description ? state.description.substring(0, 4000) : '') .setMaxLength(4000) .setRequired(false) - .setPlaceholder('Write your embed description here...'), + .setPlaceholder('Schreib hier die Beschreibung deines Embeds …'), ), ); @@ -294,23 +294,23 @@ async function handleSetColor(selectInteraction: ButtonInteraction, rootInteract const colorSelect = new StringSelectMenuBuilder() .setCustomId('eb_color_pick') - .setPlaceholder('Choose a color...') + .setPlaceholder('Wähle eine Farbe …') .addOptions( COLOR_PRESETS.map(c => new StringSelectMenuOptionBuilder() .setLabel(c.label) .setValue(c.value) .setEmoji(c.emoji) - .setDescription(c.value !== '__custom__' ? c.value : 'Enter your own #RRGGBB value'), + .setDescription(c.value !== '__custom__' ? c.value : 'Gib deinen eigenen #RRGGBB-Wert ein'), ), ); await selectInteraction.followUp({ embeds: [ new EmbedBuilder() - .setTitle('Set Color') + .setTitle('Farbe festlegen') .setDescription( - 'Select a preset color or choose **Custom Hex** to enter your own `#RRGGBB` value.', + 'Wähle eine vordefinierte Farbe oder **Eigener Hex-Wert**, um einen eigenen `#RRGGBB`-Wert einzugeben.', ) .setColor(getColor('info')), ], @@ -333,12 +333,12 @@ async function handleSetColor(selectInteraction: ButtonInteraction, rootInteract if (picked === '__custom__') { const hexModal = new ModalBuilder() .setCustomId('eb_custom_hex') - .setTitle('Custom Color') + .setTitle('Eigene Farbe') .addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('hex_value') - .setLabel('Hex Color Code') + .setLabel('Hex-Farbcode') .setStyle(TextInputStyle.Short) .setPlaceholder('#5865F2') .setMaxLength(7) @@ -364,7 +364,7 @@ async function handleSetColor(selectInteraction: ButtonInteraction, rootInteract if (!isValidHex(hex)) { await replyUserError(hexSubmit, { type: ErrorTypes.USER_INPUT, - message: `\`${hex}\` is not a valid hex color. Use the format \`#RRGGBB\` (e.g. \`#5865F2\`).`, + message: `\`${hex}\` ist keine gültige Hex-Farbe. Nutze das Format \`#RRGGBB\` (z. B. \`#5865F2\`).`, }); return; } @@ -386,22 +386,22 @@ async function handleSetColor(selectInteraction: ButtonInteraction, rootInteract async function handleSetAuthor(selectInteraction: ButtonInteraction, rootInteraction: ChatInputCommandInteraction, state: EmbedBuilderState) { const modal = new ModalBuilder() .setCustomId('eb_author') - .setTitle('Set Author') + .setTitle('Autor festlegen') .addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('author_name') - .setLabel('Author Name (leave blank to remove)') + .setLabel('Autorname (leer lassen zum Entfernen)') .setStyle(TextInputStyle.Short) .setValue(state.author?.name || '') .setMaxLength(256) .setRequired(false) - .setPlaceholder('Your Name'), + .setPlaceholder('Dein Name'), ), new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('author_icon') - .setLabel('Author Icon URL (optional)') + .setLabel('Autor-Icon-URL (optional)') .setStyle(TextInputStyle.Short) .setValue(state.author?.iconUrl || '') .setRequired(false) @@ -410,7 +410,7 @@ async function handleSetAuthor(selectInteraction: ButtonInteraction, rootInterac new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('author_url') - .setLabel('Author Link URL (optional)') + .setLabel('Autor-Link-URL (optional)') .setStyle(TextInputStyle.Short) .setValue(state.author?.url || '') .setRequired(false) @@ -437,14 +437,14 @@ async function handleSetAuthor(selectInteraction: ButtonInteraction, rootInterac if (iconUrl && !isValidUrl(iconUrl)) { await replyUserError(submitted, { type: ErrorTypes.USER_INPUT, - message: 'Author icon URL must be a valid `https://` URL.', + message: 'Die Autor-Icon-URL muss eine gültige `https://`-URL sein.', }); return; } if (url && !isValidUrl(url)) { await replyUserError(submitted, { type: ErrorTypes.USER_INPUT, - message: 'Author link URL must be a valid `https://` URL.', + message: 'Die Autor-Link-URL muss eine gültige `https://`-URL sein.', }); return; } @@ -458,22 +458,22 @@ async function handleSetAuthor(selectInteraction: ButtonInteraction, rootInterac async function handleSetFooter(selectInteraction: ButtonInteraction, rootInteraction: ChatInputCommandInteraction, state: EmbedBuilderState) { const modal = new ModalBuilder() .setCustomId('eb_footer') - .setTitle('Set Footer') + .setTitle('Footer festlegen') .addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('footer_text') - .setLabel('Footer Text (leave blank to remove)') + .setLabel('Footer-Text (leer lassen zum Entfernen)') .setStyle(TextInputStyle.Short) .setValue(state.footer?.text || '') .setMaxLength(2048) .setRequired(false) - .setPlaceholder('Built with Nexo'), + .setPlaceholder('Erstellt mit Nexo'), ), new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('footer_icon') - .setLabel('Footer Icon URL (optional)') + .setLabel('Footer-Icon-URL (optional)') .setStyle(TextInputStyle.Short) .setValue(state.footer?.iconUrl || '') .setRequired(false) @@ -499,7 +499,7 @@ async function handleSetFooter(selectInteraction: ButtonInteraction, rootInterac if (iconUrl && !isValidUrl(iconUrl)) { await replyUserError(submitted, { type: ErrorTypes.USER_INPUT, - message: 'Footer icon URL must be a valid `https://` URL.', + message: 'Die Footer-Icon-URL muss eine gültige `https://`-URL sein.', }); return; } @@ -515,26 +515,26 @@ async function handleSetImages(selectInteraction: ButtonInteraction, rootInterac const imageSelect = new StringSelectMenuBuilder() .setCustomId('eb_image_pick') - .setPlaceholder('What would you like to change?') + .setPlaceholder('Was möchtest du ändern?') .addOptions( new StringSelectMenuOptionBuilder() - .setLabel('Set Thumbnail') - .setDescription('Small image displayed in the top-right corner') + .setLabel('Vorschaubild setzen') + .setDescription('Kleines Bild oben rechts') .setValue('set_thumbnail') .setEmoji('🖼️'), new StringSelectMenuOptionBuilder() - .setLabel('Set Large Image') - .setDescription('Full-width banner image at the bottom') + .setLabel('Großes Bild setzen') + .setDescription('Banner in voller Breite unten') .setValue('set_image') .setEmoji('📸'), new StringSelectMenuOptionBuilder() - .setLabel('Clear Thumbnail') - .setDescription('Remove the current thumbnail') + .setLabel('Vorschaubild entfernen') + .setDescription('Aktuelles Vorschaubild entfernen') .setValue('clear_thumbnail') .setEmoji('🗑️'), new StringSelectMenuOptionBuilder() - .setLabel('Clear Large Image') - .setDescription('Remove the current large image') + .setLabel('Großes Bild entfernen') + .setDescription('Aktuelles großes Bild entfernen') .setValue('clear_image') .setEmoji('🗑️'), ); @@ -542,11 +542,11 @@ async function handleSetImages(selectInteraction: ButtonInteraction, rootInterac await selectInteraction.followUp({ embeds: [ new EmbedBuilder() - .setTitle('Set Images') - .setDescription('Choose which image to set or remove.') + .setTitle('Bilder festlegen') + .setDescription('Wähle, welches Bild du setzen oder entfernen willst.') .addFields( - { name: 'Thumbnail', value: state.thumbnail ? `[View](${state.thumbnail})` : '`Not set`', inline: true }, - { name: 'Large Image', value: state.image ? `[View](${state.image})` : '`Not set`', inline: true }, + { name: 'Vorschaubild', value: state.thumbnail ? `[Ansehen](${state.thumbnail})` : '`Nicht gesetzt`', inline: true }, + { name: 'Großes Bild', value: state.image ? `[Ansehen](${state.image})` : '`Nicht gesetzt`', inline: true }, ) .setColor(getColor('info')), ], @@ -583,12 +583,12 @@ async function handleSetImages(selectInteraction: ButtonInteraction, rootInterac const urlModal = new ModalBuilder() .setCustomId('eb_image_url') - .setTitle(isThumb ? 'Set Thumbnail' : 'Set Large Image') + .setTitle(isThumb ? 'Vorschaubild setzen' : 'Großes Bild setzen') .addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('image_url') - .setLabel('Image URL') + .setLabel('Bild-URL') .setStyle(TextInputStyle.Short) .setValue(isThumb ? (state.thumbnail || '') : (state.image || '')) .setRequired(true) @@ -613,7 +613,7 @@ async function handleSetImages(selectInteraction: ButtonInteraction, rootInterac if (!isValidUrl(url)) { await replyUserError(submitted, { type: ErrorTypes.USER_INPUT, - message: 'Image URL must be a valid `https://` link to a publicly accessible image.', + message: 'Die Bild-URL muss ein gültiger `https://`-Link zu einem öffentlich erreichbaren Bild sein.', }); return; } @@ -634,47 +634,47 @@ async function handleAddField(selectInteraction: ButtonInteraction, rootInteract await selectInteraction.deferUpdate(); await replyUserError(selectInteraction, { type: ErrorTypes.VALIDATION, - message: `Embeds can have a maximum of ${MAX_FIELDS} fields.`, + message: `Embeds können maximal ${MAX_FIELDS} Felder haben.`, }); return; } const modal = new ModalBuilder() .setCustomId('eb_add_field') - .setTitle('Add Field'); + .setTitle('Feld hinzufügen'); const fieldNameLabel = new LabelBuilder() - .setLabel('Field Name (max 256 characters)') + .setLabel('Feldname (max. 256 Zeichen)') .setTextInputComponent( new TextInputBuilder() .setCustomId('field_name') .setStyle(TextInputStyle.Short) .setMaxLength(256) .setRequired(true) - .setPlaceholder('Field Title'), + .setPlaceholder('Feldtitel'), ); const fieldValueLabel = new LabelBuilder() - .setLabel('Field Value (max 1024 characters)') + .setLabel('Feldinhalt (max. 1024 Zeichen)') .setTextInputComponent( new TextInputBuilder() .setCustomId('field_value') .setStyle(TextInputStyle.Paragraph) .setMaxLength(1024) .setRequired(true) - .setPlaceholder('Field content goes here...'), + .setPlaceholder('Hier kommt der Feldinhalt hin …'), ); const inlineRadio = new RadioGroupBuilder() .setCustomId('field_inline') .setRequired(false) .addOptions([ - { label: 'No — full width', value: 'no' }, - { label: 'Yes — side-by-side', value: 'yes' }, + { label: 'Nein – volle Breite', value: 'no' }, + { label: 'Ja – nebeneinander', value: 'yes' }, ]); const inlineLabel = new LabelBuilder() - .setLabel('Display inline?') + .setLabel('Inline anzeigen?') .setRadioGroupComponent(inlineRadio); modal.addLabelComponents(fieldNameLabel, fieldValueLabel, inlineLabel); @@ -706,13 +706,13 @@ async function handleEditField(selectInteraction: ButtonInteraction, rootInterac const pickSelect = new StringSelectMenuBuilder() .setCustomId('eb_edit_field_pick') - .setPlaceholder('Select a field to edit...') + .setPlaceholder('Wähle ein Feld zum Bearbeiten …') .addOptions( state.fields.slice(0, 25).map((f, i) => new StringSelectMenuOptionBuilder() .setLabel(`${i + 1}. ${f.name.substring(0, 50)}`) .setDescription( - `${f.value.substring(0, 80)}${f.value.length > 80 ? '…' : ''} · ${f.inline ? 'Inline' : 'Block'}`, + `${f.value.substring(0, 80)}${f.value.length > 80 ? '…' : ''} · ${f.inline ? 'Nebeneinander' : 'Volle Breite'}`, ) .setValue(String(i)) .setEmoji('📝'), @@ -722,8 +722,8 @@ async function handleEditField(selectInteraction: ButtonInteraction, rootInterac await selectInteraction.followUp({ embeds: [ new EmbedBuilder() - .setTitle('Edit Field') - .setDescription('Select the field you want to modify.') + .setTitle('Feld bearbeiten') + .setDescription('Wähle das Feld, das du ändern willst.') .setColor(getColor('info')), ], components: [new ActionRowBuilder().addComponents(pickSelect)], @@ -746,10 +746,10 @@ async function handleEditField(selectInteraction: ButtonInteraction, rootInterac const modal = new ModalBuilder() .setCustomId('eb_edit_field_modal') - .setTitle(`Edit Field ${idx + 1}`); + .setTitle(`Feld ${idx + 1} bearbeiten`); const editNameLabel = new LabelBuilder() - .setLabel('Field Name') + .setLabel('Feldname') .setTextInputComponent( new TextInputBuilder() .setCustomId('field_name') @@ -760,7 +760,7 @@ async function handleEditField(selectInteraction: ButtonInteraction, rootInterac ); const editValueLabel = new LabelBuilder() - .setLabel('Field Value') + .setLabel('Feldinhalt') .setTextInputComponent( new TextInputBuilder() .setCustomId('field_value') @@ -774,19 +774,19 @@ async function handleEditField(selectInteraction: ButtonInteraction, rootInterac .setCustomId('field_inline') .setRequired(false) .addOptions([ - { label: 'No — full width', value: 'no' }, - { label: 'Yes — side-by-side', value: 'yes' }, + { label: 'Nein – volle Breite', value: 'no' }, + { label: 'Ja – nebeneinander', value: 'yes' }, ]); if (field.inline) { editInlineRadio.setOptions([ - { label: 'No — full width', value: 'no' }, - { label: 'Yes — side-by-side', value: 'yes', default: true }, + { label: 'Nein – volle Breite', value: 'no' }, + { label: 'Ja – nebeneinander', value: 'yes', default: true }, ]); } const editInlineLabel = new LabelBuilder() - .setLabel('Display inline?') + .setLabel('Inline anzeigen?') .setRadioGroupComponent(editInlineRadio); modal.addLabelComponents(editNameLabel, editValueLabel, editInlineLabel); @@ -823,7 +823,7 @@ async function handleRemoveField(selectInteraction: ButtonInteraction, rootInter const pickSelect = new StringSelectMenuBuilder() .setCustomId('eb_remove_field_pick') - .setPlaceholder('Select a field to remove...') + .setPlaceholder('Wähle ein Feld zum Entfernen …') .addOptions( state.fields.slice(0, 25).map((f, i) => new StringSelectMenuOptionBuilder() @@ -839,8 +839,8 @@ async function handleRemoveField(selectInteraction: ButtonInteraction, rootInter await selectInteraction.followUp({ embeds: [ new EmbedBuilder() - .setTitle('Remove Field') - .setDescription('Select the field you want to delete.') + .setTitle('Feld entfernen') + .setDescription('Wähle das Feld, das du löschen willst.') .setColor(getColor('warning')), ], components: [new ActionRowBuilder().addComponents(pickSelect)], @@ -868,7 +868,7 @@ async function handleReorderFields(selectInteraction: ButtonInteraction, rootInt const pickSelect = new StringSelectMenuBuilder() .setCustomId('eb_reorder_pick') - .setPlaceholder('Select a field to move...') + .setPlaceholder('Wähle ein Feld zum Verschieben …') .addOptions( state.fields.slice(0, 25).map((f, i) => new StringSelectMenuOptionBuilder() @@ -884,8 +884,8 @@ async function handleReorderFields(selectInteraction: ButtonInteraction, rootInt await selectInteraction.followUp({ embeds: [ new EmbedBuilder() - .setTitle('Reorder Fields') - .setDescription('Select a field, then use the arrows to move it up or down.') + .setTitle('Felder sortieren') + .setDescription('Wähle ein Feld und verschiebe es dann mit den Pfeilen nach oben oder unten.') .setColor(getColor('info')), ], components: [new ActionRowBuilder().addComponents(pickSelect)], @@ -906,29 +906,29 @@ async function handleReorderFields(selectInteraction: ButtonInteraction, rootInt const upBtn = new ButtonBuilder() .setCustomId('eb_reorder_up') - .setLabel('Move Up') + .setLabel('Nach oben') .setStyle(ButtonStyle.Primary) .setEmoji('⬆️') .setDisabled(sourceIdx === 0); const downBtn = new ButtonBuilder() .setCustomId('eb_reorder_down') - .setLabel('Move Down') + .setLabel('Nach unten') .setStyle(ButtonStyle.Primary) .setEmoji('⬇️') .setDisabled(sourceIdx === state.fields.length - 1); const cancelBtn = new ButtonBuilder() .setCustomId('eb_reorder_cancel') - .setLabel('Cancel') + .setLabel('Abbrechen') .setStyle(ButtonStyle.Secondary); await pickInter.followUp({ embeds: [ new EmbedBuilder() - .setTitle('Move Field') + .setTitle('Feld verschieben') .setDescription( - `Moving **${state.fields[sourceIdx].name}** — currently at position **${sourceIdx + 1}** of **${state.fields.length}**.`, + `Verschiebe **${state.fields[sourceIdx].name}** – aktuell an Position **${sourceIdx + 1}** von **${state.fields.length}**.`, ) .setColor(getColor('info')), ], @@ -973,7 +973,7 @@ async function handlePostEmbed(selectInteraction: ButtonInteraction, rootInterac await selectInteraction.deferUpdate(); await replyUserError(selectInteraction, { type: ErrorTypes.VALIDATION, - message: 'Add at least a title, description, or field before posting.', + message: 'Füge vor dem Posten mindestens einen Titel, eine Beschreibung oder ein Feld hinzu.', }); return; } @@ -982,14 +982,14 @@ async function handlePostEmbed(selectInteraction: ButtonInteraction, rootInterac const chanSelect = new ChannelSelectMenuBuilder() .setCustomId('eb_post_channel') - .setPlaceholder('Select a channel...') + .setPlaceholder('Wähle einen Kanal …') .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement); await selectInteraction.followUp({ embeds: [ new EmbedBuilder() - .setTitle('Post Embed') - .setDescription('Select the channel where this embed will be sent.') + .setTitle('Embed posten') + .setDescription('Wähle den Kanal, in den das Embed gesendet werden soll.') .setColor(getColor('info')), ], components: [new ActionRowBuilder().addComponents(chanSelect)], @@ -1011,7 +1011,7 @@ async function handlePostEmbed(selectInteraction: ButtonInteraction, rootInterac if (!channel) { await replyUserError(chanInter, { type: ErrorTypes.USER_INPUT, - message: 'Could not resolve the selected channel.', + message: 'Der ausgewählte Kanal konnte nicht gefunden werden.', }); return; } @@ -1020,21 +1020,21 @@ async function handlePostEmbed(selectInteraction: ButtonInteraction, rootInterac if (!perms?.has([PermissionFlagsBits.SendMessages, PermissionFlagsBits.EmbedLinks])) { await replyUserError(chanInter, { type: ErrorTypes.PERMISSION, - message: `I need **Send Messages** and **Embed Links** permissions in ${channel} to post there.`, + message: `Ich brauche in ${channel} die Berechtigungen **Nachrichten senden** und **Links einbetten**, um dort zu posten.`, }); return; } const finalEmbed = buildPreviewEmbed(state); - if (finalEmbed.data.description === '*(Empty — use the menu below to add content)*') { + if (finalEmbed.data.description === '*(Leer – nutze das Menü unten, um Inhalte hinzuzufügen)*') { finalEmbed.setDescription(null); } await channel.send({ embeds: [finalEmbed] }); await chanInter.followUp({ - embeds: [successEmbed('Embed Sent', `Your embed has been posted to ${channel}.`)], + embeds: [successEmbed('Embed gesendet', `Dein Embed wurde in ${channel} gepostet.`)], flags: MessageFlags.Ephemeral, }); }); @@ -1061,7 +1061,7 @@ async function handleJsonExport(selectInteraction: ButtonInteraction, rootIntera embeds: [ new EmbedBuilder() .setTitle('Embed JSON') - .setDescription('The JSON is too long to display inline — see the attached file.') + .setDescription('Das JSON ist zu lang für die Anzeige – sieh dir die angehängte Datei an.') .setColor(getColor('info')), ], files: [ @@ -1079,7 +1079,7 @@ export default { slashOnly: true, data: new SlashCommandBuilder() .setName('embedbuilder') - .setDescription('Build and post a fully custom embed with live preview') + .setDescription('Erstelle und poste ein komplett eigenes Embed mit Live-Vorschau') .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages), async execute(interaction) { @@ -1167,8 +1167,8 @@ export default { logger.error('Error in embedbuilder collector:', error); const msg = error instanceof NexoBotError - ? error.userMessage || 'An error occurred.' - : 'An unexpected error occurred.'; + ? error.userMessage || 'Ein Fehler ist aufgetreten.' + : 'Ein unerwarteter Fehler ist aufgetreten.'; if (!ci.replied && !ci.deferred) await ci.deferUpdate().catch(() => {}); await replyUserError(ci, { type: ErrorTypes.UNKNOWN, @@ -1188,7 +1188,7 @@ export default { throw new NexoBotError( `embedbuilder failed: ${error instanceof Error ? error.message : String(error)}`, ErrorTypes.UNKNOWN, - 'Failed to open the embed builder.', + 'Der Embed-Builder konnte nicht geöffnet werden.', ); } }, diff --git a/src/commands/Tools/modules/generatepassword.ts b/src/commands/Tools/modules/generatepassword.ts index 67b3407ff0..54d54c2bb4 100644 --- a/src/commands/Tools/modules/generatepassword.ts +++ b/src/commands/Tools/modules/generatepassword.ts @@ -9,24 +9,24 @@ import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('generatepassword') - .setDescription('Generate a strong, random password') + .setDescription('Erzeugt ein starkes, zufälliges Passwort') .addIntegerOption(option => option.setName('length') - .setDescription('Password length (default: 16, max: 50)') + .setDescription('Passwortlänge (Standard: 16, max.: 50)') .setMinValue(8) .setMaxValue(50) .setRequired(false)) .addBooleanOption(option => option.setName('uppercase') - .setDescription('Include uppercase letters (A-Z)') + .setDescription('Großbuchstaben verwenden (A-Z)') .setRequired(false)) .addBooleanOption(option => option.setName('numbers') - .setDescription('Include numbers (0-9)') + .setDescription('Zahlen verwenden (0-9)') .setRequired(false)) .addBooleanOption(option => option.setName('symbols') - .setDescription('Include symbols (!@#$%^&*)') + .setDescription('Sonderzeichen verwenden (!@#$%^&*)') .setRequired(false)), async execute(interaction) { @@ -49,7 +49,7 @@ export default { 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}` }); + await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: `Das Passwort muss 8-50 Zeichen lang sein. Deine Angabe: ${length}` }); return; } @@ -90,7 +90,7 @@ export default { password = password.substring(0, randomIndex) + randomSymbol + password.substring(randomIndex + 1); } - let strength = 'Weak'; + let strength = 'Schwach'; let strengthEmoji = '🔴'; let strengthColor = getColor('error'); @@ -115,29 +115,29 @@ export default { if (hasSymbol) score *= 1.3; if (score > 80) { - strength = 'Very Strong'; + strength = 'Sehr stark'; strengthEmoji = '🟢'; strengthColor = getColor('success'); } else if (score > 60) { - strength = 'Strong'; + strength = 'Stark'; strengthEmoji = '🟢'; strengthColor = getColor('success'); } else if (score > 40) { - strength = 'Good'; + strength = 'Gut'; strengthEmoji = '🟡'; strengthColor = getColor('warning'); } else if (score > 20) { - strength = 'Weak'; + strength = 'Schwach'; 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' : ''}` + '🔑 Generiertes Passwort', + `**Passwort:** ||\`${password}\`||\n` + + `**Länge:** ${password.length} Zeichen\n` + + `**Stärke:** ${strengthEmoji} ${strength}\n` + + `**Enthält:** ${hasLower ? 'Kleinbuchstaben' : ''}${hasUpper ? ', Großbuchstaben' : ''}${hasNumber ? ', Zahlen' : ''}${hasSymbol ? ', Sonderzeichen' : ''}` ).setColor(strengthColor); await InteractionHelper.safeEditReply(interaction, { diff --git a/src/commands/Tools/modules/hexcolor.ts b/src/commands/Tools/modules/hexcolor.ts index fc1e2ea339..ea6a854236 100644 --- a/src/commands/Tools/modules/hexcolor.ts +++ b/src/commands/Tools/modules/hexcolor.ts @@ -9,10 +9,10 @@ import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('hexcolor') - .setDescription('Generate a random hex color with preview') + .setDescription('Erzeugt eine zufällige Hex-Farbe mit Vorschau') .addStringOption(option => option.setName('color') - .setDescription('Specific hex color (e.g., #FF5733 or FF5733)') + .setDescription('Bestimmte Hex-Farbe (z. B. #FF5733 oder FF5733)') .setRequired(false)), async execute(interaction) { @@ -28,7 +28,7 @@ export default { } else { hexColor = hexColor.replace('#', ''); if (!/^[0-9A-Fa-f]{3,6}$/.test(hexColor)) { - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please provide a valid hex code.\n\n**Valid formats:**\n• `#FF5733` (with hash)\n• `FF5733` (without hash)\n• `F57` (3-digit shorthand)\n\n**Invalid:** `#GG5733` (G is not a hex digit)' }); + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Bitte gib einen gültigen Hex-Code an.\n\n**Gültige Formate:**\n• `#FF5733` (mit Raute)\n• `FF5733` (ohne Raute)\n• `F57` (3-stellige Kurzform)\n\n**Ungültig:** `#GG5733` (G ist keine Hex-Ziffer)' }); } if (hexColor.length === 3) { @@ -50,22 +50,22 @@ export default { const colorName = getColorName(hexColor); const embed = successEmbed( - '🎨 Color Information', + '🎨 Farbinfo', `**Hex:** \`${hexColor}\`\n` + `**RGB:** \`rgb(${r}, ${g}, ${b})\`\n` + `**HSL:** \`${rgbToHsl(r, g, b)}\`\n` + - `**Name:** ${colorName || 'Custom Color'}` + `**Name:** ${colorName || 'Eigene Farbe'}` ) .setColor(hexColor as HexColorString) .setImage(colorPreviewUrl); if (isRandom) { - embed.setFooter({ text: 'Randomly generated color' }); + embed.setFooter({ text: 'Zufällig erzeugte Farbe' }); } await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, - 'Failed to generate color information. Please try again.', + 'Die Farbinfo konnte nicht erzeugt werden. Versuch es noch mal.', { autoDefer: true, deferOptions: { flags: MessageFlags.Ephemeral } @@ -97,26 +97,26 @@ h = s = 0; function getColorName(hex: string): string | null { const colors: Record = { - '#FF0000': 'Red', - '#00FF00': 'Green', - '#0000FF': 'Blue', - '#FFFF00': 'Yellow', + '#FF0000': 'Rot', + '#00FF00': 'Grün', + '#0000FF': 'Blau', + '#FFFF00': 'Gelb', '#FF00FF': 'Magenta', '#00FFFF': 'Cyan', - '#000000': 'Black', - '#FFFFFF': 'White', - '#808080': 'Gray', + '#000000': 'Schwarz', + '#FFFFFF': 'Weiß', + '#808080': 'Grau', '#FFA500': 'Orange', - '#800080': 'Purple', - '#A52A2A': 'Brown', - '#FFC0CB': 'Pink', - '#008000': 'Dark Green', - '#000080': 'Navy', + '#800080': 'Lila', + '#A52A2A': 'Braun', + '#FFC0CB': 'Rosa', + '#008000': 'Dunkelgrün', + '#000080': 'Marineblau', '#FFD700': 'Gold', - '#C0C0C0': 'Silver', - '#FF6347': 'Tomato', - '#40E0D0': 'Turquoise', - '#E6E6FA': 'Lavender' + '#C0C0C0': 'Silber', + '#FF6347': 'Tomatenrot', + '#40E0D0': 'Türkis', + '#E6E6FA': 'Lavendel' }; if (colors[hex.toUpperCase()]) { @@ -137,5 +137,5 @@ function getColorName(hex: string): string | null { } } - return minDistance < 1000000 ? `Close to ${closestColor}` : null; + return minDistance < 1000000 ? `Ähnlich wie ${closestColor}` : null; } \ No newline at end of file diff --git a/src/commands/Tools/modules/poll.ts b/src/commands/Tools/modules/poll.ts index 5ad7fcb4ba..ff402af22a 100644 --- a/src/commands/Tools/modules/poll.ts +++ b/src/commands/Tools/modules/poll.ts @@ -2,6 +2,7 @@ import { SlashCommandBuilder, MessageFlags, type GuildTextBasedChannel } from 'd import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; import { logger } from '../../../utils/logger.js'; import { getColor } from '../../../config/bot.js'; +import { createError, ErrorTypes } from '../../../utils/errorHandler.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; import type { Command } from '../../../types/index.js'; const EMOJIS = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣', '🔟']; @@ -9,54 +10,54 @@ const MAX_OPTIONS = 10; export default { data: new SlashCommandBuilder() .setName('poll') - .setDescription('Create a simple poll with up to 10 options') + .setDescription('Erstelle eine einfache Umfrage mit bis zu 10 Optionen') .addStringOption(option => option.setName('question') - .setDescription('The poll question') + .setDescription('Die Frage der Umfrage') .setRequired(true)) .addStringOption(option => option.setName('option1') - .setDescription('First option') + .setDescription('Erste Option') .setRequired(true)) .addStringOption(option => option.setName('option2') - .setDescription('Second option') + .setDescription('Zweite Option') .setRequired(true)) .addStringOption(option => option.setName('option3') - .setDescription('Third option (optional)') + .setDescription('Dritte Option (optional)') .setRequired(false)) .addStringOption(option => option.setName('option4') - .setDescription('Fourth option (optional)') + .setDescription('Vierte Option (optional)') .setRequired(false)) .addStringOption(option => option.setName('option5') - .setDescription('Fifth option (optional)') + .setDescription('Fünfte Option (optional)') .setRequired(false)) .addStringOption(option => option.setName('option6') - .setDescription('Sixth option (optional)') + .setDescription('Sechste Option (optional)') .setRequired(false)) .addStringOption(option => option.setName('option7') - .setDescription('Seventh option (optional)') + .setDescription('Siebte Option (optional)') .setRequired(false)) .addStringOption(option => option.setName('option8') - .setDescription('Eighth option (optional)') + .setDescription('Achte Option (optional)') .setRequired(false)) .addStringOption(option => option.setName('option9') - .setDescription('Ninth option (optional)') + .setDescription('Neunte Option (optional)') .setRequired(false)) .addStringOption(option => option.setName('option10') - .setDescription('Tenth option (optional)') + .setDescription('Zehnte Option (optional)') .setRequired(false)) .addBooleanOption(option => option.setName('anonymous') - .setDescription('Make the poll anonymous (default: false)') + .setDescription('Umfrage anonym machen (Standard: nein)') .setRequired(false)), async execute(interaction) { @@ -80,7 +81,7 @@ export default { } if (options.length < 2) { - throw new Error("You must provide at least 2 options for the poll."); + throw createError("You must provide at least 2 options for the poll.", ErrorTypes.VALIDATION, "Gib mindestens 2 Optionen für die Umfrage an.", { expected: true }); } let description = `**${question}**\n\n`; @@ -89,13 +90,13 @@ export default { }); if (isAnonymous) { - description += '\n*This is an anonymous poll. Votes are not tracked to users.*'; + description += '\n*Das ist eine anonyme Umfrage. Stimmen werden keinen Nutzern zugeordnet.*'; } else { - description += '\n*React with the emoji to vote!*'; + description += '\n*Reagiere mit dem Emoji, um abzustimmen!*'; } const embed = successEmbed( - `📊 ${isAnonymous ? 'Anonymous ' : ''}Poll`, + `📊 ${isAnonymous ? 'Anonyme ' : ''}Umfrage`, description ); @@ -107,7 +108,7 @@ export default { } await InteractionHelper.safeEditReply(interaction, { - content: '✅ Poll created successfully!', + content: '✅ Umfrage erstellt!', }); }, } satisfies Command; \ No newline at end of file diff --git a/src/commands/Tools/modules/randomuser.ts b/src/commands/Tools/modules/randomuser.ts index 250f046f05..750f39adb5 100644 --- a/src/commands/Tools/modules/randomuser.ts +++ b/src/commands/Tools/modules/randomuser.ts @@ -9,22 +9,22 @@ import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('randomuser') - .setDescription('Select a random user from the server') + .setDescription('Wählt einen zufälligen Nutzer vom Server aus') .addRoleOption(option => option.setName('role') - .setDescription('Limit selection to users with this role') + .setDescription('Nur Nutzer mit dieser Rolle berücksichtigen') .setRequired(false)) .addBooleanOption(option => option.setName('bots') - .setDescription('Include bots in the selection (default: false)') + .setDescription('Bots einbeziehen (Standard: nein)') .setRequired(false)) .addBooleanOption(option => option.setName('online') - .setDescription('Only select from online users (default: false)') + .setDescription('Nur Nutzer, die online sind (Standard: nein)') .setRequired(false)) .addBooleanOption(option => option.setName('mention') - .setDescription('Mention the selected user (default: false)') + .setDescription('Ausgewählten Nutzer erwähnen (Standard: nein)') .setRequired(false)), async execute(interaction) { @@ -41,7 +41,7 @@ export default { if (!interaction.guild) { return replyUserError(interaction, { type: ErrorTypes.VALIDATION, - message: 'This command can only be used in a server/guild.', + message: 'Dieser Command kann nur auf einem Server genutzt werden.', }); } @@ -67,14 +67,14 @@ export default { } 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.`; + let errorMessage = 'Es wurden keine Nutzer gefunden, die zu deinen Filtern passen.'; + if (role) errorMessage = `Kein Nutzer hat die Rolle **${role.name}**.`; + if (onlineOnly) errorMessage = 'Gerade ist kein Nutzer online.'; + if (role && onlineOnly) errorMessage = `Kein Mitglied mit der Rolle **${role.name}** ist online.`; return replyUserError(interaction, { type: ErrorTypes.USER_INPUT, - message: errorMessage + '\n\nTry adjusting your filters.', + message: errorMessage + '\n\nVersuch, deine Filter anzupassen.', }); } @@ -90,14 +90,14 @@ export default { .slice(0, 10); const embed = successEmbed( - '🎲 Random User Selected', + '🎲 Zufälliger Nutzer ausgewählt', shouldMention ? `${selectedMember}` : `**${user.username}**` ) .setThumbnail(user.displayAvatarURL({ 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 } + { name: 'Nutzername', value: user.username, inline: true }, + { name: 'Bot', value: user.bot ? 'Ja' : 'Nein', inline: true }, + { name: `Rollen (${roles.length})`, value: roles.length > 0 ? roles.slice(0, 5).join('') + (roles.length > 5 ? `+${roles.length - 5} weitere` : '') : 'Keine Rollen', inline: false } ) // NOTE: 'primary' is not a valid ColorResolvable (discord.js throws ColorConvert); kept as-is from JS .setColor('primary' as ColorResolvable); @@ -106,12 +106,12 @@ export default { .addComponents( new ButtonBuilder() .setCustomId(`randomuser_${interaction.user.id}_again`) - .setLabel('🎲 Pick Another User') + .setLabel('🎲 Anderen Nutzer wählen') .setStyle(ButtonStyle.Primary) ); const response = await interaction.editReply({ - content: shouldMention ? `${selectedMember}, you've been chosen!` : null, + content: shouldMention ? `${selectedMember}, du wurdest ausgewählt!` : null, embeds: [embed], components: [row], allowedMentions: { users: shouldMention ? [user.id] : [] } @@ -141,7 +141,7 @@ export default { if (newMemberArray.length === 0) { await replyUserError(i, { type: ErrorTypes.USER_INPUT, - message: 'No users found matching the criteria.', + message: 'Keine Nutzer gefunden, die zu den Kriterien passen.', }); return; } @@ -157,19 +157,19 @@ export default { .slice(0, 10); const newEmbed = successEmbed( - '🎲 Random User Selected', + '🎲 Zufälliger Nutzer ausgewählt', shouldMention ? `${newSelectedMember}` : `**${newUser.username}**` ) .setThumbnail(newUser.displayAvatarURL({ 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 } + { name: 'Nutzername', value: newUser.username, inline: true }, + { name: 'Bot', value: newUser.bot ? 'Ja' : 'Nein', inline: true }, + { name: `Rollen (${newRoles.length})`, value: newRoles.length > 0 ? newRoles.slice(0, 5).join('') + (newRoles.length > 5 ? `+${newRoles.length - 5} weitere` : '') : 'Keine Rollen', inline: false } ) .setColor(newSelectedMember.displayHexColor || '#3498db'); await i.update({ - content: shouldMention ? `${newSelectedMember}, you've been chosen!` : null, + content: shouldMention ? `${newSelectedMember}, du wurdest ausgewählt!` : null, embeds: [newEmbed], components: [row], allowedMentions: { users: shouldMention ? [newUser.id] : [] } @@ -178,7 +178,7 @@ export default { } catch (error) { logger.error('Button interaction error:', error); await i.reply({ - content: 'An error occurred while selecting another user.', + content: 'Beim Auswählen eines anderen Nutzers ist ein Fehler aufgetreten.', flags: ['Ephemeral'] }); } diff --git a/src/commands/Tools/modules/shorten.ts b/src/commands/Tools/modules/shorten.ts index 8442a049b2..b1603dcaf1 100644 --- a/src/commands/Tools/modules/shorten.ts +++ b/src/commands/Tools/modules/shorten.ts @@ -9,17 +9,17 @@ import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() .setName("shorten") - .setDescription("Shorten a URL using is.gd") + .setDescription("Kürze eine URL mit is.gd") .addStringOption(option => option .setName("url") - .setDescription("The URL to shorten") + .setDescription("Die URL, die gekürzt werden soll") .setRequired(true) ) .addStringOption(option => option .setName("custom") - .setDescription("Custom URL ending (optional)") + .setDescription("Eigenes URL-Ende (optional)") .setRequired(false) ) .setDMPermission(false), @@ -46,14 +46,14 @@ export default { } catch (e) { return replyUserError(interaction, { type: ErrorTypes.VALIDATION, - message: 'Invalid URL format. Include http:// or https://', + message: 'Ungültiges URL-Format. Gib http:// oder https:// mit an.', }); } 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.', + message: 'Das URL-Ende darf nur Buchstaben, Zahlen, Unterstriche und Bindestriche enthalten.', }); } @@ -75,8 +75,8 @@ export default { }); } catch (networkError) { const message = (networkError as { name?: string } | null | undefined)?.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.'; + ? 'Der URL-Kürzer hat nicht rechtzeitig geantwortet. Versuch es gleich noch mal.' + : 'Der URL-Kürzer ist gerade nicht erreichbar. Versuch es später noch mal.'; return replyUserError(interaction, { type: ErrorTypes.NETWORK, message, @@ -88,7 +88,7 @@ export default { if (!response.ok) { return replyUserError(interaction, { type: ErrorTypes.UNKNOWN, - message: `Shortener service returned HTTP ${response.status}. Please try again later.`, + message: `Der URL-Kürzer hat HTTP ${response.status} zurückgegeben. Versuch es später noch mal.`, }); } @@ -100,21 +100,21 @@ export default { if (shortUrl.includes("already exists")) { return replyUserError(interaction, { type: ErrorTypes.VALIDATION, - message: 'That custom URL is already taken. Try a different one.', + message: 'Dieses URL-Ende ist schon vergeben. Probier ein anderes.', }); } else if (shortUrl.includes("invalid")) { return replyUserError(interaction, { type: ErrorTypes.VALIDATION, - message: 'Invalid URL. Include http:// or https://', + message: 'Ungültige URL. Gib http:// oder https:// mit an.', }); } return replyUserError(interaction, { type: ErrorTypes.UNKNOWN, - message: `URL shortening failed: ${shortUrl}`, + message: `URL konnte nicht gekürzt werden: ${shortUrl}`, }); } - const embed = successEmbed('URL Shortened', `Here's your shortened URL: ${shortUrl}`); + const embed = successEmbed('URL gekürzt', `Hier ist deine gekürzte URL: ${shortUrl}`); embed.setColor(getColor('success')); await InteractionHelper.safeEditReply(interaction, { embeds: [embed], diff --git a/src/commands/Tools/modules/time.ts b/src/commands/Tools/modules/time.ts index a10dfca3c6..100c8c0346 100644 --- a/src/commands/Tools/modules/time.ts +++ b/src/commands/Tools/modules/time.ts @@ -7,10 +7,10 @@ import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('time') - .setDescription('Get the current time in different timezones') + .setDescription('Zeigt die aktuelle Uhrzeit in verschiedenen Zeitzonen') .addStringOption(option => option.setName('timezone') - .setDescription('The timezone to display (e.g., UTC, America/New_York)') + .setDescription('Die anzuzeigende Zeitzone (z. B. UTC, Europe/Berlin)') .setRequired(false)), async execute(interaction) { @@ -21,7 +21,7 @@ export default { let timeString; try { - timeString = new Date().toLocaleString('en-US', { + timeString = new Date().toLocaleString('de-DE', { timeZone: timezone, weekday: 'long', year: 'numeric', @@ -36,7 +36,7 @@ export default { logger.warn(`Invalid timezone requested: ${timezone}`); await replyUserError(interaction, { type: ErrorTypes.VALIDATION, - message: 'Invalid timezone. Please use a valid timezone identifier (e.g., UTC, America/New_York, Europe/London)', + message: 'Ungültige Zeitzone. Bitte nutze eine gültige Zeitzonen-ID (z. B. UTC, Europe/Berlin, America/New_York)', }); return; } @@ -45,15 +45,15 @@ export default { const unixTimestamp = Math.floor(now.getTime() / 1000); const embed = successEmbed( - '🕒 Current Time', + '🕒 Aktuelle Uhrzeit', `**${timezone}:** ${timeString}\n` + - `**Unix Timestamp:** \`${unixTimestamp}\`\n` + - `**ISO String:** \`${now.toISOString()}\`` + `**Unix-Zeitstempel:** \`${unixTimestamp}\`\n` + + `**ISO-String:** \`${now.toISOString()}\`` ); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, - 'Failed to get current time. Please try again.', + 'Die aktuelle Uhrzeit konnte nicht abgerufen werden. Versuch es noch mal.', { autoDefer: true, deferOptions: { flags: MessageFlags.Ephemeral } diff --git a/src/commands/Tools/modules/unixtime.ts b/src/commands/Tools/modules/unixtime.ts index d9feb0bada..fd17fa310a 100644 --- a/src/commands/Tools/modules/unixtime.ts +++ b/src/commands/Tools/modules/unixtime.ts @@ -7,7 +7,7 @@ import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('unixtime') - .setDescription('Get the current Unix timestamp'), + .setDescription('Zeigt den aktuellen Unix-Zeitstempel'), async execute(interaction) { await InteractionHelper.safeExecute( @@ -17,11 +17,11 @@ export default { const unixTimestamp = Math.floor(now.getTime() / 1000); const embed = successEmbed( - '⏱️ Current Unix Timestamp', - `**Seconds since Unix Epoch:** \`${unixTimestamp}\`\n` + - `**Milliseconds since Unix Epoch:** \`${now.getTime()}\`\n\n` + - `**Human-readable (UTC):** ${now.toUTCString()}\n` + - `**ISO String:** ${now.toISOString()}` + '⏱️ Aktueller Unix-Zeitstempel', + `**Sekunden seit Unix-Epoche:** \`${unixTimestamp}\`\n` + + `**Millisekunden seit Unix-Epoche:** \`${now.getTime()}\`\n\n` + + `**Lesbar (UTC):** ${now.toUTCString()}\n` + + `**ISO-String:** ${now.toISOString()}` ); embed.setColor(getColor('success')); @@ -29,7 +29,7 @@ export default { embeds: [embed], }); }, - 'Failed to get unix timestamp. Please try again.', + 'Der Unix-Zeitstempel konnte nicht abgerufen werden. Versuch es noch mal.', { autoDefer: true, deferOptions: { flags: MessageFlags.Ephemeral } diff --git a/src/commands/Tools/tools.ts b/src/commands/Tools/tools.ts index 6a8a36a98f..91432ba392 100644 --- a/src/commands/Tools/tools.ts +++ b/src/commands/Tools/tools.ts @@ -14,7 +14,7 @@ import firstmsgCommand from '../Utility/modules/firstmsg.js'; export default createRouterCommand({ name: 'tools', - description: 'Handy tools and utilities', + description: 'Praktische Tools und Helfer', category: 'Tools', routes: [ { legacyName: 'baseconvert', module: baseconvertCommand }, diff --git a/src/commands/Utility/info.ts b/src/commands/Utility/info.ts index 68dcd0d1e9..9f6fa61820 100644 --- a/src/commands/Utility/info.ts +++ b/src/commands/Utility/info.ts @@ -5,7 +5,7 @@ import userinfoCommand from './modules/userinfo.js'; export default createRouterCommand({ name: 'info', - description: 'Information about users and the server', + description: 'Infos über Nutzer und den Server', category: 'Utility', routes: [ { legacyName: 'avatar', module: avatarCommand }, diff --git a/src/commands/Utility/modules/avatar.ts b/src/commands/Utility/modules/avatar.ts index 8145379739..5e4d157ea6 100644 --- a/src/commands/Utility/modules/avatar.ts +++ b/src/commands/Utility/modules/avatar.ts @@ -7,12 +7,12 @@ import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() .setName("avatar") - .setDescription("Display a user's avatar image") + .setDescription("Zeigt den Avatar eines Nutzers an") .addUserOption((option) => option .setName("target") .setDescription( - "The user whose avatar you want to see (defaults to you)", + "Der Nutzer, dessen Avatar du sehen willst (Standard: du)", ), ), @@ -21,8 +21,8 @@ export default { const avatarUrl = user.displayAvatarURL({ size: 2048 }); const embed = createEmbed({ - title: `${user.username}'s Avatar`, - description: `[Download Link](${avatarUrl})` + title: `Avatar von ${user.username}`, + description: `[Download-Link](${avatarUrl})` }) .setImage(avatarUrl); diff --git a/src/commands/Utility/modules/firstmsg.ts b/src/commands/Utility/modules/firstmsg.ts index 7dccf836a3..18b271e7ab 100644 --- a/src/commands/Utility/modules/firstmsg.ts +++ b/src/commands/Utility/modules/firstmsg.ts @@ -6,7 +6,7 @@ import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() .setName("firstmsg") - .setDescription("Get a link to the first message in this channel") + .setDescription("Link zur ersten Nachricht in diesem Kanal") .setDMPermission(false) .setDefaultMemberPermissions(PermissionFlagsBits.SendMessages), category: "Utility", @@ -37,7 +37,7 @@ export default { guildId: interaction.guildId }); return await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed('First Message', "No messages found in this channel!")], + embeds: [successEmbed('Erste Nachricht', "In diesem Kanal wurden keine Nachrichten gefunden!")], }); } @@ -46,8 +46,8 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( - "First Message in #" + (interaction.channel as GuildTextBasedChannel).name, - `Message Link: ${messageLink}` + "Erste Nachricht in #" + (interaction.channel as GuildTextBasedChannel).name, + `Link zur Nachricht: ${messageLink}` ), ], }); diff --git a/src/commands/Utility/modules/report.ts b/src/commands/Utility/modules/report.ts index ba80f8ea76..87ebff603d 100644 --- a/src/commands/Utility/modules/report.ts +++ b/src/commands/Utility/modules/report.ts @@ -23,12 +23,12 @@ export default { const reportChannelId = resolveLogChannel(guildConfig, 'reports'); if (!reportChannelId) { - 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`.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Der Meldekanal ist noch nicht eingerichtet. Bitte einen Moderator, `/logging dashboard` oder `/logging channel` zu nutzen.' }); } const ownerMention = interaction.guild!.ownerId - ? `<@${interaction.guild!.ownerId}> New report!` - : 'New report!'; + ? `<@${interaction.guild!.ownerId}> Neue Meldung!` + : 'Neue Meldung!'; await logEvent({ client, @@ -36,13 +36,13 @@ export default { eventType: EVENT_TYPES.REPORT_FILE, content: ownerMention, data: { - title: 'User Report', + title: 'Nutzermeldung', lines: [ - formatLogLine('Reported User', `${targetUser.tag} (\`${targetUser.id}\`)`), - formatLogLine('Reported By', `${interaction.user.tag} (\`${interaction.user.id}\`)`), - formatLogLine('Channel', interaction.channel!.toString()), + formatLogLine('Gemeldeter Nutzer', `${targetUser.tag} (\`${targetUser.id}\`)`), + formatLogLine('Gemeldet von', `${interaction.user.tag} (\`${interaction.user.id}\`)`), + formatLogLine('Kanal', interaction.channel!.toString()), ], - blockFields: [{ name: 'Reason', value: reason }], + blockFields: [{ name: 'Grund', value: reason }], author: await resolveUserAuthor(client, targetUser.id), thumbnail: targetUser.displayAvatarURL(), }, @@ -50,8 +50,8 @@ export default { 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!`, + title: 'Meldung gesendet', + description: `Deine Meldung gegen **${targetUser.tag}** wurde an das Moderationsteam gesendet. Danke!`, })], }); diff --git a/src/commands/Utility/modules/report_setchannel.ts b/src/commands/Utility/modules/report_setchannel.ts index 1d3f6546eb..6a543acf3a 100644 --- a/src/commands/Utility/modules/report_setchannel.ts +++ b/src/commands/Utility/modules/report_setchannel.ts @@ -9,7 +9,7 @@ import type { SubcommandModule } from '../../../types/index.js'; export default { async execute(interaction, config, client) { if (!(interaction.member as GuildMember).permissions.has(PermissionsBitField.Flags.ManageGuild)) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need **Manage Server** permissions to set the report channel.' }); + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Du brauchst die Berechtigung **Server verwalten**, um den Meldekanal festzulegen.' }); } const channel = interaction.options.getChannel('channel')!; @@ -20,14 +20,14 @@ export default { return InteractionHelper.safeReply(interaction, { embeds: [successEmbed( - 'Report Channel Set', - `All new reports will now be sent to ${channel}.\nYou can also manage this from \`/logging dashboard\`.`, + 'Meldekanal festgelegt', + `Alle neuen Meldungen gehen ab jetzt an ${channel}.\nDu kannst das auch über \`/logging dashboard\` verwalten.`, )], ephemeral: true, }); } catch (error) { logger.error('report_setchannel error:', error); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Could not save the channel configuration.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Die Kanal-Einstellung konnte nicht gespeichert werden.' }); } }, } satisfies SubcommandModule; diff --git a/src/commands/Utility/modules/serverinfo.ts b/src/commands/Utility/modules/serverinfo.ts index 7321cc3423..50683ad22d 100644 --- a/src/commands/Utility/modules/serverinfo.ts +++ b/src/commands/Utility/modules/serverinfo.ts @@ -7,7 +7,7 @@ import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() .setName("serverinfo") - .setDescription("Get detailed information about the server"), + .setDescription("Zeigt ausführliche Infos über den Server"), async execute(interaction) { const deferSuccess = await InteractionHelper.safeDefer(interaction); @@ -25,24 +25,24 @@ export default { const createdTimestamp = Math.floor(guild.createdAt.getTime() / 1000); - const embed = createEmbed({ title: `Server Info: ${guild.name}`, description: `Server ID: ${guild.id}` }) + 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: "Inhaber", value: owner.user.tag, inline: true }, + { name: "Mitglieder", value: `${guild.memberCount}`, inline: true }, { - name: "Channels", + name: "Kanäle", value: `${guild.channels.cache.size}`, inline: true, }, - { name: "Roles", value: `${guild.roles.cache.size}`, inline: true }, + { name: "Rollen", value: `${guild.roles.cache.size}`, inline: true }, { name: "Boosts", value: `Level ${guild.premiumTier} (${guild.premiumSubscriptionCount})`, inline: true, }, { - name: "Creation Date", + name: "Erstellt", value: ``, inline: true, }, diff --git a/src/commands/Utility/modules/userinfo.ts b/src/commands/Utility/modules/userinfo.ts index ede8d3c10e..5457306d64 100644 --- a/src/commands/Utility/modules/userinfo.ts +++ b/src/commands/Utility/modules/userinfo.ts @@ -6,11 +6,11 @@ import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() .setName("userinfo") - .setDescription("Get detailed information about a user") + .setDescription("Zeigt ausführliche Infos über einen Nutzer") .addUserOption((option) => option .setName("target") - .setDescription("The user to inspect (defaults to you)"), + .setDescription("Der Nutzer, den du ansehen willst (Standard: du)"), ), async execute(interaction) { @@ -30,35 +30,35 @@ export default { 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}` }) + const embed = createEmbed({ title: `Nutzer-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: "Bot", value: user.bot ? "Ja" : "Nein", inline: true }, { - name: "Roles", + name: "Rollen", value: member && member.roles.cache.size > 1 ? member.roles.cache .map((r) => r.name) .slice(0, 5) .join(",") - : "None", + : "Keine", inline: true, }, { - name: "Account Created", + name: "Account erstellt", value: ``, inline: false, }, { - name: "Joined Server", - value: joinedTimestamp ? `` : "Not in server", + name: "Server beigetreten", + value: joinedTimestamp ? `` : "Nicht auf dem Server", inline: false, }, { - name: "Highest Role", - value: member?.roles?.highest?.name || "None", + name: "Höchste Rolle", + value: member?.roles?.highest?.name || "Keine", inline: true, }, ); diff --git a/src/commands/Utility/modules/weather.ts b/src/commands/Utility/modules/weather.ts index 21d21c38ac..a15b8db99a 100644 --- a/src/commands/Utility/modules/weather.ts +++ b/src/commands/Utility/modules/weather.ts @@ -37,11 +37,11 @@ interface WeatherResponse { export default { data: new SlashCommandBuilder() .setName("weather") - .setDescription("Get real-time weather information for a location") + .setDescription("Aktuelles Wetter für einen Ort abrufen") .addStringOption((option) => option .setName("city") - .setDescription("The city name, e.g., 'London' or 'Tokyo'") + .setDescription("Der Name der Stadt, z. B. 'Berlin' oder 'Tokio'") .setRequired(true), ), @@ -69,7 +69,7 @@ export default { city: city, guildId: interaction.guildId }); - await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: `Could not find a location for **${city}**. Please check the spelling.` }); + await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: `Für **${city}** wurde kein Ort gefunden. Bitte prüf die Schreibweise.` }); return; } @@ -88,38 +88,38 @@ export default { userId: interaction.user.id, guildId: interaction.guildId }); - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'A weather service error occurred.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Wetterdienst ist ein Fehler aufgetreten.' }); return; } const current: CurrentWeather = 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 temperature = current.temperature != null ? Math.round(current.temperature) : "–"; + const humidity = current.relativehumidity ?? current.relative_humidity_2m ?? "–"; + const windSpeed = current.windspeed != null ? Math.round(current.windspeed) : "–"; const weatherCode = current.weathercode ?? current.weather_code ?? null; const condition = getWeatherDescription(weatherCode); - const embed = createEmbed({ title: `Weather in ${cityDisplay}, ${country}`, description: condition.description }) + const embed = createEmbed({ title: `Wetter in ${cityDisplay}, ${country}`, description: condition.description }) .addFields( { - name: "Temperature", + name: "Temperatur", value: `${temperature}°C`, inline: true, }, { - name: "Humidity", + name: "Luftfeuchtigkeit", value: `${humidity}%`, inline: true, }, { - name: "Wind Speed", + name: "Windgeschwindigkeit", value: `${windSpeed} km/h`, inline: true, }, ) .setFooter({ - text: `Latitude: ${latitude.toFixed(2)} | Longitude: ${longitude.toFixed(2)}`, + text: `Breitengrad: ${latitude.toFixed(2)} | Längengrad: ${longitude.toFixed(2)}`, }); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); @@ -137,17 +137,17 @@ function getWeatherDescription(codeValue: number | null) { // Number(null) === 0, same coercion the relational operators applied in JS const code = Number(codeValue); if (code >= 0 && code <= 3) { - return { description: "Clear sky / Partly cloudy", emoji: "" }; + return { description: "Klar / teilweise bewölkt", emoji: "" }; } else if (code >= 45 && code <= 48) { - return { description: "Fog and Rime fog", emoji: "" }; + return { description: "Nebel und Raureifnebel", emoji: "" }; } else if (code >= 51 && code <= 67) { - return { description: "Drizzle or Rain", emoji: "" }; + return { description: "Nieselregen oder Regen", emoji: "" }; } else if (code >= 71 && code <= 75) { - return { description: "Snow fall", emoji: "" }; + return { description: "Schneefall", emoji: "" }; } else if (code >= 80 && code <= 86) { - return { description: "Showers (Rain/Snow)", emoji: "" }; + return { description: "Schauer (Regen/Schnee)", emoji: "" }; } else if (code >= 95 && code <= 99) { - return { description: "Thunderstorm", emoji: "" }; + return { description: "Gewitter", emoji: "" }; } - return { description: "Unknown conditions.", emoji: "" }; + return { description: "Unbekannte Wetterlage.", emoji: "" }; } \ No newline at end of file diff --git a/src/commands/Utility/report.ts b/src/commands/Utility/report.ts index 347b3fcd7c..1511d498d8 100644 --- a/src/commands/Utility/report.ts +++ b/src/commands/Utility/report.ts @@ -9,22 +9,22 @@ import type { Command } from '../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('report') - .setDescription('Report a user to server staff, or configure where reports are sent.') + .setDescription('Melde einen Nutzer an das Server-Team oder lege fest, wohin Meldungen gehen.') .setDMPermission(false) .addSubcommand(subcommand => subcommand .setName('file') - .setDescription('Report a user to the server moderation team.') + .setDescription('Melde einen Nutzer an das Moderationsteam.') .addUserOption(option => option .setName('user') - .setDescription('The user you want to report.') + .setDescription('Der Nutzer, den du melden möchtest.') .setRequired(true), ) .addStringOption(option => option .setName('reason') - .setDescription('The reason for the report (be detailed).') + .setDescription('Der Grund für die Meldung (bitte ausführlich).') .setRequired(true) .setMaxLength(500), ), @@ -32,11 +32,11 @@ export default { .addSubcommand(subcommand => subcommand .setName('setchannel') - .setDescription('Set the channel where user reports are sent. (Manage Server required)') + .setDescription('Lege den Kanal für Nutzermeldungen fest. („Server verwalten“ nötig)') .addChannelOption(option => option .setName('channel') - .setDescription('The text channel to receive reports.') + .setDescription('Der Textkanal, der die Meldungen empfängt.') .addChannelTypes(ChannelType.GuildText) .setRequired(true), ), @@ -54,6 +54,6 @@ export default { return await reportSetchannel.execute(interaction, config, client); } - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Unknown subcommand.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Unbekannter Subcommand.' }); }, } satisfies Command; \ No newline at end of file diff --git a/src/commands/Utility/todo.ts b/src/commands/Utility/todo.ts index 39f1667ea3..184ac0e65e 100644 --- a/src/commands/Utility/todo.ts +++ b/src/commands/Utility/todo.ts @@ -47,119 +47,119 @@ function generateShareId() { export default { data: new SlashCommandBuilder() .setName("todo") - .setDescription("Manage your personal to-do list") + .setDescription("Verwalte deine persönliche To-do-Liste") .addSubcommand(subcommand => subcommand .setName("add") - .setDescription("Add a task to your to-do list") + .setDescription("Füge eine Aufgabe zu deiner To-do-Liste hinzu") .addStringOption(option => option .setName("task") - .setDescription("The task to add") + .setDescription("Die Aufgabe, die du hinzufügen willst") .setRequired(true) ) ) .addSubcommand(subcommand => subcommand .setName("list") - .setDescription("View your to-do list") + .setDescription("Zeigt deine To-do-Liste an") ) .addSubcommand(subcommand => subcommand .setName("complete") - .setDescription("Mark a task as complete") + .setDescription("Markiere eine Aufgabe als erledigt") .addIntegerOption(option => option .setName("number") - .setDescription("The number of the task to complete") + .setDescription("Die Nummer der erledigten Aufgabe") .setRequired(true) ) ) .addSubcommand(subcommand => subcommand .setName("remove") - .setDescription("Remove a task from your to-do list") + .setDescription("Entferne eine Aufgabe von deiner To-do-Liste") .addIntegerOption(option => option .setName("number") - .setDescription("The number of the task to remove") + .setDescription("Die Nummer der Aufgabe, die du entfernen willst") .setRequired(true) ) ) .addSubcommandGroup(group => group .setName("share") - .setDescription("Manage shared to-do lists") + .setDescription("Verwalte geteilte To-do-Listen") .addSubcommand(subcommand => subcommand .setName("create") - .setDescription("Create a new shared to-do list") + .setDescription("Erstelle eine neue geteilte To-do-Liste") .addStringOption(option => option .setName("name") - .setDescription("Name for the shared list") + .setDescription("Name der geteilten Liste") .setRequired(true) ) ) .addSubcommand(subcommand => subcommand .setName("add") - .setDescription("Add a member to a shared list") + .setDescription("Füge ein Mitglied zu einer geteilten Liste hinzu") .addStringOption(option => option .setName("list_id") - .setDescription("ID of the shared list") + .setDescription("ID der geteilten Liste") .setRequired(true) ) .addUserOption(option => option .setName("user") - .setDescription("User to add to the list") + .setDescription("Nutzer, den du zur Liste hinzufügen willst") .setRequired(true) ) ) .addSubcommand(subcommand => subcommand .setName("view") - .setDescription("View a shared to-do list") + .setDescription("Zeigt eine geteilte To-do-Liste an") .addStringOption(option => option .setName("list_id") - .setDescription("ID of the shared list") + .setDescription("ID der geteilten Liste") .setRequired(true) ) ) .addSubcommand(subcommand => subcommand .setName("addtask") - .setDescription("Add a task to a shared to-do list") + .setDescription("Füge eine Aufgabe zu einer geteilten To-do-Liste hinzu") .addStringOption(option => option .setName("list_id") - .setDescription("ID of the shared list") + .setDescription("ID der geteilten Liste") .setRequired(true) ) .addStringOption(option => option .setName("task") - .setDescription("The task to add") + .setDescription("Die Aufgabe, die du hinzufügen willst") .setRequired(true) ) ) .addSubcommand(subcommand => subcommand .setName("remove") - .setDescription("Remove a task from a shared to-do list") + .setDescription("Entferne eine Aufgabe von einer geteilten To-do-Liste") .addStringOption(option => option .setName("list_id") - .setDescription("ID of the shared list") + .setDescription("ID der geteilten Liste") .setRequired(true) ) .addIntegerOption(option => option .setName("number") - .setDescription("The number of the task to remove") + .setDescription("Die Nummer der Aufgabe, die du entfernen willst") .setRequired(true) ) ) @@ -231,9 +231,9 @@ export default { 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.` + "Geteilte Liste erstellt", + `Geteilte Liste „${listName}“ mit der ID \`${listId}\` erstellt.\n` + + `Nutze \`/todo share add list_id:${listId} user:@username\`, um Mitglieder hinzuzufügen.` ) ] }); @@ -245,11 +245,11 @@ export default { const listData = await getOrCreateSharedList(listId); if (!listData) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Shared list not found.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Geteilte Liste nicht gefunden.' }); } if (listData.creatorId !== userId) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Only the list creator can add members.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Nur wer die Liste erstellt hat, kann Mitglieder hinzufügen.' }); } if (!listData.members.includes(memberToAdd.id)) { @@ -265,13 +265,13 @@ export default { return await InteractionHelper.safeEditReply(interaction, { embeds: [ - successEmbed('Member Added', - `Added ${memberToAdd.username} to the shared list "${listData.name}"` + successEmbed('Mitglied hinzugefügt', + `${memberToAdd.username} wurde zur geteilten Liste „${listData.name}“ hinzugefügt.` ) ] }); } else { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'User is already a member of this list.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Der Nutzer ist bereits Mitglied dieser Liste.' }); } } @@ -280,11 +280,11 @@ export default { const listData = await getOrCreateSharedList(listId); if (!listData) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Shared list not found.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Geteilte Liste nicht gefunden.' }); } if (!listData.members.includes(userId)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You don\'t have access to this list.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Du hast keinen Zugriff auf diese Liste.' }); } if (listData.tasks.length === 0) { @@ -300,25 +300,25 @@ export default { 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}\`)` + `👑 **Inhaber:** ${ownerName}\n` + + `👥 **Mitglieder:** ${memberList}\n\n` + + `*Diese Liste ist gerade leer. Nutze den Button „Aufgabe hinzufügen“, um Aufgaben hinzuzufügen!*`, + `Geteilte Liste (ID: \`${listId}\`)` ) ], components: [ new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`shared_todo_add_${listId}`) - .setLabel('Add Task') + .setLabel('Aufgabe hinzufügen') .setStyle(ButtonStyle.Primary), new ButtonBuilder() .setCustomId(`shared_todo_complete_${listId}`) - .setLabel('Complete Task') + .setLabel('Aufgabe erledigen') .setStyle(ButtonStyle.Success), new ButtonBuilder() .setCustomId(`shared_todo_remove_${listId}`) - .setLabel('Remove Task') + .setLabel('Aufgabe entfernen') .setStyle(ButtonStyle.Danger) ) ] @@ -329,7 +329,7 @@ export default { .map(task => `${task.completed ? '✅' : '📝'} #${task.id} ${task.text}` + `\`[${new Date(task.createdAt).toLocaleDateString()}]` + - (task.completed ? `• Completed by ${task.completedBy}` : '') + '`' + (task.completed ? `• Erledigt von ${task.completedBy}` : '') + '`' ) .join('\n'); @@ -342,27 +342,27 @@ export default { const ownerName = owner ? owner.user.username : `<@${listData.creatorId}>`; const fullListDisplay = `📋 **${listData.name}**\n\n` + - `👑 **Owner:** ${ownerName}\n` + - `👥 **Members:** ${memberList}\n\n` + - `**Tasks:**\n${taskList}`; + `👑 **Inhaber:** ${ownerName}\n` + + `👥 **Mitglieder:** ${memberList}\n\n` + + `**Aufgaben:**\n${taskList}`; return await InteractionHelper.safeEditReply(interaction, { embeds: [ - successEmbed(`Shared List (ID: \`${listId}\`)`, fullListDisplay) + successEmbed(`Geteilte Liste (ID: \`${listId}\`)`, fullListDisplay) ], components: [ new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`shared_todo_add_${listId}`) - .setLabel('Add Task') + .setLabel('Aufgabe hinzufügen') .setStyle(ButtonStyle.Primary), new ButtonBuilder() .setCustomId(`shared_todo_complete_${listId}`) - .setLabel('Complete Task') + .setLabel('Aufgabe erledigen') .setStyle(ButtonStyle.Success), new ButtonBuilder() .setCustomId(`shared_todo_remove_${listId}`) - .setLabel('Remove Task') + .setLabel('Aufgabe entfernen') .setStyle(ButtonStyle.Danger) ) ] @@ -376,11 +376,11 @@ export default { const listData = await getOrCreateSharedList(listId); if (!listData) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Shared list not found.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Geteilte Liste nicht gefunden.' }); } if (!listData.members.includes(userId)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You don\'t have access to this list.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Du hast keinen Zugriff auf diese Liste.' }); } const newTask: SharedTodoTask = { @@ -396,7 +396,7 @@ export default { return await InteractionHelper.safeEditReply(interaction, { embeds: [ - successEmbed('Task Added', `Added "${taskText}" to the shared list "${listData.name}"`) + successEmbed('Aufgabe hinzugefügt', `„${taskText}“ wurde zur geteilten Liste „${listData.name}“ hinzugefügt.`) ] }); } @@ -408,16 +408,16 @@ export default { const listData = await getOrCreateSharedList(listId); if (!listData) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Shared list not found.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Geteilte Liste nicht gefunden.' }); } if (!listData.members.includes(userId)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You don\'t have access to this list.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Du hast keinen Zugriff auf diese Liste.' }); } 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 replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Aufgabe nicht gefunden.' }); } const [removedTask] = listData.tasks.splice(taskIndex, 1); @@ -425,7 +425,7 @@ export default { return await InteractionHelper.safeEditReply(interaction, { embeds: [ - successEmbed('Task Removed', `Removed "${removedTask.text}" from the shared list "${listData.name}".`) + successEmbed('Aufgabe entfernt', `„${removedTask.text}“ wurde aus der geteilten Liste „${listData.name}“ entfernt.`) ] }); } @@ -460,8 +460,8 @@ export default { return await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( - "Task Added", - `Added "${taskText}" to your to-do list.` + "Aufgabe hinzugefügt", + `„${taskText}“ wurde zu deiner To-do-Liste hinzugefügt.` ), ], }); @@ -470,7 +470,7 @@ export default { case 'list': { if (userData.tasks.length === 0) { return await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed('Your to-do list is empty!', "Your To-Do List")], + embeds: [successEmbed('Deine To-do-Liste ist leer!', "Deine To-do-Liste")], }); } @@ -483,7 +483,7 @@ export default { return await InteractionHelper.safeEditReply(interaction, { embeds: [ - successEmbed('Your To-Do List', taskList) + successEmbed('Deine To-do-Liste', taskList) ], }); } @@ -493,11 +493,11 @@ export default { const task = userData.tasks.find(t => t.id === taskNumber); if (!task) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Task not found.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Aufgabe nicht gefunden.' }); } 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: `Aufgabe #${task.id} ist bereits erledigt.` }); } task.completed = true; @@ -505,7 +505,7 @@ export default { return await InteractionHelper.safeEditReply(interaction, { embeds: [ - successEmbed('Task Completed', `Marked "${task.text}" as complete!`) + successEmbed('Aufgabe erledigt', `„${task.text}“ ist als erledigt markiert!`) ], }); } @@ -515,7 +515,7 @@ export default { const taskIndex = userData.tasks.findIndex(t => t.id === taskNumber); if (taskIndex === -1) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Task not found.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Aufgabe nicht gefunden.' }); } const [removedTask] = userData.tasks.splice(taskIndex, 1); @@ -523,13 +523,13 @@ export default { return await InteractionHelper.safeEditReply(interaction, { embeds: [ - successEmbed('Task Removed', `Removed "${removedTask.text}" from your to-do list.`) + successEmbed('Aufgabe entfernt', `„${removedTask.text}“ wurde von deiner To-do-Liste entfernt.`) ], }); } default: - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Invalid subcommand.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Ungültiger Subcommand.' }); } }, } satisfies Command; \ No newline at end of file diff --git a/src/commands/Utility/wipedata.ts b/src/commands/Utility/wipedata.ts index 3a6d0cf756..792a830f6c 100644 --- a/src/commands/Utility/wipedata.ts +++ b/src/commands/Utility/wipedata.ts @@ -9,22 +9,22 @@ export default { slashOnly: true, data: new SlashCommandBuilder() .setName('wipedata') - .setDescription('Delete all your personal data from the bot (irreversible)'), + .setDescription('Lösche alle deine persönlichen Daten aus dem Bot (unwiderruflich)'), async execute(interaction, guildConfig, client) { 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?**`; + `⚠️ **DIESE AKTION KANN NICHT RÜCKGÄNGIG GEMACHT WERDEN!** ⚠️\n\n` + + `Damit werden **ALLE** deine Daten auf diesem Server dauerhaft gelöscht, darunter:\n` + + `• 💰 Kontostand (Geldbörse & Bank)\n` + + `• 📊 Level und XP\n` + + `• 🎒 Items im Inventar\n` + + `• 🛍️ Shop-Käufe\n` + + `• 🎂 Geburtstagsdaten\n` + + `• 🔢 Zählerdaten\n` + + `• 📋 Alle weiteren persönlichen Daten\n\n` + + `**Das lässt sich nicht rückgängig machen. Bist du dir ganz sicher?**`; - const embed = warningEmbed('Wipe All Data', warningMessage); + const embed = warningEmbed('Alle Daten löschen', warningMessage); const confirmButtons = getConfirmationButtons('wipedata'); diff --git a/src/commands/Verification/modules/autoVerify.ts b/src/commands/Verification/modules/autoVerify.ts index a74a8c0377..345b1ce1d3 100644 --- a/src/commands/Verification/modules/autoVerify.ts +++ b/src/commands/Verification/modules/autoVerify.ts @@ -20,32 +20,32 @@ const defaultAccountAgeDays = autoVerifyDefaults.defaultAccountAgeDays ?? 7; export default { data: new SlashCommandBuilder() .setName("autoverify") - .setDescription("Configure automatic verification settings") + .setDescription('Einstellungen für die automatische Verifizierung') .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .addSubcommand(subcommand => subcommand .setName("setup") - .setDescription("Set up automatic verification") + .setDescription('Die automatische Verifizierung einrichten') .addRoleOption(option => option .setName("role") - .setDescription("Role to assign to users who meet auto-verify criteria") + .setDescription('Rolle für Nutzer, die die AutoVerify-Kriterien erfüllen') .setRequired(true) ) .addStringOption(option => option .setName("criteria") - .setDescription("Criteria for automatic verification") + .setDescription('Kriterien für die automatische Verifizierung') .addChoices( - { name: "Account Age", value: "account_age" }, - { name: "No Criteria", value: "none" } + { name: 'Account-Alter', value: "account_age" }, + { name: 'Keine Kriterien', value: "none" } ) .setRequired(true) ) .addIntegerOption(option => option .setName("account_age_days") - .setDescription("Minimum account age in days (required for account age criteria)") + .setDescription('Mindestalter des Accounts in Tagen (nötig für das Kriterium Account-Alter)') .setMinValue(minAccountAgeDays) .setMaxValue(maxAccountAgeDays) .setRequired(false) @@ -54,7 +54,7 @@ export default { .addSubcommand(subcommand => subcommand .setName("dashboard") - .setDescription("Open the auto-verification dashboard for customization") + .setDescription('Das AutoVerify-Dashboard zum Anpassen öffnen') ), async execute(interaction, config, client) { @@ -71,7 +71,7 @@ export default { throw createError( `Unknown subcommand: ${subcommand}`, ErrorTypes.VALIDATION, - "Invalid subcommand selected.", + 'Ungültiger Subcommand ausgewählt.', { subcommand } ); } @@ -98,7 +98,7 @@ async function handleSetup(interaction: ChatInputCommandInteraction, guild: Guil throw createError( 'Auto-verify enable blocked by conflicting onboarding system', ErrorTypes.CONFIGURATION, - 'You cannot enable **AutoVerify** while the verification system or AutoRole is configured. Disable those first.', + 'Du kannst **AutoVerify** nicht aktivieren, solange die Verifizierung oder eine Auto-Rolle eingerichtet ist. Deaktiviere diese zuerst.', { guildId: guild.id, verificationEnabled, @@ -114,7 +114,7 @@ async function handleSetup(interaction: ChatInputCommandInteraction, guild: Guil throw createError( 'Bot member not found in guild cache', ErrorTypes.CONFIGURATION, - 'I could not verify my permissions in this server. Please try again in a moment.', + 'Ich konnte meine Berechtigungen auf diesem Server nicht prüfen. Bitte versuch es gleich noch einmal.', { guildId: guild.id } ); } @@ -123,7 +123,7 @@ async function handleSetup(interaction: ChatInputCommandInteraction, guild: Guil throw createError( 'Missing ManageRoles permission', ErrorTypes.PERMISSION, - "I need the 'Manage Roles' permission to assign auto-verify roles.", + 'Ich brauche die Berechtigung „Rollen verwalten“, um AutoVerify-Rollen zu vergeben.', { guildId: guild.id } ); } @@ -132,7 +132,7 @@ async function handleSetup(interaction: ChatInputCommandInteraction, guild: Guil throw createError( 'Invalid auto-verify role selected', ErrorTypes.VALIDATION, - 'Please choose a normal assignable role (not @everyone or an integration-managed role).', + 'Bitte wähle eine normale, vergebbare Rolle (nicht @everyone und keine von einer Integration verwaltete Rolle).', { guildId: guild.id, roleId: targetRole.id, managed: targetRole.managed } ); } @@ -141,7 +141,7 @@ async function handleSetup(interaction: ChatInputCommandInteraction, guild: Guil throw createError( 'Role hierarchy error for auto-verify setup', ErrorTypes.PERMISSION, - 'The selected auto-verify role must be below my highest role in the server role hierarchy.', + 'Die gewählte AutoVerify-Rolle muss in der Rollenhierarchie unter meiner höchsten Rolle liegen.', { guildId: guild.id, roleId: targetRole.id, rolePosition: targetRole.position, botRolePosition: botMember.roles.highest.position } ); } @@ -167,10 +167,10 @@ async function handleSetup(interaction: ChatInputCommandInteraction, guild: Guil let criteriaDescription = ""; switch (criteria) { case "account_age": - criteriaDescription = `\`${accountAgeDays} days\` old`; + criteriaDescription = `mindestens \`${accountAgeDays} Tage\` alt`; break; case "none": - criteriaDescription = "All users immediately"; + criteriaDescription = 'Alle Nutzer sofort'; break; } @@ -183,8 +183,8 @@ async function handleSetup(interaction: ChatInputCommandInteraction, guild: Guil await InteractionHelper.safeEditReply(interaction, { embeds: [successEmbed( - "Auto-Verification Configured", - `Automatic verification has been configured!\n\n**Role:** ${targetRole}\n**Criteria:** ${criteriaDescription}\n\nUsers who meet these criteria will receive this role when they join the server.` + 'AutoVerify eingerichtet', + `Die automatische Verifizierung ist eingerichtet!\n\n**Rolle:** ${targetRole}\n**Kriterien:** ${criteriaDescription}\n\nNutzer, die diese Kriterien erfüllen, erhalten die Rolle beim Beitritt zum Server.` )] }); diff --git a/src/commands/Verification/modules/autoVerifyDashboard.ts b/src/commands/Verification/modules/autoVerifyDashboard.ts index 5cffffd355..fae8f8daf3 100644 --- a/src/commands/Verification/modules/autoVerifyDashboard.ts +++ b/src/commands/Verification/modules/autoVerifyDashboard.ts @@ -34,53 +34,53 @@ function buildDashboardEmbed(cfg: GuildConfig, guild: Guild, conflictSummary = ' const autoVerify = cfg.verification?.autoVerify; const autoVerifyRole = autoVerify?.roleId ? guild.roles.cache.get(autoVerify.roleId) : null; - let criteriaDescription = "`Not configured`"; + let criteriaDescription = '`Nicht eingerichtet`'; if (autoVerify?.criteria) { switch (autoVerify.criteria) { case "account_age": - criteriaDescription = `\`Account Age\` - \`${autoVerify.accountAgeDays} days\``; + criteriaDescription = `\`Account-Alter\` – \`${autoVerify.accountAgeDays} Tage\``; break; case "none": - criteriaDescription = `\`No Criteria\``; + criteriaDescription = `\`Keine Kriterien\``; break; } } const embed = new EmbedBuilder() - .setTitle('🤖 Auto-Verification Dashboard') - .setDescription(`Manage auto-verification settings for **${guild.name}**.\nSelect an option below to modify a setting.`) + .setTitle('🤖 AutoVerify-Dashboard') + .setDescription(`Verwalte die AutoVerify-Einstellungen für **${guild.name}**.\nWähle unten eine Option, um eine Einstellung zu ändern.`) .setColor(getColor('info')) .addFields( - { name: 'System Status', value: autoVerify?.enabled ? 'Enabled' : 'Disabled', inline: true }, - { name: 'Target Role', value: autoVerifyRole ? autoVerifyRole.toString() : '`Not set`', inline: true }, - { name: 'Criteria', value: criteriaDescription, inline: true }, - { name: 'Account Age', value: autoVerify?.accountAgeDays ? `\`${autoVerify.accountAgeDays}\` days` : '`N/A`', inline: true }, + { name: 'Systemstatus', value: autoVerify?.enabled ? 'Aktiviert' : 'Deaktiviert', inline: true }, + { name: 'Zielrolle', value: autoVerifyRole ? autoVerifyRole.toString() : '`Nicht gesetzt`', inline: true }, + { name: 'Kriterien', value: criteriaDescription, inline: true }, + { name: 'Account-Alter', value: autoVerify?.accountAgeDays ? `\`${autoVerify.accountAgeDays}\` Tage` : '`–`', inline: true }, { name: '\u200B', value: '\u200B', inline: true }, { name: '\u200B', value: '\u200B', inline: true }, ); if (conflictSummary) { - embed.addFields({ name: 'Setup Conflicts', value: conflictSummary, inline: false }); + embed.addFields({ name: 'Konflikte bei der Einrichtung', value: conflictSummary, inline: false }); } return embed - .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) + .setFooter({ text: 'Das Dashboard schließt sich nach 10 Minuten Inaktivität' }) .setTimestamp(); } function buildSelectMenu(guildId: string) { return new StringSelectMenuBuilder() .setCustomId(`autoverify_cfg_${guildId}`) - .setPlaceholder('Select a setting to configure...') + .setPlaceholder('Wähle eine Einstellung...') .addOptions( new StringSelectMenuOptionBuilder() - .setLabel('Change Role') - .setDescription('Select the role to assign automatically') + .setLabel('Rolle ändern') + .setDescription('Wähle die Rolle, die automatisch vergeben wird') .setValue('role') .setEmoji('🏷️'), new StringSelectMenuOptionBuilder() - .setLabel('Edit Account Age Days') - .setDescription('Set minimum account age in days') + .setLabel('Account-Alter bearbeiten') + .setDescription('Lege das Mindestalter des Accounts in Tagen fest') .setValue('account_age') .setEmoji('📅'), ); @@ -91,13 +91,13 @@ function buildButtonRow(cfg: GuildConfig, guildId: string, disabled = false) { return new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`autoverify_cfg_criteria_${guildId}`) - .setLabel('Change Criteria') + .setLabel('Kriterien ändern') .setStyle(ButtonStyle.Primary) .setEmoji('🎯') .setDisabled(disabled), new ButtonBuilder() .setCustomId(`autoverify_cfg_toggle_${guildId}`) - .setLabel('Auto-Verification') + .setLabel('AutoVerify') .setStyle(autoVerifyOn ? ButtonStyle.Success : ButtonStyle.Danger) .setEmoji('🤖') .setDisabled(disabled), @@ -115,8 +115,8 @@ async function refreshDashboard(rootInteraction: ChatInputCommandInteraction, cf const autoRoleConfigured = Boolean(cfg.autoRole) || (Array.isArray(welcomeConfig.roleIds) && welcomeConfig.roleIds.length > 0); const conflicts = [ - verificationEnabled ? 'Verification system is enabled' : null, - autoRoleConfigured ? 'AutoRole is configured' : null + verificationEnabled ? 'Die Verifizierung ist aktiviert' : null, + autoRoleConfigured ? 'Eine Auto-Rolle ist eingerichtet' : null ].filter(Boolean); if (conflicts.length > 0) { @@ -153,20 +153,20 @@ export default { const autoRoleConfigured = Boolean(guildConfig.autoRole) || (Array.isArray(welcomeConfig.roleIds) && welcomeConfig.roleIds.length > 0); const blockingMessage: string[] = []; - if (verificationEnabled) blockingMessage.push('Verification system is enabled'); - if (autoRoleConfigured) blockingMessage.push('AutoRole is configured'); + if (verificationEnabled) blockingMessage.push('Die Verifizierung ist aktiviert'); + if (autoRoleConfigured) blockingMessage.push('Eine Auto-Rolle ist eingerichtet'); const blockingText = blockingMessage.length > 0 - ? `\n\n⚠️ **To enable AutoVerify, you must first disable:**\n${blockingMessage.map(msg =>`• ${msg}`).join('\n')}` + ? `\n\n⚠️ **Um AutoVerify zu aktivieren, musst du zuerst deaktivieren:**\n${blockingMessage.map(msg =>`• ${msg}`).join('\n')}` : ''; return await InteractionHelper.safeReply(interaction, { embeds: [ new EmbedBuilder() - .setTitle('🤖 Auto-Verification Dashboard') - .setDescription(`Auto-verification is not yet configured.${blockingText}\n\nUse \`/verification autoverify setup\` to configure it.`) + .setTitle('🤖 AutoVerify-Dashboard') + .setDescription(`AutoVerify ist noch nicht eingerichtet.${blockingText}\n\nNutze \`/verification autoverify setup\`, um es einzurichten.`) .setColor(getColor('warning')) - .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) + .setFooter({ text: 'Das Dashboard schließt sich nach 10 Minuten Inaktivität' }) .setTimestamp() ], flags: MessageFlags.Ephemeral @@ -184,8 +184,8 @@ export default { const autoRoleConfigured = Boolean(guildConfig.autoRole) || (Array.isArray(welcomeConfig.roleIds) && welcomeConfig.roleIds.length > 0); const conflicts = [ - verificationEnabled ? 'Verification system is enabled' : null, - autoRoleConfigured ? 'AutoRole is configured' : null + verificationEnabled ? 'Die Verifizierung ist aktiviert' : null, + autoRoleConfigured ? 'Eine Auto-Rolle ist eingerichtet' : null ].filter(Boolean); if (conflicts.length > 0) { @@ -231,8 +231,8 @@ export default { const errorMessage = error instanceof NexoBotError - ? error.userMessage || 'An error occurred while processing your selection.' - : 'An unexpected error occurred while updating the configuration.'; + ? error.userMessage || 'Beim Verarbeiten deiner Auswahl ist ein Fehler aufgetreten.' + : 'Beim Aktualisieren der Konfiguration ist ein unerwarteter Fehler aufgetreten.'; if (!selectInteraction.replied && !selectInteraction.deferred) { await selectInteraction.deferUpdate().catch(() => {}); @@ -265,8 +265,8 @@ export default { await btnInteraction.followUp({ embeds: [ successEmbed( - '✅ Status Updated', - `Auto-verification is now **${guildConfig.verification!.autoVerify!.enabled ? 'enabled' : 'disabled'}**.`, + '✅ Status aktualisiert', + `AutoVerify ist jetzt **${guildConfig.verification!.autoVerify!.enabled ? 'aktiviert' : 'deaktiviert'}**.`, ), ], flags: MessageFlags.Ephemeral, @@ -284,8 +284,8 @@ export default { btnCollector.stop(); try { const timeoutEmbed = new EmbedBuilder() - .setTitle('Dashboard Timed Out') - .setDescription('This dashboard has been closed due to inactivity. Please run the command again to continue.') + .setTitle('Dashboard abgelaufen') + .setDescription('Dieses Dashboard wurde wegen Inaktivität geschlossen. Führe den Command erneut aus, um weiterzumachen.') .setColor(getColor('error')); await InteractionHelper.safeEditReply(interaction, { embeds: [timeoutEmbed], @@ -303,7 +303,7 @@ export default { throw new NexoBotError( `Auto-verification dashboard failed: ${(error as Error).message}`, ErrorTypes.UNKNOWN, - 'Failed to open the auto-verification dashboard.', + 'Das AutoVerify-Dashboard konnte nicht geöffnet werden.', ); } }, @@ -316,21 +316,21 @@ async function handleCriteria(selectInteraction: ButtonInteraction, rootInteract } const criteriaEmbed = new EmbedBuilder() - .setTitle('Select Verification Criteria') - .setDescription('Choose the criteria for automatic verification') + .setTitle('Verifizierungskriterien wählen') + .setDescription('Wähle die Kriterien für die automatische Verifizierung') .setColor(getColor('info')); const criteriaMenu = new StringSelectMenuBuilder() .setCustomId('autoverify_criteria_select') - .setPlaceholder('Select criteria...') + .setPlaceholder('Wähle Kriterien...') .addOptions( new StringSelectMenuOptionBuilder() - .setLabel(`Account Age (older than ${defaultAccountAgeDays} days)`) - .setDescription('Users with older accounts will be auto-verified') + .setLabel(`Account-Alter (älter als ${defaultAccountAgeDays} Tage)`) + .setDescription('Nutzer mit älteren Accounts werden automatisch verifiziert') .setValue('account_age'), new StringSelectMenuOptionBuilder() - .setLabel('No Criteria (verify everyone)') - .setDescription('All users gain the role immediately') + .setLabel('Keine Kriterien (alle verifizieren)') + .setDescription('Alle Nutzer erhalten die Rolle sofort') .setValue('none'), ); @@ -365,15 +365,15 @@ async function handleCriteria(selectInteraction: ButtonInteraction, rootInteract let criteriaDisplay = ''; switch (newCriteria) { case 'account_age': - criteriaDisplay = `Account Age (${guildConfig.verification!.autoVerify!.accountAgeDays} days)`; + criteriaDisplay = `Account-Alter (${guildConfig.verification!.autoVerify!.accountAgeDays} Tage)`; break; case 'none': - criteriaDisplay = 'No Criteria'; + criteriaDisplay = 'Keine Kriterien'; break; } await criteriaInteraction.followUp({ - embeds: [successEmbed('Criteria Updated', `Auto-verification criteria changed to **${criteriaDisplay}**.`)], + embeds: [successEmbed('Kriterien aktualisiert', `AutoVerify-Kriterien geändert zu **${criteriaDisplay}**.`)], flags: MessageFlags.Ephemeral, }); @@ -384,7 +384,7 @@ async function handleCriteria(selectInteraction: ButtonInteraction, rootInteract if (reason === 'time' && collected.size === 0) { replyUserError(selectInteraction, { type: ErrorTypes.RATE_LIMIT, - message: 'No criteria selected. The setting was not changed.', + message: 'Es wurden keine Kriterien ausgewählt. Die Einstellung wurde nicht geändert.', }).catch(() => {}); } }); @@ -395,14 +395,14 @@ async function handleRole(selectInteraction: StringSelectMenuInteraction, rootIn const roleSelect = new RoleSelectMenuBuilder() .setCustomId('autoverify_role_select') - .setPlaceholder('Select a role...') + .setPlaceholder('Wähle eine Rolle...') .setMaxValues(1); await selectInteraction.followUp({ embeds: [ new EmbedBuilder() - .setTitle('Auto-Verification Role') - .setDescription('Select the role to assign to auto-verified users.') + .setTitle('AutoVerify-Rolle') + .setDescription('Wähle die Rolle, die automatisch verifizierte Nutzer erhalten.') .setColor(getColor('info')), ], components: [new ActionRowBuilder().addComponents(roleSelect)], @@ -424,7 +424,7 @@ async function handleRole(selectInteraction: StringSelectMenuInteraction, rootIn if (role.id === rootInteraction.guild!.id || role.managed) { await replyUserError(roleInteraction, { type: ErrorTypes.VALIDATION, - message: 'Please choose a normal assignable role (not @everyone or a bot-managed role).', + message: 'Bitte wähle eine normale, vergebbare Rolle (nicht @everyone und keine von einem Bot verwaltete Rolle).', }); return; } @@ -433,7 +433,7 @@ async function handleRole(selectInteraction: StringSelectMenuInteraction, rootIn if (role.position >= botMember!.roles.highest.position) { await replyUserError(roleInteraction, { type: ErrorTypes.PERMISSION, - message: 'The selected role must be below my highest role in the server role hierarchy.', + message: 'Die gewählte Rolle muss in der Rollenhierarchie unter meiner höchsten Rolle liegen.', }); return; } @@ -442,7 +442,7 @@ async function handleRole(selectInteraction: StringSelectMenuInteraction, rootIn await setGuildConfig(client, guildId, guildConfig); await roleInteraction.followUp({ - embeds: [successEmbed('Role Updated', `Auto-verification role set to ${role}.`)], + embeds: [successEmbed('Rolle aktualisiert', `AutoVerify-Rolle auf ${role} gesetzt.`)], flags: MessageFlags.Ephemeral, }); @@ -453,7 +453,7 @@ async function handleRole(selectInteraction: StringSelectMenuInteraction, rootIn if (reason === 'time' && collected.size === 0) { replyUserError(selectInteraction, { type: ErrorTypes.RATE_LIMIT, - message: 'No role was selected. The setting was not changed.', + message: 'Es wurde keine Rolle ausgewählt. Die Einstellung wurde nicht geändert.', }).catch(() => {}); } }); @@ -462,14 +462,14 @@ async function handleRole(selectInteraction: StringSelectMenuInteraction, rootIn async function handleAccountAge(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, guildConfig: GuildConfig, guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId('autoverify_account_age_modal') - .setTitle('Set Account Age Requirement') + .setTitle('Mindestalter des Accounts festlegen') .addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('age_input') - .setLabel('Minimum Account Age (days)') + .setLabel('Mindestalter des Accounts (Tage)') .setStyle(TextInputStyle.Short) - .setPlaceholder(`Between ${minAccountAgeDays} and ${maxAccountAgeDays}`) + .setPlaceholder(`Zwischen ${minAccountAgeDays} und ${maxAccountAgeDays}`) .setValue((guildConfig.verification!.autoVerify!.accountAgeDays || defaultAccountAgeDays).toString()) .setRequired(true), ), @@ -491,7 +491,7 @@ async function handleAccountAge(selectInteraction: StringSelectMenuInteraction, 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: `Bitte gib eine Zahl zwischen ${minAccountAgeDays} und ${maxAccountAgeDays} ein.` }); return; } @@ -499,7 +499,7 @@ async function handleAccountAge(selectInteraction: StringSelectMenuInteraction, await setGuildConfig(client, guildId, guildConfig); await submitted.reply({ - embeds: [successEmbed('Account Age Updated', `Minimum account age requirement set to **${days} days**.`)], + embeds: [successEmbed('Account-Alter aktualisiert', `Mindestalter des Accounts auf **${days} Tage** gesetzt.`)], flags: MessageFlags.Ephemeral, }); diff --git a/src/commands/Verification/modules/verification.ts b/src/commands/Verification/modules/verification.ts index 6e6178db93..bf5f32d224 100644 --- a/src/commands/Verification/modules/verification.ts +++ b/src/commands/Verification/modules/verification.ts @@ -14,35 +14,35 @@ import type { Command, NexoClient } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() .setName("verification") - .setDescription("Manage the server verification system") + .setDescription('Das Verifizierungssystem des Servers verwalten') .addSubcommand(subcommand => subcommand .setName("setup") - .setDescription("Set up the verification system") + .setDescription('Das Verifizierungssystem einrichten') .addChannelOption(option => option .setName("verification_channel") - .setDescription("Channel where verification messages will be sent") + .setDescription('Kanal, in dem die Verifizierungsnachricht gesendet wird') .addChannelTypes(ChannelType.GuildText) .setRequired(true) ) .addRoleOption(option => option .setName("verified_role") - .setDescription("Role to give to verified users") + .setDescription('Rolle, die verifizierte Nutzer erhalten') .setRequired(true) ) .addStringOption(option => option .setName("message") - .setDescription("Custom verification message") + .setDescription('Eigene Verifizierungsnachricht') .setMaxLength(2000) .setRequired(false) ) .addStringOption(option => option .setName("button_text") - .setDescription("Text for the verification button") + .setDescription('Text für den Verifizierungs-Button') .setMaxLength(80) .setRequired(false) ) @@ -50,18 +50,18 @@ export default { .addSubcommand(subcommand => subcommand .setName("remove") - .setDescription("Remove verification from a user") + .setDescription('Die Verifizierung eines Nutzers entfernen') .addUserOption(option => option .setName("user") - .setDescription("User to remove verification from") + .setDescription('Nutzer, dessen Verifizierung entfernt wird') .setRequired(true) ) ) .addSubcommand(subcommand => subcommand .setName("dashboard") - .setDescription("Open the verification system configuration dashboard") + .setDescription('Das Dashboard für die Verifizierung öffnen') ), async execute(interaction, config, client) { @@ -73,7 +73,7 @@ export default { throw createError( 'Missing ManageGuild permission for verification admin subcommand', ErrorTypes.PERMISSION, - 'You need the **Manage Server** permission to use this verification subcommand.', + 'Du brauchst die Berechtigung **Server verwalten**, um diesen Verifizierungs-Subcommand zu nutzen.', { subcommand, requiredPermission: 'ManageGuild', userId: interaction.user.id } ); } @@ -89,7 +89,7 @@ export default { throw createError( `Unknown subcommand: ${subcommand}`, ErrorTypes.VALIDATION, - "Please select a valid subcommand.", + 'Bitte wähle einen gültigen Subcommand.', { subcommand } ); } @@ -110,7 +110,7 @@ async function handleSetup(interaction: ChatInputCommandInteraction, guild: Guil throw createError( 'Bot member not found in guild cache', ErrorTypes.CONFIGURATION, - 'I could not verify my permissions in this server. Please try again in a moment.', + 'Ich konnte meine Berechtigungen auf diesem Server nicht prüfen. Bitte versuch es gleich noch einmal.', { guildId: guild.id } ); } @@ -128,7 +128,7 @@ async function handleSetup(interaction: ChatInputCommandInteraction, guild: Guil throw createError( `Missing channel permissions: ${missingChannelPerms.join(', ')}`, ErrorTypes.PERMISSION, - 'I need **View Channel**, **Send Messages**, and **Embed Links** in the verification channel.', + 'Ich brauche **Kanal ansehen**, **Nachrichten senden** und **Links einbetten** im Verifizierungskanal.', { missingPermissions: missingChannelPerms, channel: verificationChannel.id } ); } @@ -137,7 +137,7 @@ async function handleSetup(interaction: ChatInputCommandInteraction, guild: Guil throw createError( "Missing ManageRoles permission", ErrorTypes.PERMISSION, - "I need the 'Manage Roles' permission to give verified roles.", + 'Ich brauche die Berechtigung „Rollen verwalten“, um die Verifizierungsrolle zu vergeben.', { missingPermission: "ManageRoles" } ); } @@ -146,7 +146,7 @@ async function handleSetup(interaction: ChatInputCommandInteraction, guild: Guil throw createError( 'Invalid verified role selected', ErrorTypes.VALIDATION, - 'Please choose a normal assignable role (not @everyone or an integration-managed role).', + 'Bitte wähle eine normale, vergebbare Rolle (nicht @everyone und keine von einer Integration verwaltete Rolle).', { roleId: verifiedRole.id, managed: verifiedRole.managed } ); } @@ -156,7 +156,7 @@ async function handleSetup(interaction: ChatInputCommandInteraction, guild: Guil throw createError( "Role hierarchy error", ErrorTypes.PERMISSION, - "The verified role must be below my highest role in the server role hierarchy.", + 'Die Verifizierungsrolle muss in der Rollenhierarchie unter meiner höchsten Rolle liegen.', { rolePosition: verifiedRole.position, botRolePosition: botRole.position } ); } @@ -170,7 +170,7 @@ async function handleSetup(interaction: ChatInputCommandInteraction, guild: Guil throw createError( 'Verification setup blocked by conflicting onboarding system', ErrorTypes.CONFIGURATION, - 'You cannot enable the verification system while **AutoVerify** or **AutoRole** is configured. Disable those first.', + 'Du kannst das Verifizierungssystem nicht aktivieren, solange **AutoVerify** oder eine **Auto-Rolle** eingerichtet ist. Deaktiviere diese zuerst.', { guildId: guild.id, hasAutoVerifyEnabled, @@ -184,7 +184,7 @@ async function handleSetup(interaction: ChatInputCommandInteraction, guild: Guil await InteractionHelper.safeDefer(interaction); const verifyEmbed = createEmbed({ - title: "Server Verification", + title: 'Server-Verifizierung', description: message, color: getColor('success') }); @@ -215,11 +215,11 @@ async function handleSetup(interaction: ChatInputCommandInteraction, guild: Guil await InteractionHelper.safeEditReply(interaction, { embeds: [successEmbed( - 'Verification System Updated', + 'Verifizierungssystem aktualisiert', [ - `Channel: ${verificationChannel}`, - `Verified Role: ${verifiedRole}`, - `Button Text: ${buttonText}` + `Kanal: ${verificationChannel}`, + `Verifizierungsrolle: ${verifiedRole}`, + `Button-Text: ${buttonText}` ].join('\n') )] }); @@ -235,7 +235,7 @@ async function handleRemove(interaction: ChatInputCommandInteraction, guild: Gui if (result.status === 'not_verified') { return await InteractionHelper.safeReply(interaction, { - embeds: [infoEmbed('Not Verified', `${targetUser.tag} does not currently have the verified role.`)], + embeds: [infoEmbed('Nicht verifiziert', `${targetUser.tag} hat die Verifizierungsrolle derzeit nicht.`)], flags: MessageFlags.Ephemeral }); } @@ -247,6 +247,6 @@ async function handleRemove(interaction: ChatInputCommandInteraction, guild: Gui }); return await InteractionHelper.safeReply(interaction, { - embeds: [successEmbed('Verification Removed', `Verification removed from ${targetUser.tag}.`)] + embeds: [successEmbed('Verifizierung entfernt', `Die Verifizierung von ${targetUser.tag} wurde entfernt.`)] }); } \ No newline at end of file diff --git a/src/commands/Verification/modules/verification_dashboard.ts b/src/commands/Verification/modules/verification_dashboard.ts index 585fc2facb..c1f6630533 100644 --- a/src/commands/Verification/modules/verification_dashboard.ts +++ b/src/commands/Verification/modules/verification_dashboard.ts @@ -41,7 +41,7 @@ async function updateLivePanel(guild: Guild, cfg: VerificationConfig) { if (!msg) return; const verifyEmbed = new EmbedBuilder() - .setTitle('Server Verification') + .setTitle('Server-Verifizierung') .setDescription(cfg.message || botConfig.verification.defaultMessage) .setColor(getColor('success')); @@ -60,60 +60,60 @@ async function updateLivePanel(guild: Guild, cfg: VerificationConfig) { } function buildDashboardEmbed(cfg: VerificationConfig, guild: Guild, verifiedUserCount = 0, conflictSummary = '', panelStatus: PanelStatus | null = null) { - const channel = cfg.channelId ? `<#${cfg.channelId}>` : '`Not set`'; - const role = cfg.roleId ? `<@&${cfg.roleId}>` : '`Not set`'; + const channel = cfg.channelId ? `<#${cfg.channelId}>` : '`Nicht gesetzt`'; + const role = cfg.roleId ? `<@&${cfg.roleId}>` : '`Nicht gesetzt`'; const rawMsg = cfg.message || botConfig.verification.defaultMessage; const msgPreview = `\`${rawMsg.length > 60 ? rawMsg.substring(0, 60) + '…' : rawMsg}\``; const buttonText = cfg.buttonText || botConfig.verification.defaultButtonText; - const panelStatusValue = cfg.channelId ? formatPanelStatusField(panelStatus) : '`Not configured`'; + const panelStatusValue = cfg.channelId ? formatPanelStatusField(panelStatus) : '`Nicht eingerichtet`'; const embed = new EmbedBuilder() - .setTitle('✅ Verification System Dashboard') - .setDescription(`Manage verification settings for **${guild.name}**.\nSelect an option below to modify a setting.`) + .setTitle('✅ Verifizierungs-Dashboard') + .setDescription(`Verwalte die Verifizierungseinstellungen für **${guild.name}**.\nWähle unten eine Option, um eine Einstellung zu ändern.`) .setColor(getColor('info')) .addFields( - { name: 'Panel Status', value: panelStatusValue, inline: false }, - { name: 'Verification Channel', value: channel, inline: true }, - { name: 'Verified Role', value: role, inline: true }, - { name: 'System Status', value: cfg.enabled !== false ? 'Enabled' : 'Disabled', inline: true }, - { name: 'Button Text', value: `\`${buttonText}\``, inline: true }, - { name: 'Verified Users', value: `${verifiedUserCount} users`, inline: true }, + { name: 'Panel-Status', value: panelStatusValue, inline: false }, + { name: 'Verifizierungskanal', value: channel, inline: true }, + { name: 'Verifizierungsrolle', value: role, inline: true }, + { name: 'Systemstatus', value: cfg.enabled !== false ? 'Aktiviert' : 'Deaktiviert', inline: true }, + { name: 'Button-Text', value: `\`${buttonText}\``, inline: true }, + { name: 'Verifizierte Nutzer', value: `${verifiedUserCount} Nutzer`, inline: true }, { name: '\u200B', value: '\u200B', inline: true }, - { name: 'Verification Message', value: msgPreview, inline: false }, + { name: 'Verifizierungsnachricht', value: msgPreview, inline: false }, ); if (conflictSummary) { - embed.addFields({ name: 'Setup Conflicts', value: conflictSummary, inline: false }); + embed.addFields({ name: 'Konflikte bei der Einrichtung', value: conflictSummary, inline: false }); } return embed - .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) + .setFooter({ text: 'Das Dashboard schließt sich nach 10 Minuten Inaktivität' }) .setTimestamp(); } function buildSelectMenu(guildId: string) { return new StringSelectMenuBuilder() .setCustomId(`verif_cfg_${guildId}`) - .setPlaceholder('Select a setting to configure...') + .setPlaceholder('Wähle eine Einstellung...') .addOptions( new StringSelectMenuOptionBuilder() - .setLabel('Change Verification Channel') - .setDescription('Set the channel where the verification panel is posted') + .setLabel('Verifizierungskanal ändern') + .setDescription('Lege fest, in welchem Kanal das Verifizierungs-Panel gepostet wird') .setValue('channel') .setEmoji('📢'), new StringSelectMenuOptionBuilder() - .setLabel('Change Verified Role') - .setDescription('Set the role assigned when a user verifies') + .setLabel('Verifizierungsrolle ändern') + .setDescription('Lege die Rolle fest, die ein Nutzer bei der Verifizierung erhält') .setValue('role') .setEmoji('🏷️'), new StringSelectMenuOptionBuilder() - .setLabel('Edit Verification Message') - .setDescription('Customise the message shown on the verification panel embed') + .setLabel('Verifizierungsnachricht bearbeiten') + .setDescription('Passe die Nachricht im Embed des Verifizierungs-Panels an') .setValue('message') .setEmoji('💬'), new StringSelectMenuOptionBuilder() - .setLabel('Edit Button Text') - .setDescription('Change the label on the verify button') + .setLabel('Button-Text bearbeiten') + .setDescription('Ändere die Beschriftung des Verifizierungs-Buttons') .setValue('button_text') .setEmoji('🔘'), ); @@ -130,7 +130,7 @@ function buildButtonRow(cfg: VerificationConfig, guildId: string, disabled = fal buttons.push( new ButtonBuilder() .setCustomId(`verif_cfg_repost_${guildId}`) - .setLabel('Repost Panel') + .setLabel('Panel neu posten') .setStyle(ButtonStyle.Primary) .setEmoji('📌') .setDisabled(disabled), @@ -140,7 +140,7 @@ function buildButtonRow(cfg: VerificationConfig, guildId: string, disabled = fal buttons.push( new ButtonBuilder() .setCustomId(`verif_cfg_toggle_${guildId}`) - .setLabel('Verification') + .setLabel('Verifizierung') .setStyle(systemOn ? ButtonStyle.Success : ButtonStyle.Danger) .setEmoji('🔒') .setDisabled(disabled), @@ -155,12 +155,12 @@ async function repostVerificationPanel(guild: Guild, cfg: VerificationConfig) { throw new NexoBotError( 'Panel channel missing', ErrorTypes.CONFIGURATION, - 'The configured verification channel no longer exists. Set a new channel from the dashboard.', + 'Der eingerichtete Verifizierungskanal existiert nicht mehr. Lege im Dashboard einen neuen Kanal fest.', ); } const verifyEmbed = new EmbedBuilder() - .setTitle('Server Verification') + .setTitle('Server-Verifizierung') .setDescription(cfg.message || botConfig.verification.defaultMessage) .setColor(getColor('success')); @@ -205,8 +205,8 @@ async function refreshDashboard(rootInteraction: ChatInputCommandInteraction, cf const autoRoleConfigured = Boolean(guildConfig.autoRole) || (Array.isArray(welcomeConfig.roleIds) && welcomeConfig.roleIds.length > 0); const conflicts = [ - autoVerifyEnabled ? 'AutoVerify is enabled' : null, - autoRoleConfigured ? 'AutoRole is configured' : null + autoVerifyEnabled ? 'AutoVerify ist aktiviert' : null, + autoRoleConfigured ? 'Eine Auto-Rolle ist eingerichtet' : null ].filter(Boolean); if (conflicts.length > 0) { @@ -241,7 +241,7 @@ export default { throw new NexoBotError( 'Verification not configured', ErrorTypes.CONFIGURATION, - 'The verification system has not been set up yet. Run `/verification setup` first.', + 'Das Verifizierungssystem ist noch nicht eingerichtet. Führe zuerst `/verification setup` aus.', ); } @@ -271,8 +271,8 @@ export default { const autoRoleConfigured = Boolean(guildConfig.autoRole) || (Array.isArray(welcomeConfig.roleIds) && welcomeConfig.roleIds.length > 0); const conflicts = [ - autoVerifyEnabled ? 'AutoVerify is enabled' : null, - autoRoleConfigured ? 'AutoRole is configured' : null + autoVerifyEnabled ? 'AutoVerify ist aktiviert' : null, + autoRoleConfigured ? 'Eine Auto-Rolle ist eingerichtet' : null ].filter(Boolean); if (conflicts.length > 0) { @@ -319,7 +319,7 @@ export default { latestConfig.verification = cfg; await setGuildConfig(client, guildId, latestConfig); await btnInteraction.followUp({ - embeds: [successEmbed('Panel Reposted', `Verification panel restored in ${newMsg.channel}.`)], + embeds: [successEmbed('Panel neu gepostet', `Das Verifizierungs-Panel wurde in ${newMsg.channel} wiederhergestellt.`)], flags: MessageFlags.Ephemeral, }); await refreshDashboard(interaction, cfg, guildId, client); @@ -334,7 +334,7 @@ export default { if (!wasEnabled && autoVerifyEnabled) { await replyUserError(btnInteraction, { type: ErrorTypes.CONFIGURATION, - message: 'AutoVerify is currently enabled. Please disable AutoVerify first before enabling the manual Verification system.\n\nRun `/verification autoverify dashboard` to access the AutoVerify dashboard.', + message: 'AutoVerify ist derzeit aktiviert. Deaktiviere zuerst AutoVerify, bevor du die manuelle Verifizierung aktivierst.\n\nMit `/verification autoverify dashboard` öffnest du das AutoVerify-Dashboard.', }); return; } @@ -365,8 +365,8 @@ export default { await btnInteraction.followUp({ embeds: [ successEmbed( - '✅ System Updated', - `The verification system is now **${cfg.enabled ? 'enabled' : 'disabled'}**.`, + '✅ System aktualisiert', + `Das Verifizierungssystem ist jetzt **${cfg.enabled ? 'aktiviert' : 'deaktiviert'}**.`, ), ], flags: MessageFlags.Ephemeral, @@ -378,8 +378,8 @@ export default { await InteractionHelper.safeEditReply(rootInteraction, { embeds: [ new EmbedBuilder() - .setTitle('Dashboard Timed Out') - .setDescription('This dashboard has been closed due to inactivity. Please run the command again to continue.') + .setTitle('Dashboard abgelaufen') + .setDescription('Dieses Dashboard wurde wegen Inaktivität geschlossen. Führe den Command erneut aus, um weiterzumachen.') .setColor(getColor('error')), ], components: [], @@ -393,7 +393,7 @@ export default { throw new NexoBotError( `Verification dashboard failed: ${(error as Error).message}`, ErrorTypes.UNKNOWN, - 'Failed to open the verification dashboard.', + 'Das Verifizierungs-Dashboard konnte nicht geöffnet werden.', ); } }, @@ -404,16 +404,16 @@ async function handleChannel(selectInteraction: StringSelectMenuInteraction, roo const channelSelect = new ChannelSelectMenuBuilder() .setCustomId('verif_cfg_channel') - .setPlaceholder('Select a text channel...') + .setPlaceholder('Wähle einen Textkanal...') .addChannelTypes(ChannelType.GuildText) .setMaxValues(1); await selectInteraction.followUp({ embeds: [ new EmbedBuilder() - .setTitle('Change Verification Channel') + .setTitle('Verifizierungskanal ändern') .setDescription( - `**Current:** ${cfg.channelId ?`<#${cfg.channelId}>`: '`Not set`'}\n\nSelect the channel where the verification panel will be posted.\n\n> ⚠️ The existing panel will be deleted and re-posted in the new channel.`, + `**Aktuell:** ${cfg.channelId ?`<#${cfg.channelId}>`: '`Nicht gesetzt`'}\n\nWähle den Kanal, in dem das Verifizierungs-Panel gepostet wird.\n\n> ⚠️ Das bestehende Panel wird gelöscht und im neuen Kanal neu gepostet.`, ) .setColor(getColor('info')), ], @@ -436,7 +436,7 @@ async function handleChannel(selectInteraction: StringSelectMenuInteraction, roo if (!botHasPermission(newChannel, ['ViewChannel', 'SendMessages', 'EmbedLinks'])) { await replyUserError(chanInteraction, { type: ErrorTypes.PERMISSION, - message: `I need **View Channel**, **Send Messages**, and **Embed Links** permissions in ${newChannel}.`, + message: `Ich brauche die Berechtigungen **Kanal ansehen**, **Nachrichten senden** und **Links einbetten** in ${newChannel}.`, }); return; } @@ -456,7 +456,7 @@ async function handleChannel(selectInteraction: StringSelectMenuInteraction, roo if (cfg.enabled !== false) { try { const verifyEmbed = new EmbedBuilder() - .setTitle('Server Verification') + .setTitle('Server-Verifizierung') .setDescription(cfg.message || botConfig.verification.defaultMessage) .setColor(getColor('success')); @@ -481,7 +481,7 @@ async function handleChannel(selectInteraction: StringSelectMenuInteraction, roo await setGuildConfig(client, guildId, latestConfig); await chanInteraction.followUp({ - embeds: [successEmbed('Channel Updated', `Verification panel moved to ${newChannel}.`)], + embeds: [successEmbed('Kanal aktualisiert', `Das Verifizierungs-Panel wurde nach ${newChannel} verschoben.`)], flags: MessageFlags.Ephemeral, }); @@ -492,7 +492,7 @@ async function handleChannel(selectInteraction: StringSelectMenuInteraction, roo if (reason === 'time' && collected.size === 0) { replyUserError(selectInteraction, { type: ErrorTypes.RATE_LIMIT, - message: 'No channel was selected. The setting was not changed.', + message: 'Es wurde kein Kanal ausgewählt. Die Einstellung wurde nicht geändert.', }).catch(() => {}); } }); @@ -503,15 +503,15 @@ async function handleRole(selectInteraction: StringSelectMenuInteraction, rootIn const roleSelect = new RoleSelectMenuBuilder() .setCustomId('verif_cfg_role') - .setPlaceholder('Select a role...') + .setPlaceholder('Wähle eine Rolle...') .setMaxValues(1); await selectInteraction.followUp({ embeds: [ new EmbedBuilder() - .setTitle('Change Verified Role') + .setTitle('Verifizierungsrolle ändern') .setDescription( - `**Current:** ${cfg.roleId ?`<@&${cfg.roleId}>`: '`Not set`'}\n\nSelect the role to assign when a user verifies.`, + `**Aktuell:** ${cfg.roleId ?`<@&${cfg.roleId}>`: '`Nicht gesetzt`'}\n\nWähle die Rolle, die ein Nutzer bei der Verifizierung erhält.`, ) .setColor(getColor('info')), ], @@ -536,7 +536,7 @@ async function handleRole(selectInteraction: StringSelectMenuInteraction, rootIn if (role.id === guild.id || role.managed) { await replyUserError(roleInteraction, { type: ErrorTypes.VALIDATION, - message: 'Please choose a normal assignable role (not @everyone or a bot-managed role).', + message: 'Bitte wähle eine normale, vergebbare Rolle (nicht @everyone und keine von einem Bot verwaltete Rolle).', }); return; } @@ -544,7 +544,7 @@ async function handleRole(selectInteraction: StringSelectMenuInteraction, rootIn if (role.position >= botMember.roles.highest.position) { await replyUserError(roleInteraction, { type: ErrorTypes.PERMISSION, - message: 'The verified role must be below my highest role in the server role hierarchy.', + message: 'Die Verifizierungsrolle muss in der Rollenhierarchie unter meiner höchsten Rolle liegen.', }); return; } @@ -555,7 +555,7 @@ async function handleRole(selectInteraction: StringSelectMenuInteraction, rootIn await setGuildConfig(client, guildId, latestConfig); await roleInteraction.followUp({ - embeds: [successEmbed('Role Updated', `Verified role set to ${role}.`)], + embeds: [successEmbed('Rolle aktualisiert', `Verifizierungsrolle auf ${role} gesetzt.`)], flags: MessageFlags.Ephemeral, }); @@ -566,7 +566,7 @@ async function handleRole(selectInteraction: StringSelectMenuInteraction, rootIn if (reason === 'time' && collected.size === 0) { replyUserError(selectInteraction, { type: ErrorTypes.RATE_LIMIT, - message: 'No role was selected. The setting was not changed.', + message: 'Es wurde keine Rolle ausgewählt. Die Einstellung wurde nicht geändert.', }).catch(() => {}); } }); @@ -576,12 +576,12 @@ async function handleMessage(selectInteraction: StringSelectMenuInteraction, roo try { const modal = new ModalBuilder() .setCustomId('verif_cfg_message') - .setTitle('Edit Verification Message') + .setTitle('Verifizierungsnachricht bearbeiten') .addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('message_input') - .setLabel('Message shown on the verification panel embed') + .setLabel('Nachricht im Verifizierungs-Panel') .setStyle(TextInputStyle.Paragraph) .setValue(cfg.message || botConfig.verification.defaultMessage) .setMaxLength(2000) @@ -611,7 +611,7 @@ async function handleMessage(selectInteraction: StringSelectMenuInteraction, roo await updateLivePanel(rootInteraction.guild!, cfg); await submitted.reply({ - embeds: [successEmbed('Message Updated', 'The verification panel has been updated with the new message.')], + embeds: [successEmbed('Nachricht aktualisiert', 'Das Verifizierungs-Panel zeigt jetzt die neue Nachricht.')], flags: MessageFlags.Ephemeral, }); @@ -626,12 +626,12 @@ async function handleButtonText(selectInteraction: StringSelectMenuInteraction, try { const modal = new ModalBuilder() .setCustomId('verif_cfg_button_text') - .setTitle('Edit Button Text') + .setTitle('Button-Text bearbeiten') .addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('button_text_input') - .setLabel('Button label (max 80 characters)') + .setLabel('Button-Beschriftung (max. 80 Zeichen)') .setStyle(TextInputStyle.Short) .setValue(cfg.buttonText || botConfig.verification.defaultButtonText) .setMaxLength(80) @@ -661,7 +661,7 @@ async function handleButtonText(selectInteraction: StringSelectMenuInteraction, await updateLivePanel(rootInteraction.guild!, cfg); await submitted.reply({ - embeds: [successEmbed('Button Text Updated', `The verify button now reads **${cfg.buttonText}**.`)], + embeds: [successEmbed('Button-Text aktualisiert', `Der Verifizierungs-Button zeigt jetzt **${cfg.buttonText}**.`)], flags: MessageFlags.Ephemeral, }); diff --git a/src/commands/Verification/verification.ts b/src/commands/Verification/verification.ts index ff5fb151c8..215e0668f9 100644 --- a/src/commands/Verification/verification.ts +++ b/src/commands/Verification/verification.ts @@ -4,7 +4,7 @@ import autoverifyCommand from './modules/autoVerify.js'; export default createRouterCommand({ name: 'verification', - description: 'Manage member verification', + description: 'Verifizierung von Mitgliedern verwalten', category: 'Verification', routes: [ { legacyName: 'verification', module: verificationCommand, spread: true }, diff --git a/src/commands/Verification/verify.ts b/src/commands/Verification/verify.ts index abf3bf787e..645854f14d 100644 --- a/src/commands/Verification/verify.ts +++ b/src/commands/Verification/verify.ts @@ -8,7 +8,7 @@ import type { Command } from '../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('verify') - .setDescription('Verify yourself and gain access to the server'), + .setDescription('Verifiziere dich und erhalte Zugang zum Server'), async execute(interaction, config, client) { const guild = interaction.guild!; @@ -20,15 +20,15 @@ export default { if (result.status === 'already_verified') { return await InteractionHelper.safeReply(interaction, { - embeds: [infoEmbed('Already Verified', "You are already verified.")], + embeds: [infoEmbed('Bereits verifiziert', 'Du bist bereits verifiziert.')], flags: MessageFlags.Ephemeral }); } await InteractionHelper.safeReply(interaction, { embeds: [successEmbed( - "Verification Complete", - `You have been verified and given the **${result.roleName}** role! Welcome to the server! 🎉` + 'Verifizierung abgeschlossen', + `Du wurdest verifiziert und hast die Rolle **${result.roleName}** erhalten! Willkommen auf dem Server! 🎉` )], flags: MessageFlags.Ephemeral }); diff --git a/src/commands/Welcome/modules/autorole.ts b/src/commands/Welcome/modules/autorole.ts index b3365ede39..52c42b59e8 100644 --- a/src/commands/Welcome/modules/autorole.ts +++ b/src/commands/Welcome/modules/autorole.ts @@ -18,28 +18,28 @@ function createAutoroleInfoEmbed(description: string) { export default { data: new SlashCommandBuilder() .setName('autorole') - .setDescription('Manage roles that are automatically assigned to new members') + .setDescription('Rollen verwalten, die neuen Mitgliedern automatisch vergeben werden') .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .addSubcommand(subcommand => subcommand .setName('add') - .setDescription('Add a role to be automatically assigned to new members') + .setDescription('Eine Rolle hinzufügen, die neue Mitglieder automatisch erhalten') .addRoleOption(option => option.setName('role') - .setDescription('The role to add') + .setDescription('Die Rolle, die hinzugefügt wird') .setRequired(true))) .addSubcommand(subcommand => subcommand .setName('remove') - .setDescription('Remove a role from auto-assignment') + .setDescription('Eine Rolle aus der automatischen Vergabe entfernen') .addRoleOption(option => option.setName('role') - .setDescription('The role to remove') + .setDescription('Die Rolle, die entfernt wird') .setRequired(true))) .addSubcommand(subcommand => subcommand .setName('list') - .setDescription('List all auto-assigned roles')), + .setDescription('Alle automatisch vergebenen Rollen anzeigen')), async execute(interaction) { const deferSuccess = await InteractionHelper.safeDefer(interaction); @@ -53,7 +53,7 @@ export default { } if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the **Manage Server** permission to use `/welcome autorole`.' }); + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Du brauchst die Berechtigung **Server verwalten**, um `/welcome autorole` zu nutzen.' }); } const { options, guild, client } = interaction as ChatInputCommandInteraction<'cached'>; @@ -67,12 +67,12 @@ export default { const autoVerifyEnabled = Boolean(guildConfig.verification?.autoVerify?.enabled); if (verificationEnabled || autoVerifyEnabled) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You cannot add AutoRole while the verification system or AutoVerify is enabled. Disable those first.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Du kannst keine Auto-Rolle hinzufügen, solange die Verifizierung oder AutoVerify aktiviert ist. Deaktiviere diese zuerst.' }); } if (role.position >= guild.members.me!.roles.highest.position) { logger.warn(`[Autorole] User ${interaction.user.tag} tried to add role ${role.name} (${role.id}) higher than bot's highest role in ${guild.name}`); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'I can\'t assign roles that are higher than my highest role.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Ich kann keine Rollen vergeben, die höher sind als meine höchste Rolle.' }); } try { @@ -82,7 +82,7 @@ export default { if (currentRoleId === role.id) { logger.info(`[Autorole] User ${interaction.user.tag} tried to add duplicate role ${role.name} (${role.id}) in ${guild.name}`); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `The role ${role} is already set to be auto-assigned.` }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `Die Rolle ${role} wird bereits automatisch vergeben.` }); } await updateWelcomeConfig(client, guild.id, { @@ -93,14 +93,14 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [createAutoroleInfoEmbed( currentRoleId - ? `✅ Auto-role updated to ${role}. Only one auto-role is allowed.` - : `✅ Auto-role set to ${role}.` + ? `✅ Auto-Rolle auf ${role} geändert. Es ist nur eine Auto-Rolle erlaubt.` + : `✅ Auto-Rolle auf ${role} gesetzt.` )], flags: MessageFlags.Ephemeral }); } catch (error) { logger.error(`[Autorole] Failed to add role for guild ${guild.id}:`, error); - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while adding the role. Please try again.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Hinzufügen der Rolle ist ein Fehler aufgetreten. Bitte versuch es erneut.' }); } } @@ -113,7 +113,7 @@ export default { if (!existingRoles.includes(role.id)) { logger.info(`[Autorole] User ${interaction.user.tag} tried to remove non-existent role ${role.name} (${role.id}) in ${guild.name}`); - return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: `The role ${role} is not set to be auto-assigned.` }); + return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: `Die Rolle ${role} wird nicht automatisch vergeben.` }); } const updatedRoles = existingRoles.filter(id => id !== role.id); @@ -124,12 +124,12 @@ export default { logger.info(`[Autorole] Removed role ${role.name} (${role.id}) from auto-assign in ${guild.name} by ${interaction.user.tag}`); await InteractionHelper.safeEditReply(interaction, { - embeds: [createAutoroleInfoEmbed(`✅ Removed ${role} from auto-assigned roles.`)], + embeds: [createAutoroleInfoEmbed(`✅ ${role} wurde aus den automatisch vergebenen Rollen entfernt.`)], flags: MessageFlags.Ephemeral }); } catch (error) { logger.error(`[Autorole] Failed to remove role for guild ${guild.id}:`, error); - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while removing the role. Please try again.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Entfernen der Rolle ist ein Fehler aufgetreten. Bitte versuch es erneut.' }); } } @@ -139,8 +139,8 @@ export default { const verificationEnabled = Boolean(guildConfig.verification?.enabled); const autoVerifyEnabled = Boolean(guildConfig.verification?.autoVerify?.enabled); const conflictSummary = [ - verificationEnabled ? 'Verification system is enabled' : null, - autoVerifyEnabled ? 'AutoVerify is enabled' : null + verificationEnabled ? 'Die Verifizierung ist aktiviert' : null, + autoVerifyEnabled ? 'AutoVerify ist aktiviert' : null ].filter(Boolean).join('\n'); const config = await getWelcomeConfig(client, guild.id); @@ -156,7 +156,7 @@ export default { if (singleRoleIds.length === 0) { return InteractionHelper.safeEditReply(interaction, { - embeds: [createAutoroleInfoEmbed(`ℹ️ No role is set to be auto-assigned.${conflictSummary ?`\n\n⚠️ Setup blockers:\n${conflictSummary}`: ''}`)], + embeds: [createAutoroleInfoEmbed(`ℹ️ Es wird keine Rolle automatisch vergeben.${conflictSummary ?`\n\n⚠️ Hindernisse für die Einrichtung:\n${conflictSummary}`: ''}`)], flags: MessageFlags.Ephemeral }); } @@ -184,16 +184,16 @@ export default { if (validRoles.length === 0) { return InteractionHelper.safeEditReply(interaction, { - embeds: [createAutoroleInfoEmbed(`ℹ️ No valid auto-role found. Any invalid role has been removed.${conflictSummary ?`\n\n⚠️ Setup blockers:\n${conflictSummary}`: ''}`)], + embeds: [createAutoroleInfoEmbed(`ℹ️ Keine gültige Auto-Rolle gefunden. Ungültige Rollen wurden entfernt.${conflictSummary ?`\n\n⚠️ Hindernisse für die Einrichtung:\n${conflictSummary}`: ''}`)], flags: MessageFlags.Ephemeral }); } const embed = new EmbedBuilder() .setColor(getColor('info')) - .setTitle('Auto-Assigned Role') - .setDescription(`${validRoles[0]}${conflictSummary ?`\n\n⚠️ Setup blockers:\n${conflictSummary}`: ''}`) - .setFooter({ text: 'Only one auto-role can be configured.' }); + .setTitle('Automatisch vergebene Rolle') + .setDescription(`${validRoles[0]}${conflictSummary ?`\n\n⚠️ Hindernisse für die Einrichtung:\n${conflictSummary}`: ''}`) + .setFooter({ text: 'Es kann nur eine Auto-Rolle eingerichtet werden.' }); await InteractionHelper.safeEditReply(interaction, { embeds: [embed], @@ -202,7 +202,7 @@ export default { } catch (error) { logger.error(`[Autorole] Failed to list roles for guild ${guild.id}:`, error); - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while listing auto-assigned roles. Please try again.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Anzeigen der automatisch vergebenen Rollen ist ein Fehler aufgetreten. Bitte versuch es erneut.' }); } } }, diff --git a/src/commands/Welcome/modules/goodbye.ts b/src/commands/Welcome/modules/goodbye.ts index 25903fd2ad..7af17d6096 100644 --- a/src/commands/Welcome/modules/goodbye.ts +++ b/src/commands/Welcome/modules/goodbye.ts @@ -12,28 +12,28 @@ import type { WelcomeEmbedConfig } from '../../../utils/database.js'; export default { data: new SlashCommandBuilder() .setName('goodbye') - .setDescription('Configure the goodbye message system') + .setDescription('Das Abschiedssystem konfigurieren') .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .addSubcommand(subcommand => subcommand .setName('setup') - .setDescription('Set up the goodbye message') + .setDescription('Die Abschiedsnachricht einrichten') .addChannelOption(option => option.setName('channel') - .setDescription('The channel to send goodbye messages to') + .setDescription('Der Kanal für Abschiedsnachrichten') .addChannelTypes(ChannelType.GuildText) .setRequired(true)) .addStringOption(option => option.setName('message') - .setDescription('Goodbye message. Variables: {user}, {username}, {server}, {memberCount}') + .setDescription('Abschiedsnachricht. Variablen: {user}, {username}, {server}, {memberCount}') .setRequired(true)) .addStringOption(option => option.setName('image') - .setDescription('URL of the image to include in the goodbye message') + .setDescription('URL des Bildes für die Abschiedsnachricht') .setRequired(false)) .addBooleanOption(option => option.setName('ping') - .setDescription('Whether to ping the user in the goodbye message') + .setDescription('Ob der Nutzer in der Abschiedsnachricht gepingt wird') .setRequired(false))), async execute(interaction) { @@ -50,7 +50,7 @@ export default { const { options, guild, client } = interaction as ChatInputCommandInteraction<'cached'>; if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the **Manage Server** permission to use `/welcome goodbye`.' }); + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Du brauchst die Berechtigung **Server verwalten**, um `/welcome goodbye` zu nutzen.' }); } const subcommand = options.getSubcommand(); @@ -64,12 +64,12 @@ 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 **/welcome greet dashboard** to customize channel, message, ping, or image.` }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `Abschied ist bereits für <#${existingConfig.goodbyeChannelId}> eingerichtet. Nutze **/welcome greet dashboard**, um Kanal, Nachricht, Ping oder Bild anzupassen.` }); } if (!message || message.trim().length === 0) { logger.warn(`[Goodbye] Empty message provided by ${interaction.user.tag} in ${guild.name}`); - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Goodbye message cannot be empty' }); + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Die Abschiedsnachricht darf nicht leer sein.' }); } if (image) { @@ -77,7 +77,7 @@ export default { new URL(image); } catch (e) { logger.warn(`[Goodbye] Invalid image URL provided by ${interaction.user.tag}: ${image}`); - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please provide a valid image URL (must start with http:// or https://' }); + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Bitte gib eine gültige Bild-URL an (muss mit http:// oder https:// beginnen).' }); } } @@ -89,10 +89,10 @@ export default { goodbyePing: ping, // Stored color is numeric (getColor); WelcomeEmbedConfig.color is typed string-only. leaveEmbed: { - title: "Goodbye {user.tag}", + title: "Tschüss {user.tag}", description: message, color: getColor('error'), - footer: `Goodbye from ${guild.name}!`, + footer: `Tschüss von ${guild.name}!`, ...(image && { image: { url: image } }) } as Record as WelcomeEmbedConfig }); @@ -106,14 +106,14 @@ export default { const embed = new EmbedBuilder() .setColor(getColor('success')) - .setTitle('Goodbye System Configured') - .setDescription(`Goodbye messages will now be sent to ${channel}`) + .setTitle('Abschiedssystem eingerichtet') + .setDescription(`Abschiedsnachrichten werden jetzt in ${channel} gesendet.`) .addFields( - { name: 'Message Preview', value: truncateForEmbedField(previewMessage) }, - { name: 'Ping User', value: ping ? 'Yes' : 'No' }, - { name: 'Status', value: 'Enabled' } + { name: 'Nachrichtenvorschau', value: truncateForEmbedField(previewMessage) }, + { name: 'Nutzer pingen', value: ping ? 'Ja' : 'Nein' }, + { name: 'Status', value: 'Aktiviert' } ) - .setFooter({ text: 'Tip: Use /welcome greet dashboard to customize goodbye settings' }); + .setFooter({ text: 'Tipp: Mit /welcome greet dashboard passt du die Abschiedseinstellungen an' }); if (image) { embed.setImage(image); @@ -122,7 +122,7 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); } catch (error) { logger.error(`[Goodbye] Failed to setup goodbye system for guild ${guild.id}:`, error); - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while configuring the goodbye system. Please try again.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Einrichten des Abschiedssystems ist ein Fehler aufgetreten. Bitte versuch es erneut.' }); } } }, diff --git a/src/commands/Welcome/modules/greet.ts b/src/commands/Welcome/modules/greet.ts index 799fd3dcd3..ad20b0730d 100644 --- a/src/commands/Welcome/modules/greet.ts +++ b/src/commands/Welcome/modules/greet.ts @@ -9,18 +9,18 @@ export default { slashOnly: true, data: new SlashCommandBuilder() .setName('greet') - .setDescription('Manage welcome & goodbye settings') + .setDescription('Willkommens- und Abschiedseinstellungen verwalten') .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .addSubcommand(subcommand => subcommand .setName('dashboard') - .setDescription('Open the welcome & goodbye configuration dashboard'), + .setDescription('Das Dashboard für Willkommen und Abschied öffnen'), ), async execute(interaction, config, client) { try { if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the **Manage Server** permission to use `/welcome greet`.' }); + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Du brauchst die Berechtigung **Server verwalten**, um `/welcome greet` zu nutzen.' }); } const subcommand = interaction.options.getSubcommand(); @@ -33,7 +33,7 @@ export default { } } catch (error) { if (error instanceof NexoBotError) { - return await replyUserError(interaction, { type: ErrorTypes.CONFIGURATION, message: error.userMessage || 'Something went wrong.' }); + return await replyUserError(interaction, { type: ErrorTypes.CONFIGURATION, message: error.userMessage || 'Etwas ist schiefgelaufen.' }); } await handleInteractionError(interaction, error, { command: 'greet' }); } diff --git a/src/commands/Welcome/modules/greet_dashboard.ts b/src/commands/Welcome/modules/greet_dashboard.ts index 26cea6cebd..ab0d2db8bc 100644 --- a/src/commands/Welcome/modules/greet_dashboard.ts +++ b/src/commands/Welcome/modules/greet_dashboard.ts @@ -60,67 +60,67 @@ async function sendEphemeralFollowUp(interaction: MessageComponentInteraction, p } function buildDashboardEmbed(cfg: WelcomeConfig, guild: Guild) { - const welcomeChannel = cfg.channelId ? `<#${cfg.channelId}>` : '`Not set`'; - const goodbyeChannel = cfg.goodbyeChannelId ? `<#${cfg.goodbyeChannelId}>` : '`Not set`'; + const welcomeChannel = cfg.channelId ? `<#${cfg.channelId}>` : '`Nicht gesetzt`'; + const goodbyeChannel = cfg.goodbyeChannelId ? `<#${cfg.goodbyeChannelId}>` : '`Nicht gesetzt`'; - const rawWelcome = cfg.welcomeMessage || 'Welcome {user} to {server}!'; - const rawGoodbye = cfg.leaveMessage || '{user.tag} has left the server.'; + const rawWelcome = cfg.welcomeMessage || 'Willkommen {user} auf {server}!'; + const rawGoodbye = cfg.leaveMessage || '{user.tag} hat den Server verlassen.'; const welcomePreview = `\`${rawWelcome.length > 55 ? rawWelcome.substring(0, 55) + '…' : rawWelcome}\``; const goodbyePreview = `\`${rawGoodbye.length > 55 ? rawGoodbye.substring(0, 55) + '…' : rawGoodbye}\``; return new EmbedBuilder() - .setTitle('👋 Greet System Dashboard') + .setTitle('👋 Willkommen & Abschied – Dashboard') .setDescription( - `Manage welcome & goodbye settings for **${guild.name}**.\nUse the toggles to enable/disable each side, then select an option to edit.`, + `Verwalte die Willkommens- und Abschiedseinstellungen für **${guild.name}**.\nAktiviere oder deaktiviere beide Seiten mit den Schaltern und wähle dann eine Option zum Bearbeiten.`, ) .setColor(getColor('info')) .addFields( - { name: 'Welcome Channel', value: welcomeChannel, inline: true }, - { name: 'Welcome Status', value: cfg.enabled ? 'Enabled' : 'Disabled', inline: true }, - { name: 'Welcome Ping', value: cfg.welcomePing ? 'On' : 'Off', inline: true }, - { name: 'Goodbye Channel', value: goodbyeChannel, inline: true }, - { name: 'Goodbye Status', value: cfg.goodbyeEnabled ? 'Enabled' : 'Disabled', inline: true }, - { name: 'Goodbye Ping', value: cfg.goodbyePing ? 'On' : 'Off', inline: true }, - { name: 'Welcome Message', value: welcomePreview, inline: false }, - { name: 'Goodbye Message', value: goodbyePreview, inline: false }, + { name: 'Willkommenskanal', value: welcomeChannel, inline: true }, + { name: 'Willkommen-Status', value: cfg.enabled ? 'Aktiviert' : 'Deaktiviert', inline: true }, + { name: 'Willkommen-Ping', value: cfg.welcomePing ? 'An' : 'Aus', inline: true }, + { name: 'Abschiedskanal', value: goodbyeChannel, inline: true }, + { name: 'Abschied-Status', value: cfg.goodbyeEnabled ? 'Aktiviert' : 'Deaktiviert', inline: true }, + { name: 'Abschied-Ping', value: cfg.goodbyePing ? 'An' : 'Aus', inline: true }, + { name: 'Willkommensnachricht', value: welcomePreview, inline: false }, + { name: 'Abschiedsnachricht', value: goodbyePreview, inline: false }, ) - .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) + .setFooter({ text: 'Das Dashboard schließt sich nach 10 Minuten Inaktivität' }) .setTimestamp(); } function buildSelectMenu(guildId: string) { return new StringSelectMenuBuilder() .setCustomId(`greet_cfg_${guildId}`) - .setPlaceholder('Select a setting to configure...') + .setPlaceholder('Wähle eine Einstellung...') .addOptions( new StringSelectMenuOptionBuilder() - .setLabel('Welcome Channel') - .setDescription('Set the channel where welcome messages are sent') + .setLabel('Willkommenskanal') + .setDescription('Lege fest, in welchem Kanal Willkommensnachrichten gesendet werden') .setValue('welcome_channel') .setEmoji('🟢'), new StringSelectMenuOptionBuilder() - .setLabel('Welcome Message') - .setDescription('Edit the text shown when a member joins') + .setLabel('Willkommensnachricht') + .setDescription('Bearbeite den Text, der beim Beitritt eines Mitglieds erscheint') .setValue('welcome_message') .setEmoji('💬'), new StringSelectMenuOptionBuilder() - .setLabel('Welcome Image') - .setDescription('Set the image for welcome messages') + .setLabel('Willkommensbild') + .setDescription('Lege das Bild für Willkommensnachrichten fest') .setValue('welcome_image') .setEmoji('🖼️'), new StringSelectMenuOptionBuilder() - .setLabel('Goodbye Channel') - .setDescription('Set the channel where goodbye messages are sent') + .setLabel('Abschiedskanal') + .setDescription('Lege fest, in welchem Kanal Abschiedsnachrichten gesendet werden') .setValue('goodbye_channel') .setEmoji('🔴'), new StringSelectMenuOptionBuilder() - .setLabel('Goodbye Message') - .setDescription('Edit the text shown when a member leaves') + .setLabel('Abschiedsnachricht') + .setDescription('Bearbeite den Text, der beim Verlassen eines Mitglieds erscheint') .setValue('goodbye_message') .setEmoji('💬'), new StringSelectMenuOptionBuilder() - .setLabel('Goodbye Image') - .setDescription('Set the image for goodbye messages') + .setLabel('Abschiedsbild') + .setDescription('Lege das Bild für Abschiedsnachrichten fest') .setValue('goodbye_image') .setEmoji('🖼️'), ); @@ -136,13 +136,13 @@ function buildButtonRow(cfg: WelcomeConfig, guildId: string, disabled = false) { new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`greet_cfg_toggle_welcome_${guildId}`) - .setLabel('Welcome') + .setLabel('Willkommen') .setStyle(welcomeOn ? ButtonStyle.Success : ButtonStyle.Danger) .setEmoji('🟢') .setDisabled(disabled), new ButtonBuilder() .setCustomId(`greet_cfg_toggle_goodbye_${guildId}`) - .setLabel('Goodbye') + .setLabel('Abschied') .setStyle(goodbyeOn ? ButtonStyle.Success : ButtonStyle.Danger) .setEmoji('🔴') .setDisabled(disabled), @@ -150,13 +150,13 @@ function buildButtonRow(cfg: WelcomeConfig, guildId: string, disabled = false) { new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`greet_cfg_ping_welcome_${guildId}`) - .setLabel('Ping Welcome') + .setLabel('Ping bei Willkommen') .setStyle(welcomePingOn ? ButtonStyle.Primary : ButtonStyle.Secondary) .setEmoji('🔔') .setDisabled(disabled), new ButtonBuilder() .setCustomId(`greet_cfg_ping_goodbye_${guildId}`) - .setLabel('Ping Goodbye') + .setLabel('Ping bei Abschied') .setStyle(goodbyePingOn ? ButtonStyle.Primary : ButtonStyle.Secondary) .setEmoji('🔔') .setDisabled(disabled), @@ -190,7 +190,7 @@ export default { throw new NexoBotError( 'Greet system not configured', ErrorTypes.CONFIGURATION, - 'Neither Welcome nor Goodbye has been set up yet. Run `/welcome message setup` or `/welcome goodbye setup` first.', + 'Weder Willkommen noch Abschied sind eingerichtet. Führe zuerst `/welcome message setup` oder `/welcome goodbye setup` aus.', ); } @@ -248,8 +248,8 @@ export default { const errorMessage = error instanceof NexoBotError - ? error.userMessage || 'An error occurred while processing your selection.' - : 'An unexpected error occurred while updating the configuration.'; + ? error.userMessage || 'Beim Verarbeiten deiner Auswahl ist ein Fehler aufgetreten.' + : 'Beim Aktualisieren der Konfiguration ist ein unerwarteter Fehler aufgetreten.'; if (!selectInteraction.replied && !selectInteraction.deferred) { await selectInteraction.deferUpdate().catch(() => {}); @@ -287,8 +287,8 @@ export default { await sendEphemeralFollowUp(btnInteraction, { embeds: [ successEmbed( - '✅ Welcome Updated', - `Welcome messages are now **${cfg.enabled ? 'enabled' : 'disabled'}**.`, + '✅ Willkommen aktualisiert', + `Willkommensnachrichten sind jetzt **${cfg.enabled ? 'aktiviert' : 'deaktiviert'}**.`, ), ], }); @@ -298,8 +298,8 @@ export default { await sendEphemeralFollowUp(btnInteraction, { embeds: [ successEmbed( - '✅ Goodbye Updated', - `Goodbye messages are now **${cfg.goodbyeEnabled ? 'enabled' : 'disabled'}**.`, + '✅ Abschied aktualisiert', + `Abschiedsnachrichten sind jetzt **${cfg.goodbyeEnabled ? 'aktiviert' : 'deaktiviert'}**.`, ), ], }); @@ -309,8 +309,8 @@ export default { await sendEphemeralFollowUp(btnInteraction, { embeds: [ successEmbed( - '✅ Welcome Ping Updated', - `Joining users will${cfg.welcomePing ? '' : ' **not**'} be pinged in the welcome message.`, + '✅ Willkommen-Ping aktualisiert', + `Neue Nutzer werden in der Willkommensnachricht${cfg.welcomePing ? '' : ' **nicht**'} gepingt.`, ), ], }); @@ -320,8 +320,8 @@ export default { await sendEphemeralFollowUp(btnInteraction, { embeds: [ successEmbed( - '✅ Goodbye Ping Updated', - `Leaving users will${cfg.goodbyePing ? '' : ' **not**'} be pinged in the goodbye message.`, + '✅ Abschied-Ping aktualisiert', + `Gehende Nutzer werden in der Abschiedsnachricht${cfg.goodbyePing ? '' : ' **nicht**'} gepingt.`, ), ], }); @@ -340,8 +340,8 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [ new EmbedBuilder() - .setTitle('Dashboard Timed Out') - .setDescription('This dashboard has been closed due to inactivity. Please run the command again to continue.') + .setTitle('Dashboard abgelaufen') + .setDescription('Dieses Dashboard wurde wegen Inaktivität geschlossen. Führe den Command erneut aus, um weiterzumachen.') .setColor(getColor('error')) ], components: [], @@ -357,7 +357,7 @@ export default { throw new NexoBotError( `Greet dashboard failed: ${(error as Error).message}`, ErrorTypes.UNKNOWN, - 'Failed to open the greet dashboard.', + 'Das Willkommen-Dashboard konnte nicht geöffnet werden.', ); } }, @@ -370,16 +370,16 @@ async function handleWelcomeChannel(selectInteraction: StringSelectMenuInteracti const channelSelect = new ChannelSelectMenuBuilder() .setCustomId('greet_cfg_welcome_channel') - .setPlaceholder('Select a text channel...') + .setPlaceholder('Wähle einen Textkanal...') .addChannelTypes(ChannelType.GuildText) .setMaxValues(1); await sendEphemeralFollowUp(selectInteraction, { embeds: [ new EmbedBuilder() - .setTitle('🟢 Welcome Channel') + .setTitle('🟢 Willkommenskanal') .setDescription( - `**Current:** ${cfg.channelId ?`<#${cfg.channelId}>`: '`Not set`'}\n\nSelect the channel where welcome messages will be sent.`, + `**Aktuell:** ${cfg.channelId ?`<#${cfg.channelId}>`: '`Nicht gesetzt`'}\n\nWähle den Kanal, in dem Willkommensnachrichten gesendet werden.`, ) .setColor(getColor('info')), ], @@ -403,7 +403,7 @@ async function handleWelcomeChannel(selectInteraction: StringSelectMenuInteracti if (!botHasPermission(channel, ['ViewChannel', 'SendMessages', 'EmbedLinks'])) { await replyUserError(chanInteraction, { type: ErrorTypes.PERMISSION, - message: `I need **View Channel**, **Send Messages**, and **Embed Links** in ${channel}.`, + message: `Ich brauche **Kanal ansehen**, **Nachrichten senden** und **Links einbetten** in ${channel}.`, }); return; } @@ -412,7 +412,7 @@ async function handleWelcomeChannel(selectInteraction: StringSelectMenuInteracti await saveWelcomeConfig(client, guildId, cfg); await sendEphemeralFollowUp(chanInteraction, { - embeds: [successEmbed('Channel Updated', `Welcome messages will now be sent in ${channel}.`)], + embeds: [successEmbed('Kanal aktualisiert', `Willkommensnachrichten werden jetzt in ${channel} gesendet.`)], }); await refreshDashboard(rootInteraction, cfg, guildId); @@ -422,7 +422,7 @@ async function handleWelcomeChannel(selectInteraction: StringSelectMenuInteracti if (reason === 'time' && collected.size === 0) { replyUserError(selectInteraction, { type: ErrorTypes.RATE_LIMIT, - message: 'No channel was selected. The setting was not changed.', + message: 'Es wurde kein Kanal ausgewählt. Die Einstellung wurde nicht geändert.', }).catch(() => {}); } }); @@ -431,14 +431,14 @@ async function handleWelcomeChannel(selectInteraction: StringSelectMenuInteracti async function handleWelcomeMessage(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, cfg: WelcomeConfig, guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId('greet_cfg_welcome_message') - .setTitle('Edit Welcome Message') + .setTitle('Willkommensnachricht bearbeiten') .addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('message_input') - .setLabel('Message (variables: {user}, {server}, etc)') + .setLabel('Nachricht (Variablen: {user}, {server} usw.)') .setStyle(TextInputStyle.Paragraph) - .setValue(cfg.welcomeMessage || 'Welcome {user} to {server}!') + .setValue(cfg.welcomeMessage || 'Willkommen {user} auf {server}!') .setMaxLength(2000) .setMinLength(1) .setRequired(true), @@ -465,7 +465,7 @@ async function handleWelcomeMessage(selectInteraction: StringSelectMenuInteracti await saveWelcomeConfig(client, guildId, cfg); await submitted.reply({ - embeds: [successEmbed('Welcome Message Updated', 'The welcome message has been saved.')], + embeds: [successEmbed('Willkommensnachricht aktualisiert', 'Die Willkommensnachricht wurde gespeichert.')], flags: MessageFlags.Ephemeral, }); @@ -475,13 +475,13 @@ async function handleWelcomeMessage(selectInteraction: StringSelectMenuInteracti async function handleWelcomeImage(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, cfg: WelcomeConfig, guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId('greet_cfg_welcome_image') - .setTitle('Set Welcome Image'); + .setTitle('Willkommensbild festlegen'); const imageHint = new TextDisplayBuilder() - .setContent('Provide a direct image URL **or** upload a file below. If both are given, the uploaded file takes priority. Leave the URL blank and skip the upload to remove the image.'); + .setContent('Gib eine direkte Bild-URL an **oder** lade unten eine Datei hoch. Gibst du beides an, hat die hochgeladene Datei Vorrang. Lass die URL leer und überspring den Upload, um das Bild zu entfernen.'); const urlLabel = new LabelBuilder() - .setLabel('Image URL (optional)') + .setLabel('Bild-URL (optional)') .setTextInputComponent( new TextInputBuilder() .setCustomId('image_input') @@ -492,7 +492,7 @@ async function handleWelcomeImage(selectInteraction: StringSelectMenuInteraction ); const uploadLabel = new LabelBuilder() - .setLabel('Or upload an image file (optional)') + .setLabel('Oder lade eine Bilddatei hoch (optional)') .setFileUploadComponent( new FileUploadBuilder() .setCustomId('image_upload') @@ -526,11 +526,11 @@ async function handleWelcomeImage(selectInteraction: StringSelectMenuInteraction try { new URL(imageUrl); if (!['http:', 'https:'].includes(new URL(imageUrl).protocol)) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Image URL must start with `http://` or `https://`.' }); + await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Die Bild-URL muss mit `http://` oder `https://` beginnen.' }); return; } } catch { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Please provide a valid image URL.' }); + await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Bitte gib eine gültige Bild-URL an.' }); return; } } @@ -539,7 +539,7 @@ async function handleWelcomeImage(selectInteraction: StringSelectMenuInteraction await saveWelcomeConfig(client, guildId, cfg); await submitted.reply({ - embeds: [successEmbed('Welcome Image Updated', `Image ${imageUrl ? 'updated' : 'removed'} successfully.`)], + embeds: [successEmbed('Willkommensbild aktualisiert', `Bild erfolgreich ${imageUrl ? 'aktualisiert' : 'entfernt'}.`)], flags: MessageFlags.Ephemeral, }); @@ -557,8 +557,8 @@ async function handleWelcomePing(selectInteraction: StringSelectMenuInteraction, await sendEphemeralFollowUp(selectInteraction, { embeds: [ successEmbed( - '✅ Welcome Ping Updated', - `Joining users will${cfg.welcomePing ? '' : ' **not**'} be pinged in the welcome message.`, + '✅ Willkommen-Ping aktualisiert', + `Neue Nutzer werden in der Willkommensnachricht${cfg.welcomePing ? '' : ' **nicht**'} gepingt.`, ), ], }); @@ -573,16 +573,16 @@ async function handleGoodbyeChannel(selectInteraction: StringSelectMenuInteracti const channelSelect = new ChannelSelectMenuBuilder() .setCustomId('greet_cfg_goodbye_channel') - .setPlaceholder('Select a text channel...') + .setPlaceholder('Wähle einen Textkanal...') .addChannelTypes(ChannelType.GuildText) .setMaxValues(1); await sendEphemeralFollowUp(selectInteraction, { embeds: [ new EmbedBuilder() - .setTitle('🔴 Goodbye Channel') + .setTitle('🔴 Abschiedskanal') .setDescription( - `**Current:** ${cfg.goodbyeChannelId ?`<#${cfg.goodbyeChannelId}>`: '`Not set`'}\n\nSelect the channel where goodbye messages will be sent.`, + `**Aktuell:** ${cfg.goodbyeChannelId ?`<#${cfg.goodbyeChannelId}>`: '`Nicht gesetzt`'}\n\nWähle den Kanal, in dem Abschiedsnachrichten gesendet werden.`, ) .setColor(getColor('info')), ], @@ -606,7 +606,7 @@ async function handleGoodbyeChannel(selectInteraction: StringSelectMenuInteracti if (!botHasPermission(channel, ['ViewChannel', 'SendMessages', 'EmbedLinks'])) { await replyUserError(chanInteraction, { type: ErrorTypes.PERMISSION, - message: `I need **View Channel**, **Send Messages**, and **Embed Links** in ${channel}.`, + message: `Ich brauche **Kanal ansehen**, **Nachrichten senden** und **Links einbetten** in ${channel}.`, }); return; } @@ -615,7 +615,7 @@ async function handleGoodbyeChannel(selectInteraction: StringSelectMenuInteracti await saveWelcomeConfig(client, guildId, cfg); await sendEphemeralFollowUp(chanInteraction, { - embeds: [successEmbed('Channel Updated', `Goodbye messages will now be sent in ${channel}.`)], + embeds: [successEmbed('Kanal aktualisiert', `Abschiedsnachrichten werden jetzt in ${channel} gesendet.`)], }); await refreshDashboard(rootInteraction, cfg, guildId); @@ -625,7 +625,7 @@ async function handleGoodbyeChannel(selectInteraction: StringSelectMenuInteracti if (reason === 'time' && collected.size === 0) { replyUserError(selectInteraction, { type: ErrorTypes.RATE_LIMIT, - message: 'No channel was selected. The setting was not changed.', + message: 'Es wurde kein Kanal ausgewählt. Die Einstellung wurde nicht geändert.', }).catch(() => {}); } }); @@ -634,14 +634,14 @@ async function handleGoodbyeChannel(selectInteraction: StringSelectMenuInteracti async function handleGoodbyeMessage(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, cfg: WelcomeConfig, guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId('greet_cfg_goodbye_message') - .setTitle('Edit Goodbye Message') + .setTitle('Abschiedsnachricht bearbeiten') .addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('message_input') - .setLabel('Message (variables: {user}, {server}, etc)') + .setLabel('Nachricht (Variablen: {user}, {server} usw.)') .setStyle(TextInputStyle.Paragraph) - .setValue(cfg.leaveMessage || '{user.tag} has left the server.') + .setValue(cfg.leaveMessage || '{user.tag} hat den Server verlassen.') .setMaxLength(2000) .setMinLength(1) .setRequired(true), @@ -668,7 +668,7 @@ async function handleGoodbyeMessage(selectInteraction: StringSelectMenuInteracti await saveWelcomeConfig(client, guildId, cfg); await submitted.reply({ - embeds: [successEmbed('Goodbye Message Updated', 'The goodbye message has been saved.')], + embeds: [successEmbed('Abschiedsnachricht aktualisiert', 'Die Abschiedsnachricht wurde gespeichert.')], flags: MessageFlags.Ephemeral, }); @@ -678,13 +678,13 @@ async function handleGoodbyeMessage(selectInteraction: StringSelectMenuInteracti async function handleGoodbyeImage(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, cfg: WelcomeConfig, guildId: string, client: NexoClient) { const modal = new ModalBuilder() .setCustomId('greet_cfg_goodbye_image') - .setTitle('Set Goodbye Image'); + .setTitle('Abschiedsbild festlegen'); const imageHint = new TextDisplayBuilder() - .setContent('Provide a direct image URL **or** upload a file below. If both are given, the uploaded file takes priority. Leave the URL blank and skip the upload to remove the image.'); + .setContent('Gib eine direkte Bild-URL an **oder** lade unten eine Datei hoch. Gibst du beides an, hat die hochgeladene Datei Vorrang. Lass die URL leer und überspring den Upload, um das Bild zu entfernen.'); const urlLabel = new LabelBuilder() - .setLabel('Image URL (optional)') + .setLabel('Bild-URL (optional)') .setTextInputComponent( new TextInputBuilder() .setCustomId('image_input') @@ -699,7 +699,7 @@ async function handleGoodbyeImage(selectInteraction: StringSelectMenuInteraction ); const uploadLabel = new LabelBuilder() - .setLabel('Or upload an image file (optional)') + .setLabel('Oder lade eine Bilddatei hoch (optional)') .setFileUploadComponent( new FileUploadBuilder() .setCustomId('image_upload') @@ -733,11 +733,11 @@ async function handleGoodbyeImage(selectInteraction: StringSelectMenuInteraction try { new URL(imageUrl); if (!['http:', 'https:'].includes(new URL(imageUrl).protocol)) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Image URL must start with `http://` or `https://`.' }); + await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Die Bild-URL muss mit `http://` oder `https://` beginnen.' }); return; } } catch { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Please provide a valid image URL.' }); + await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Bitte gib eine gültige Bild-URL an.' }); return; } } @@ -753,7 +753,7 @@ async function handleGoodbyeImage(selectInteraction: StringSelectMenuInteraction await saveWelcomeConfig(client, guildId, cfg); await submitted.reply({ - embeds: [successEmbed('Goodbye Image Updated', `Image ${imageUrl ? 'updated' : 'removed'} successfully.`)], + embeds: [successEmbed('Abschiedsbild aktualisiert', `Bild erfolgreich ${imageUrl ? 'aktualisiert' : 'entfernt'}.`)], flags: MessageFlags.Ephemeral, }); @@ -771,8 +771,8 @@ async function handleGoodbyePing(selectInteraction: StringSelectMenuInteraction, await sendEphemeralFollowUp(selectInteraction, { embeds: [ successEmbed( - '✅ Goodbye Ping Updated', - `Leaving users will${cfg.goodbyePing ? '' : ' **not**'} be pinged in the goodbye message.`, + '✅ Abschied-Ping aktualisiert', + `Gehende Nutzer werden in der Abschiedsnachricht${cfg.goodbyePing ? '' : ' **nicht**'} gepingt.`, ), ], }); diff --git a/src/commands/Welcome/modules/welcome.ts b/src/commands/Welcome/modules/welcome.ts index 40470c6c09..d084722d70 100644 --- a/src/commands/Welcome/modules/welcome.ts +++ b/src/commands/Welcome/modules/welcome.ts @@ -11,28 +11,28 @@ import type { Command } from '../../../types/index.js'; export default { data: new SlashCommandBuilder() .setName('welcome') - .setDescription('Configure the welcome system') + .setDescription('Das Willkommenssystem konfigurieren') .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .addSubcommand(subcommand => subcommand .setName('setup') - .setDescription('Set up the welcome message') + .setDescription('Die Willkommensnachricht einrichten') .addChannelOption(option => option.setName('channel') - .setDescription('The channel to send welcome messages to') + .setDescription('Der Kanal für Willkommensnachrichten') .addChannelTypes(ChannelType.GuildText) .setRequired(true)) .addStringOption(option => option.setName('message') - .setDescription('Welcome message. Variables: {user}, {username}, {server}, {memberCount}') + .setDescription('Willkommensnachricht. Variablen: {user}, {username}, {server}, {memberCount}') .setRequired(true)) .addStringOption(option => option.setName('image') - .setDescription('URL of the image to include in the welcome message') + .setDescription('URL des Bildes für die Willkommensnachricht') .setRequired(false)) .addBooleanOption(option => option.setName('ping') - .setDescription('Whether to ping the user in the welcome message') + .setDescription('Ob der Nutzer in der Willkommensnachricht gepingt wird') .setRequired(false))), async execute(interaction) { @@ -54,7 +54,7 @@ export default { const { options, guild, client } = interaction as ChatInputCommandInteraction<'cached'>; if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the **Manage Server** permission to use `/welcome message`.' }); + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Du brauchst die Berechtigung **Server verwalten**, um `/welcome message` zu nutzen.' }); } const subcommand = options.getSubcommand(); @@ -68,12 +68,12 @@ 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 greet dashboard** to customize channel, message, ping, or image.` }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `Willkommen ist bereits für <#${existingConfig.channelId}> eingerichtet. Nutze **/welcome greet dashboard**, um Kanal, Nachricht, Ping oder Bild anzupassen.` }); } if (!message || message.trim().length === 0) { logger.warn(`[Welcome] Empty message provided by ${interaction.user.tag} in ${guild.name}`); - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Welcome message cannot be empty' }); + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Die Willkommensnachricht darf nicht leer sein.' }); } if (image) { @@ -81,7 +81,7 @@ export default { new URL(image); } catch (e) { logger.warn(`[Welcome] Invalid image URL provided by ${interaction.user.tag}: ${image}`); - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please provide a valid image URL (must start with http:// or https://' }); + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Bitte gib eine gültige Bild-URL an (muss mit http:// oder https:// beginnen).' }); } } @@ -103,14 +103,14 @@ export default { const embed = new EmbedBuilder() .setColor(getColor('success')) - .setTitle('Welcome System Configured') - .setDescription(`Welcome messages will now be sent to ${channel}`) + .setTitle('Willkommenssystem eingerichtet') + .setDescription(`Willkommensnachrichten werden jetzt in ${channel} gesendet.`) .addFields( - { name: 'Message Preview', value: truncateForEmbedField(previewMessage) }, - { name: 'Ping User', value: ping ? 'Yes' : 'No' }, - { name: 'Status', value: 'Enabled' } + { name: 'Nachrichtenvorschau', value: truncateForEmbedField(previewMessage) }, + { name: 'Nutzer pingen', value: ping ? 'Ja' : 'Nein' }, + { name: 'Status', value: 'Aktiviert' } ) - .setFooter({ text: 'Tip: Use /welcome greet dashboard to customize welcome settings' }); + .setFooter({ text: 'Tipp: Mit /welcome greet dashboard passt du die Willkommenseinstellungen an' }); if (image) { embed.setImage(image); @@ -119,7 +119,7 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); } catch (error) { logger.error(`[Welcome] Failed to setup welcome system for guild ${guild.id}:`, error); - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while configuring the welcome system. Please try again.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Einrichten des Willkommenssystems ist ein Fehler aufgetreten. Bitte versuch es erneut.' }); } } }, diff --git a/src/commands/Welcome/welcome.ts b/src/commands/Welcome/welcome.ts index 6458cad65f..61a87c993c 100644 --- a/src/commands/Welcome/welcome.ts +++ b/src/commands/Welcome/welcome.ts @@ -7,7 +7,7 @@ import welcomeCommand from './modules/welcome.js'; export default createRouterCommand({ name: 'welcome', - description: 'Welcome, goodbye and auto role settings', + description: 'Einstellungen für Willkommen, Abschied und Auto-Rolle', category: 'Welcome', defaultMemberPermissions: PermissionFlagsBits.ManageGuild, routes: [ diff --git a/src/config/bot.ts b/src/config/bot.ts index 6f82136f69..1e8e705c9e 100644 --- a/src/config/bot.ts +++ b/src/config/bot.ts @@ -28,7 +28,7 @@ export const botConfig = { activities: [ { name: "Custom Status", // required by Discord API, not shown in the client - state: "stalking", // this is what people actually see + state: "auf der Lauer", // this is what people actually see type: 4, // Custom }, ], @@ -65,9 +65,9 @@ export const botConfig = { applications: { // Default questions shown when someone fills out an application. defaultQuestions: [ - { question: "What is your name?", required: true }, - { question: "How old are you?", required: true }, - { question: "Why do you want to join?", required: true }, + { question: "Wie heißt du?", required: true }, + { question: "Wie alt bist du?", required: true }, + { question: "Warum möchtest du beitreten?", required: true }, ], // Embed colors by application status. @@ -165,9 +165,9 @@ export const botConfig = { economy: { currency: { // Currency display name. - name: "coins", + name: "Münzen", // Plural display name. - namePlural: "coins", + namePlural: "Münzen", // Currency symbol shown in balances. symbol: "$", }, @@ -228,27 +228,27 @@ export const botConfig = { none: { emoji: "⚪", color: "#95A5A6", - label: "None", + label: "Keine", }, low: { emoji: "🟢", color: "#2ECC71", - label: "Low", + label: "Niedrig", }, medium: { emoji: "🟡", color: "#F1C40F", - label: "Medium", + label: "Mittel", }, high: { emoji: "🔴", color: "#E74C3C", - label: "High", + label: "Hoch", }, urgent: { emoji: "🚨", color: "#E91E63", - label: "Urgent", + label: "Dringend", }, }, @@ -306,10 +306,10 @@ export const botConfig = { // ========================= verification: { // Message shown when posting the verification panel. - defaultMessage: "Click the button below to verify yourself and gain access to the server!", + defaultMessage: "Klicke auf den Button unten, um dich zu verifizieren und Zugang zum Server zu erhalten!", // Text on the verification button. - defaultButtonText: "Verify", + defaultButtonText: "Verifizieren", // Automatic verification behavior. autoVerify: { @@ -336,9 +336,9 @@ export const botConfig = { // Human-readable descriptions for each criteria mode. criteria: { - account_age: "Account must be older than specified days", - server_size: "All users if server has less than 1000 members", - none: "All users immediately" + account_age: "Account muss älter als die angegebene Anzahl Tage sein", + server_size: "Alle Nutzer, wenn der Server weniger als 1000 Mitglieder hat", + none: "Alle Nutzer sofort" } }, @@ -376,11 +376,11 @@ export const botConfig = { // Welcome template posted when a user joins. // Placeholders: {user}, {server}, {memberCount} defaultWelcomeMessage: - "Welcome {user} to {server}! We now have {memberCount} members!", + "Willkommen {user} auf {server}! Wir sind jetzt {memberCount} Mitglieder!", // Goodbye template posted when a user leaves. // Placeholders: {user}, {memberCount} defaultGoodbyeMessage: - "{user} has left the server. We now have {memberCount} members.", + "{user} hat den Server verlassen. Wir sind jetzt {memberCount} Mitglieder.", // Channel ID for welcome messages. defaultWelcomeChannel: null as string | null, // Channel ID for goodbye messages. @@ -393,8 +393,8 @@ export const botConfig = { counters: { defaults: { // Default naming/description templates for counter entries. - name: "{name} Counter", - description: "Server {name} counter", + name: "{name}-Zähler", + description: "Server-Zähler: {name}", // Channel type used for counters (typically "voice"). type: "voice", // Channel name format. `{count}` is replaced automatically. @@ -408,26 +408,26 @@ export const botConfig = { }, messages: { // Default response messages for counter actions. - created: "✅ Created counter **{name}**", - deleted: "🗑️ Deleted counter **{name}**", - updated: "🔄 Updated counter **{name}**", + created: "✅ Zähler **{name}** erstellt", + deleted: "🗑️ Zähler **{name}** gelöscht", + updated: "🔄 Zähler **{name}** aktualisiert", }, types: { // Built-in counter types and how each count is calculated. members: { - name: "👥 Members", - description: "Total members in the server", + name: "👥 Mitglieder", + description: "Mitglieder insgesamt auf dem Server", getCount: (guild: Guild) => guild.memberCount.toString(), }, bots: { name: "🤖 Bots", - description: "Total bot accounts in the server", + description: "Bot-Accounts insgesamt auf dem Server", getCount: (guild: Guild) => guild.members.cache.filter((m) => m.user.bot).size.toString(), }, members_only: { - name: "👤 Humans", - description: "Total human members (non-bots)", + name: "👤 Menschen", + description: "Menschliche Mitglieder insgesamt (ohne Bots)", getCount: (guild: Guild) => guild.members.cache.filter((m) => !m.user.bot).size.toString(), }, @@ -438,13 +438,13 @@ export const botConfig = { // GENERIC BOT MESSAGES // ========================= messages: { - noPermission: "You do not have permission to use this command.", - cooldownActive: "Please wait {time} before using this command again.", - errorOccurred: "An error occurred while executing this command.", + noPermission: "Du hast keine Berechtigung, diesen Command zu nutzen.", + cooldownActive: "Bitte warte {time}, bevor du diesen Command erneut nutzt.", + errorOccurred: "Beim Ausführen dieses Commands ist ein Fehler aufgetreten.", missingPermissions: - "I am missing required permissions to perform this action.", - commandDisabled: "This command has been disabled.", - maintenanceMode: "The bot is currently in maintenance mode.", + "Mir fehlen die nötigen Berechtigungen für diese Aktion.", + commandDisabled: "Dieser Command wurde deaktiviert.", + maintenanceMode: "Der Bot befindet sich gerade im Wartungsmodus.", }, // ========================= diff --git a/src/config/commands/commandCategories.ts b/src/config/commands/commandCategories.ts index b8a1140a66..4522eb7f89 100644 --- a/src/config/commands/commandCategories.ts +++ b/src/config/commands/commandCategories.ts @@ -24,6 +24,33 @@ export const CATEGORY_ICONS: Record = { Welcome: '👋', }; +/** German display labels by command folder name (folder keys stay English: they are stored as disabledCategories keys). */ +export const CATEGORY_LABELS: Record = { + Birthday: 'Geburtstag', + Community: 'Community', + Core: 'Allgemein', + Economy: 'Wirtschaft', + Fun: 'Spaß', + Giveaway: 'Giveaways', + JoinToCreate: 'Join-to-Create', + Leveling: 'Leveling', + Logging: 'Logging', + Moderation: 'Moderation', + Music: 'Musik', + Reaction_roles: 'Reaktionsrollen', + Search: 'Suche', + ServerStats: 'Server-Statistiken', + Ticket: 'Tickets', + Tools: 'Tools', + Utility: 'Nützliches', + Verification: 'Verifizierung', + Welcome: 'Willkommen', +}; + +const CATEGORY_LABELS_BY_KEY = new Map( + Object.entries(CATEGORY_LABELS).map(([folder, label]) => [normalizeCategoryKey(folder), label]), +); + /** Commands that always stay available so admins can recover access. */ export const PROTECTED_COMMANDS = new Set(['commands', 'configwizard']); @@ -34,13 +61,19 @@ export function normalizeCategoryKey(category: string | null | undefined): strin .replace(/\s+/g, '_'); } -export function formatCategoryName(rawCategory: string | null | undefined): string { +/** English title-cased folder name (e.g. "Reaction_roles" -> "Reaction Roles"). */ +function formatFolderName(rawCategory: string | null | undefined): string { return String(rawCategory || '') .replace(/_/g, ' ') .replace(/([a-z])([A-Z])/g, '$1 $2') .replace(/\b\w/g, (char) => char.toUpperCase()); } +/** German display label for a category folder; falls back to the title-cased folder name. */ +export function formatCategoryName(rawCategory: string | null | undefined): string { + return CATEGORY_LABELS_BY_KEY.get(normalizeCategoryKey(rawCategory)) ?? formatFolderName(rawCategory); +} + export function getCategoryIcon(category: string): string { - return CATEGORY_ICONS[category] || CATEGORY_ICONS[formatCategoryName(category)] || '📁'; + return CATEGORY_ICONS[category] || CATEGORY_ICONS[formatFolderName(category)] || '📁'; } diff --git a/src/config/commands/prefixRestrictions.ts b/src/config/commands/prefixRestrictions.ts index 1cd7075252..045f4c35cd 100644 --- a/src/config/commands/prefixRestrictions.ts +++ b/src/config/commands/prefixRestrictions.ts @@ -104,11 +104,11 @@ export function getPrefixRestriction( const commandName = commandJson.name?.toLowerCase(); if (command.prefixOnly === false || command.slashOnly === true) { - return { blocked: true, reason: 'This command is only available as a slash command.' }; + return { blocked: true, reason: 'Dieser Command ist nur als Slash-Command verfügbar.' }; } if (SLASH_ONLY_COMMANDS.has(commandName)) { - return { blocked: true, reason: 'This command is only available as a slash command.' }; + return { blocked: true, reason: 'Dieser Command ist nur als Slash-Command verfügbar.' }; } const [firstArg, secondArg] = args.map((arg) => arg?.toLowerCase?.() || null); @@ -125,27 +125,27 @@ export function getPrefixRestriction( allSubcommandNames.every((name) => isSubcommandBlocked(commandName, name)); if (allSubcommandsBlocked) { - return { blocked: true, reason: 'This command is only available as a slash command.' }; + return { blocked: true, reason: 'Dieser Command ist nur als Slash-Command verfügbar.' }; } if (firstArg && GLOBAL_BLOCKED_SUBCOMMAND_GROUPS.has(firstArg)) { return { blocked: true, - reason: 'This configuration flow is only available as a slash command.', + reason: 'Diese Konfiguration ist nur als Slash-Command verfügbar.', }; } if (firstArg && (isSubcommandBlocked(commandName, firstArg) || isSubcommandBlocked(commandName, resolvedFirstArg))) { return { blocked: true, - reason: 'This subcommand is only available as a slash command.', + reason: 'Dieser Subcommand ist nur als Slash-Command verfügbar.', }; } if (matchedGroup && secondArg && (isSubcommandBlocked(commandName, secondArg) || isSubcommandBlocked(commandName, resolvedSecondArg))) { return { blocked: true, - reason: 'This subcommand is only available as a slash command.', + reason: 'Dieser Subcommand ist nur als Slash-Command verfügbar.', }; } diff --git a/src/config/guild/guildConfigDefaults.ts b/src/config/guild/guildConfigDefaults.ts index ef60a90be6..52777c8dce 100644 --- a/src/config/guild/guildConfigDefaults.ts +++ b/src/config/guild/guildConfigDefaults.ts @@ -8,7 +8,7 @@ import { DEFAULT_GUILD_CONFIG } from '../../utils/constants.js'; export const GUILD_CONFIG_DEFAULTS = { ...DEFAULT_GUILD_CONFIG, prefix: getCommandPrefix(), - welcomeMessage: BotConfig.welcome?.defaultWelcomeMessage || 'Welcome {user} to {server}!', + welcomeMessage: BotConfig.welcome?.defaultWelcomeMessage || 'Willkommen {user} auf {server}!', dmOnClose: true, disabledCommands: {}, disabledCategories: {}, diff --git a/src/config/shop/index.ts b/src/config/shop/index.ts index 1960292cef..e38e8d70bd 100644 --- a/src/config/shop/index.ts +++ b/src/config/shop/index.ts @@ -22,29 +22,29 @@ export const shopConfig = { categories: [ { id: 'consumables', - name: 'Consumables', - description: 'One-time use items that provide temporary benefits', + name: 'Verbrauchsgüter', + description: 'Einmal nutzbare Items mit zeitlich begrenzten Vorteilen', icon: '🍯', itemTypes: ['consumable'] }, { id: 'upgrades', name: 'Upgrades', - description: 'Permanent upgrades that enhance your abilities', + description: 'Dauerhafte Upgrades, die deine Fähigkeiten verbessern', icon: '⚡', itemTypes: ['upgrade'] }, { id: 'tools', - name: 'Tools', - description: 'Equipment that helps you gather resources more efficiently', + name: 'Werkzeuge', + description: 'Ausrüstung, mit der du Ressourcen effizienter sammelst', icon: '⛏️', itemTypes: ['tool'] }, { id: 'roles', - name: 'Roles', - description: 'Special roles with unique perks', + name: 'Rollen', + description: 'Besondere Rollen mit einzigartigen Vorteilen', icon: '🎭', itemTypes: ['role'] } @@ -106,7 +106,7 @@ mythic: '#E74C3C' enabled: true, interval: 86400000, announcementChannel: null as string | null, - message: '🛒 **Shop Restocked!** New items are now available!' + message: '🛒 **Shop aufgefüllt!** Neue Items sind jetzt verfügbar!' }, sales: { @@ -115,7 +115,7 @@ announcementChannel: null as string | null, { day: 0, discount: 0.2, - message: '🔥 **Weekend Sale!** 20% off all items!' + message: '🔥 **Wochenend-Sale!** 20 % Rabatt auf alle Items!' }, ] } @@ -167,8 +167,8 @@ export function getCategoryForItem(itemType: string): Omit = userData.inventory || {}; @@ -201,7 +201,7 @@ export function validatePurchase(itemId: string, userData: ShopUserData): Purcha if (currentQuantity >= item.maxQuantity) { return { valid: false, - reason: `You can only have a maximum of ${item.maxQuantity} ${item.name}s` + reason: `Du kannst höchstens ${item.maxQuantity}× ${item.name} besitzen` }; } } @@ -211,7 +211,7 @@ export function validatePurchase(itemId: string, userData: ShopUserData): Purcha if (upgrades[itemId]) { return { valid: false, - reason: `You've already purchased ${item.name}` + reason: `Du hast ${item.name} bereits gekauft` }; } } @@ -222,7 +222,7 @@ export function validatePurchase(itemId: string, userData: ShopUserData): Purcha if (itemId !== 'bank_note' && currentQuantity > 0) { return { valid: false, - reason: `You already have a ${item.name}` + reason: `Du besitzt bereits: ${item.name}` }; } } @@ -231,7 +231,7 @@ export function validatePurchase(itemId: string, userData: ShopUserData): Purcha if (userData.roles?.includes(item.roleId)) { return { valid: false, - reason: `You already have the ${item.name} role` + reason: `Du hast die Rolle ${item.name} bereits` }; } } diff --git a/src/events/guildMemberAdd.ts b/src/events/guildMemberAdd.ts index 8930805706..9a9081e913 100644 --- a/src/events/guildMemberAdd.ts +++ b/src/events/guildMemberAdd.ts @@ -36,19 +36,19 @@ export default { if (permissions?.has([PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages])) { const formatData = { user, guild, member }; const welcomeMessage = formatWelcomeMessage( - welcomeConfig.welcomeMessage || welcomeConfig.welcomeEmbed?.description || botConfig.welcome?.defaultWelcomeMessage || 'Welcome {user} to {server}!', + welcomeConfig.welcomeMessage || welcomeConfig.welcomeEmbed?.description || botConfig.welcome?.defaultWelcomeMessage || 'Willkommen {user} auf {server}!', formatData ); const messageContent = welcomeConfig.welcomePing ? user.toString() : null; const embedTitle = formatWelcomeMessage( - welcomeConfig.welcomeEmbed?.title || '🎉 Welcome!', + welcomeConfig.welcomeEmbed?.title || '🎉 Willkommen!', formatData ); const embedFooter = welcomeConfig.welcomeEmbed?.footer ? formatWelcomeMessage(welcomeConfig.welcomeEmbed.footer, formatData) - : `Welcome to ${guild.name}!`; + : `Willkommen auf ${guild.name}!`; const canEmbed = permissions.has(PermissionFlagsBits.EmbedLinks); @@ -63,8 +63,8 @@ export default { .setDescription(welcomeMessage) .setThumbnail(user.displayAvatarURL()) .addFields( - { name: 'User', value: `${user.tag} (${user.id})`, inline: true }, - { name: 'Member Count', value: guild.memberCount.toString(), inline: true } + { name: 'Nutzer', value: `${user.tag} (${user.id})`, inline: true }, + { name: 'Mitglieder', value: guild.memberCount.toString(), inline: true } ) .setTimestamp() .setFooter({ text: embedFooter }); @@ -118,12 +118,12 @@ export default { guildId: guild.id, eventType: EVENT_TYPES.MEMBER_JOIN, data: { - title: 'User joined', + title: 'Nutzer beigetreten', lines: [ - `**User:** ${user.toString()} (${user.displayName !== user.username ? `@${user.displayName}` : user.tag})`, + `**Nutzer:** ${user.toString()} (${user.displayName !== user.username ? `@${user.displayName}` : user.tag})`, `**ID:** \`${user.id}\``, - `**Created:** `, - `**Members:** ${guild.memberCount}`, + `**Erstellt:** `, + `**Mitglieder:** ${guild.memberCount}`, ], quoted: false, thumbnail: user.displayAvatarURL(), diff --git a/src/events/guildMemberRemove.ts b/src/events/guildMemberRemove.ts index e0d990165c..f6678eef68 100644 --- a/src/events/guildMemberRemove.ts +++ b/src/events/guildMemberRemove.ts @@ -34,17 +34,17 @@ export default { const formatData = { user, guild, member }; const goodbyeMessage = formatWelcomeMessage( - welcomeConfig.leaveMessage || welcomeConfig.leaveEmbed?.description || botConfig.welcome?.defaultGoodbyeMessage || '{user} has left the server.', + welcomeConfig.leaveMessage || welcomeConfig.leaveEmbed?.description || botConfig.welcome?.defaultGoodbyeMessage || '{user} hat den Server verlassen.', formatData ); const embedTitle = formatWelcomeMessage( - welcomeConfig.leaveEmbed?.title || '👋 Goodbye', + welcomeConfig.leaveEmbed?.title || '👋 Auf Wiedersehen', formatData ); const embedFooter = welcomeConfig.leaveEmbed?.footer ? formatWelcomeMessage(welcomeConfig.leaveEmbed.footer, formatData) - : `Goodbye from ${guild.name}!`; + : `Tschüss von ${guild.name}!`; const canEmbed = permissions.has(PermissionFlagsBits.EmbedLinks); @@ -60,8 +60,8 @@ export default { .setColor((welcomeConfig.leaveEmbed?.color || getColor('error')) as ColorResolvable) .setThumbnail(user.displayAvatarURL()) .addFields( - { name: 'User', value: `${user.tag} (${user.id})`, inline: true }, - { name: 'Member Count', value: guild.memberCount.toString(), inline: true } + { name: 'Nutzer', value: `${user.tag} (${user.id})`, inline: true }, + { name: 'Mitglieder', value: guild.memberCount.toString(), inline: true } ) .setTimestamp() .setFooter({ text: embedFooter }); @@ -89,12 +89,12 @@ export default { guildId: guild.id, eventType: EVENT_TYPES.MEMBER_LEAVE, data: { - title: 'User left', + title: 'Nutzer hat den Server verlassen', lines: [ - `**User:** ${user.toString()} (${user.tag})`, + `**Nutzer:** ${user.toString()} (${user.tag})`, `**ID:** \`${user.id}\``, - `**Joined:** `, - `**Members:** ${guild.memberCount}`, + `**Beigetreten:** `, + `**Mitglieder:** ${guild.memberCount}`, ], quoted: false, thumbnail: user.displayAvatarURL(), diff --git a/src/events/guildMemberUpdate.ts b/src/events/guildMemberUpdate.ts index 0bd6f4fc04..d73c96b98b 100644 --- a/src/events/guildMemberUpdate.ts +++ b/src/events/guildMemberUpdate.ts @@ -17,12 +17,12 @@ export default { guildId: newMember.guild.id, eventType: EVENT_TYPES.MEMBER_NAME_CHANGE, data: { - title: 'Nickname changed', + title: 'Nickname geändert', lines: [ - `**User:** ${newMember.user.toString()} (${newMember.user.tag})`, + `**Nutzer:** ${newMember.user.toString()} (${newMember.user.tag})`, `**ID:** \`${newMember.user.id}\``, - `**Before:** ${oldMember.nickname || '*(no nickname)*'}`, - `**After:** ${newMember.nickname || '*(no nickname)*'}`, + `**Vorher:** ${oldMember.nickname || '*(kein Nickname)*'}`, + `**Nachher:** ${newMember.nickname || '*(kein Nickname)*'}`, ], thumbnail: newMember.user.displayAvatarURL(), userId: newMember.user.id, diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index c065b0819d..942f947d4e 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -99,7 +99,7 @@ export default { throw createError( `No command matching ${interaction.commandName} was found.`, ErrorTypes.CONFIGURATION, - 'Sorry, that command does not exist.', + 'Diesen Command gibt es leider nicht.', withTraceContext({ commandName: interaction.commandName }, interactionTraceContext) ); } @@ -154,7 +154,7 @@ export default { throw createError( `Risky command cooldown active for ${interaction.commandName}`, ErrorTypes.RATE_LIMIT, - `This command is on cooldown. Please wait ${formattedCooldown} before trying again.`, + `Dieser Command hat gerade Cooldown. Bitte warte ${formattedCooldown}, bevor du es erneut versuchst.`, withTraceContext({ commandName: interaction.commandName, subtype: 'command_cooldown', @@ -174,7 +174,7 @@ export default { throw createError( `Command ${accessKey} is disabled in this guild`, ErrorTypes.CONFIGURATION, - 'This command has been disabled for this server.', + 'Dieser Command wurde für diesen Server deaktiviert.', withTraceContext({ commandName: accessKey, guildId: interaction.guild.id }, interactionTraceContext) ); } @@ -227,7 +227,7 @@ export default { await interaction.respond( filtered.slice(0, 25).map(role => ({ - name: `${role.name}${role.enabled === false ? ' (disabled)' : ''}`, + name: `${role.name}${role.enabled === false ? ' (deaktiviert)' : ''}`, value: role.name })) ); @@ -251,7 +251,7 @@ export default { await interaction.respond( filtered.slice(0, 25).map(role => ({ - name: `${role.name}${role.enabled === false ? ' (disabled)' : ''}`, + name: `${role.name}${role.enabled === false ? ' (deaktiviert)' : ''}`, value: role.name })) ); @@ -310,8 +310,8 @@ export default { 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'; + const title = msg?.embeds?.[0]?.title ?? 'Panel ohne Titel'; + const channelName = channel?.name ?? 'unbekannt'; return { name: `${title} (${channelName})`.substring(0, 100), @@ -355,7 +355,7 @@ export default { throw createError( `No button handler found for ${buttonType}`, ErrorTypes.CONFIGURATION, - 'This button is not available.', + 'Dieser Button ist nicht verfügbar.', withTraceContext({ buttonType }, interactionTraceContext) ); } @@ -373,7 +373,7 @@ export default { throw createError( `No button handler found for ${customId}`, ErrorTypes.CONFIGURATION, - 'This button is not available.', + 'Dieser Button ist nicht verfügbar.', withTraceContext({ customId }, interactionTraceContext) ); } @@ -399,7 +399,7 @@ export default { throw createError( `No select menu handler found for ${customId}`, ErrorTypes.CONFIGURATION, - 'This select menu is not available.', + 'Dieses Auswahlmenü ist nicht verfügbar.', withTraceContext({ customId }, interactionTraceContext) ); } @@ -452,7 +452,7 @@ export default { throw createError( `No modal handler found for ${customId}`, ErrorTypes.CONFIGURATION, - 'This form is not available.', + 'Dieses Formular ist nicht verfügbar.', withTraceContext({ customId }, interactionTraceContext) ); } diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index e4f559bd76..9dd22830de 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -85,7 +85,7 @@ async function handlePrefixCommand(message: Message, client: NexoClient) { if (isMaintenanceMode() && !isBotOwner(message.author.id)) { await message.channel.send({ embeds: [createEmbed({ - title: 'Maintenance Mode', + title: 'Wartungsmodus', description: getBotMessage('maintenanceMode'), color: 'warning', })], @@ -101,7 +101,7 @@ async function handlePrefixCommand(message: Message, client: NexoClient) { if (!isCommandCategoryEnabled(command.category) || (route && !isCommandCategoryEnabled(route.category))) { await message.channel.send({ embeds: [createEmbed({ - title: 'Feature Disabled', + title: 'Funktion deaktiviert', description: getBotMessage('commandDisabled'), color: 'error', })], @@ -113,8 +113,8 @@ async function handlePrefixCommand(message: Message, client: NexoClient) { if (!supportsPrefixExecution(command) || restriction.blocked) { if (restriction.blocked && restriction.reason) { const embed = createEmbed({ - title: 'Slash Command Only', - description: `${restriction.reason}\nUse \`/${route ? accessKey : resolvedCommandName}\` instead.`, + title: 'Nur als Slash-Command', + description: `${restriction.reason}\nNutze stattdessen \`/${route ? accessKey : resolvedCommandName}\`.`, color: 'info', }); await message.channel.send({ embeds: [embed] }).catch(() => {}); @@ -124,8 +124,8 @@ async function handlePrefixCommand(message: Message, client: NexoClient) { if (!(await isCommandEnabled(client, message.guild.id, accessKey, command.category))) { const embed = createEmbed({ - title: 'Command Disabled', - description: 'This command has been disabled for this server.', + title: 'Command deaktiviert', + description: 'Dieser Command wurde für diesen Server deaktiviert.', color: 'error', }); await message.channel.send({ embeds: [embed] }).catch(() => {}); @@ -144,8 +144,8 @@ async function handlePrefixCommand(message: Message, client: NexoClient) { if (!abuseProtection.allowed) { const formattedCooldown = formatCooldownDuration(abuseProtection.remainingMs); const embed = createEmbed({ - title: 'Command Cooldown', - description: `This command is on cooldown. Please wait ${formattedCooldown} before trying again.`, + title: 'Command-Cooldown', + description: `Dieser Command hat gerade Cooldown. Bitte warte ${formattedCooldown}, bevor du es erneut versuchst.`, color: 'error', }); await message.channel.send({ embeds: [embed] }).catch(() => {}); @@ -180,7 +180,7 @@ async function handleCountingGame(message: Message, client: NexoClient) { currentStreak: 0, }); - const failureMessage = await message.channel.send(`❌ Count broken by <@${message.author.id}>. The sequence has been reset to **1**.`); + const failureMessage = await message.channel.send(`❌ <@${message.author.id}> hat die Zählung unterbrochen. Es geht wieder bei **1** los.`); setTimeout(() => { failureMessage.delete().catch(() => {}); }, 10000); diff --git a/src/events/messageDelete.ts b/src/events/messageDelete.ts index 15cd0efa52..68d822adef 100644 --- a/src/events/messageDelete.ts +++ b/src/events/messageDelete.ts @@ -28,11 +28,11 @@ export default { guildId: message.guild.id, eventType: EVENT_TYPES.REACTION_ROLE_DELETE, data: { - title: 'Reaction Role Removed', + title: 'Reaktionsrolle entfernt', lines: [ - formatLogLine('Channel', message.channel ? `${(message.channel as GuildTextBasedChannel).name} ${message.channel.toString()}` : 'Unknown'), - formatLogLine('Message ID', `\`${message.id}\``), - formatLogLine('Cleanup', 'Database entry removed automatically'), + formatLogLine('Kanal', message.channel ? `${(message.channel as GuildTextBasedChannel).name} ${message.channel.toString()}` : 'Unbekannt'), + formatLogLine('Nachrichten-ID', `\`${message.id}\``), + formatLogLine('Bereinigung', 'Datenbankeintrag automatisch entfernt'), ], quoted: true, } @@ -48,10 +48,10 @@ export default { if (message.author?.bot) return; const metaLines = [ - formatLogLine('Channel', message.channel ? `${(message.channel as GuildTextBasedChannel).name} ${message.channel.toString()}` : 'Unknown'), - formatLogLine('Message ID', `\`${message.id}\``), - formatLogLine('Message author', message.author ? message.author.toString() : 'Unknown'), - formatLogLine('Message created', ``), + formatLogLine('Kanal', message.channel ? `${(message.channel as GuildTextBasedChannel).name} ${message.channel.toString()}` : 'Unbekannt'), + formatLogLine('Nachrichten-ID', `\`${message.id}\``), + formatLogLine('Autor', message.author ? message.author.toString() : 'Unbekannt'), + formatLogLine('Erstellt', ``), ]; let messageBody = null; @@ -62,7 +62,7 @@ export default { } if (message.attachments.size > 0) { - metaLines.push(formatLogLine('Attachments', String(message.attachments.size))); + metaLines.push(formatLogLine('Anhänge', String(message.attachments.size))); } await logEvent({ @@ -70,10 +70,10 @@ export default { guildId: message.guild.id, eventType: EVENT_TYPES.MESSAGE_DELETE, data: { - title: 'Message deleted', + title: 'Nachricht gelöscht', lines: metaLines, quoted: true, - section: messageBody ? { title: 'Message', body: messageBody || '*(empty message)*' } : null, + section: messageBody ? { title: 'Nachricht', body: messageBody || '*(leere Nachricht)*' } : null, userId: message.author?.id, channelId: message.channel.id, } diff --git a/src/events/messageUpdate.ts b/src/events/messageUpdate.ts index ac0732dcf5..b79118d6ea 100644 --- a/src/events/messageUpdate.ts +++ b/src/events/messageUpdate.ts @@ -18,14 +18,14 @@ export default { if (oldMessage.content === newMessage.content) return; const metaLines = [ - formatLogLine('Channel', newMessage.channel ? `${(newMessage.channel as GuildTextBasedChannel).name} ${newMessage.channel.toString()}` : 'Unknown'), - formatLogLine('Message ID', `\`${newMessage.id}\``), - formatLogLine('Message author', newMessage.author ? newMessage.author.toString() : 'Unknown'), - formatLogLine('Message created', ``), + formatLogLine('Kanal', newMessage.channel ? `${(newMessage.channel as GuildTextBasedChannel).name} ${newMessage.channel.toString()}` : 'Unbekannt'), + formatLogLine('Nachrichten-ID', `\`${newMessage.id}\``), + formatLogLine('Autor', newMessage.author ? newMessage.author.toString() : 'Unbekannt'), + formatLogLine('Erstellt', ``), ]; - const oldContent = oldMessage.content || '*(empty message)*'; - const newContent = newMessage.content || '*(empty message)*'; + const oldContent = oldMessage.content || '*(leere Nachricht)*'; + const newContent = newMessage.content || '*(leere Nachricht)*'; const oldContentTruncated = oldContent.length > MAX_LOGGED_EDIT_CONTENT_LENGTH ? `${oldContent.substring(0, MAX_LOGGED_EDIT_CONTENT_LENGTH - 3)}...` : oldContent; @@ -38,12 +38,12 @@ export default { guildId: newMessage.guild.id, eventType: EVENT_TYPES.MESSAGE_EDIT, data: { - title: 'Message edited', + title: 'Nachricht bearbeitet', lines: metaLines, quoted: true, fields: [ - { name: 'Before', value: oldContentTruncated, inline: true }, - { name: 'After', value: newContentTruncated, inline: true }, + { name: 'Vorher', value: oldContentTruncated, inline: true }, + { name: 'Nachher', value: newContentTruncated, inline: true }, ], userId: newMessage.author?.id, channelId: newMessage.channel.id, diff --git a/src/events/roleCreate.ts b/src/events/roleCreate.ts index 16bcfacc0e..dd1fef7914 100644 --- a/src/events/roleCreate.ts +++ b/src/events/roleCreate.ts @@ -19,8 +19,8 @@ export default { guildId: role.guild.id, eventType: EVENT_TYPES.ROLE_CREATE, data: { - title: 'Role Created', - headline: `${role.toString()} was created`, + title: 'Rolle erstellt', + headline: `${role.toString()} wurde erstellt`, lines, }, }); diff --git a/src/events/roleDelete.ts b/src/events/roleDelete.ts index dba791068e..0363d3646f 100644 --- a/src/events/roleDelete.ts +++ b/src/events/roleDelete.ts @@ -19,8 +19,8 @@ export default { guildId: role.guild.id, eventType: EVENT_TYPES.ROLE_DELETE, data: { - title: 'Role Deleted', - headline: `**${role.name}** was deleted`, + title: 'Rolle gelöscht', + headline: `**${role.name}** wurde gelöscht`, lines, }, }); diff --git a/src/events/userUpdate.ts b/src/events/userUpdate.ts index deb44ba993..d1f3f09043 100644 --- a/src/events/userUpdate.ts +++ b/src/events/userUpdate.ts @@ -20,12 +20,12 @@ export default { if (usernameChanged) { fields.push({ - name: '🏷️ Old Username', + name: '🏷️ Alter Nutzername', value: oldUser.username, inline: true }); fields.push({ - name: '🏷️ New Username', + name: '🏷️ Neuer Nutzername', value: newUser.username, inline: true }); @@ -33,12 +33,12 @@ export default { if (discriminatorChanged) { fields.push({ - name: '🔢 Old Tag', + name: '🔢 Alter Tag', value: `#${oldUser.discriminator}`, inline: true }); fields.push({ - name: '🔢 New Tag', + name: '🔢 Neuer Tag', value: `#${newUser.discriminator}`, inline: true }); @@ -53,11 +53,11 @@ export default { guildId: guild.id, eventType: EVENT_TYPES.MEMBER_NAME_CHANGE, data: { - description: `${newUser.tag} updated their username`, + description: `${newUser.tag} hat den Nutzernamen geändert`, userId: newUser.id, fields: [ { - name: '👤 User', + name: '👤 Nutzer', value: `${newUser.tag} (${newUser.id})`, inline: true }, diff --git a/src/events/voiceStateUpdate.ts b/src/events/voiceStateUpdate.ts index 7a153897c7..6dceb04b11 100644 --- a/src/events/voiceStateUpdate.ts +++ b/src/events/voiceStateUpdate.ts @@ -22,7 +22,7 @@ const DEFAULT_VOICE_BITRATE = 64000; const MAX_VOICE_BITRATE = 384000; const MIN_VOICE_BITRATE = 8000; const MAX_CHANNEL_NAME_LENGTH = 100; -const FALLBACK_CHANNEL_NAME = 'Voice Room'; +const FALLBACK_CHANNEL_NAME = 'Sprachraum'; const MAX_TRACKED_COOLDOWNS = 10000; export default { @@ -163,7 +163,7 @@ if (now - lastCreation < VOICE_CREATE_COOLDOWN_MS) { } const channelOptions: Partial = config.channelOptions?.[triggerChannel.id] || {}; - const nameTemplate = channelOptions.nameTemplate || config.channelNameTemplate || "{username}'s Room"; + const nameTemplate = channelOptions.nameTemplate || config.channelNameTemplate || 'Raum von {username}'; let userLimit = channelOptions.userLimit ?? config.userLimit ?? 0; const bitrate = clampVoiceBitrate(channelOptions.bitrate ?? config.bitrate ?? DEFAULT_VOICE_BITRATE); @@ -237,7 +237,7 @@ userLimit: userLimit === 0 ? undefined : userLimit, try { await member.send({ - content: `❌ Failed to create your temporary voice channel. Please contact a server administrator.` + content: `❌ Dein temporärer Sprachkanal konnte nicht erstellt werden. Bitte wende dich an einen Server-Admin.` }); } catch (dmError) { logger.debug(`Unable to send temporary channel failure DM to user ${member.id}:`, dmError); @@ -249,7 +249,7 @@ userLimit: userLimit === 0 ? undefined : userLimit, try { await unregisterTemporaryChannel(client, guildId, channel.id); - await channel.delete('Temporary voice channel - empty'); + await channel.delete('Temporärer Sprachkanal – leer'); logger.info(`Deleted temporary voice channel ${channel.name} (${channel.id}) in guild ${channel.guild.name}`); @@ -278,7 +278,7 @@ userLimit: userLimit === 0 ? undefined : userLimit, userTag: newOwner.user.tag, displayName: newOwner.displayName, guildName: channel.guild.name, - channelName: channel.guild.channels.cache.get(tempChannelInfo.triggerChannelId)?.name || 'Voice Channel' + channelName: channel.guild.channels.cache.get(tempChannelInfo.triggerChannelId)?.name || 'Sprachkanal' })); await channel.setName(newChannelName); diff --git a/src/handlers/calculateModals.ts b/src/handlers/calculateModals.ts index 74b6a8573a..f30790dbbb 100644 --- a/src/handlers/calculateModals.ts +++ b/src/handlers/calculateModals.ts @@ -17,14 +17,14 @@ async function calculateModalHandler(interaction: ModalSubmitInteraction, client const contextKey = operandInput?.customId?.split(':')[1]; if (!contextKey) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Failed to retrieve calculation context.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Der Rechenkontext konnte nicht geladen werden.' }); } const { calculationContexts } = await import('../commands/Tools/modules/calculate.js'); const context = calculationContexts.get(contextKey); if (!context) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This calculation has expired. Please start a new calculation.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Diese Berechnung ist abgelaufen. Bitte starte eine neue.' }); } await interaction.deferReply({ ephemeral: false }); @@ -32,7 +32,7 @@ async function calculateModalHandler(interaction: ModalSubmitInteraction, client const operand = interaction.fields.getTextInputValue(operandInput.customId); if (!operand || isNaN(Number(operand))) { - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please provide a valid number.' }); + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Bitte gib eine gültige Zahl ein.' }); } const { expression, formattedResult, operator } = context; @@ -59,10 +59,10 @@ async function calculateModalHandler(interaction: ModalSubmitInteraction, client } const updatedEmbed = successEmbed( - "🧮 Calculation Result", - `**Expression:** \`${newExpression.replace(/`/g, "\`")}\`\n` + - `**Result:** \`${formattedNewResult}\`\n\n` + - `*Use the buttons in the channel message to perform more operations.*`, + "🧮 Rechenergebnis", + `**Ausdruck:** \`${newExpression.replace(/`/g, "\`")}\`\n` + + `**Ergebnis:** \`${formattedNewResult}\`\n\n` + + `*Nutze die Buttons in der Kanal-Nachricht für weitere Rechenschritte.*`, ); try { @@ -80,20 +80,20 @@ async function calculateModalHandler(interaction: ModalSubmitInteraction, client calculationContexts.delete(contextKey); await interaction.editReply({ - embeds: [successEmbed('✅ Calculated', `\`${newExpression}\` = \`${formattedNewResult}\``)], + embeds: [successEmbed('✅ Berechnet', `\`${newExpression}\` = \`${formattedNewResult}\``)], }); } catch (calcError) { logger.error('Calculate evaluation error:', calcError); - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Failed to evaluate the expression.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Der Ausdruck konnte nicht berechnet werden.' }); } } catch (error) { logger.error('Calculate modal handler error:', error); try { if (!interaction.replied && !interaction.deferred) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred processing your calculation.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Bei deiner Berechnung ist ein Fehler aufgetreten.' }); } else { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred processing your calculation.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Bei deiner Berechnung ist ein Fehler aufgetreten.' }); } } catch (err) { logger.error('Failed to send error message:', err); diff --git a/src/handlers/countdownButtons.ts b/src/handlers/countdownButtons.ts index 2c676d0de3..bc5f0153de 100644 --- a/src/handlers/countdownButtons.ts +++ b/src/handlers/countdownButtons.ts @@ -10,11 +10,11 @@ function createControlButtons(countdownId: string, isPaused = false): ActionRowB return new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`countdown_pause:${countdownId}`) - .setLabel(isPaused ? "▶️ Resume" : "⏸️ Pause") + .setLabel(isPaused ? "▶️ Fortsetzen" : "⏸️ Pausieren") .setStyle(ButtonStyle.Secondary), new ButtonBuilder() .setCustomId(`countdown_cancel:${countdownId}`) - .setLabel("❌ Cancel") + .setLabel("❌ Abbrechen") .setStyle(ButtonStyle.Danger), ); } @@ -54,7 +54,7 @@ function startCountdown(countdownId: string, countdownData: CountdownData, activ const embed = successEmbed( `⏱️ ${countdownData.title}`, - `Time remaining: **${formatTime(Math.ceil(remaining / 1000))}**`, + `Verbleibende Zeit: **${formatTime(Math.ceil(remaining / 1000))}**`, ); try { @@ -76,8 +76,8 @@ function startCountdown(countdownId: string, countdownData: CountdownData, activ clearInterval(countdownData.interval!); const finishedEmbed = successEmbed( - `⏱️ ${countdownData.title} (Finished!)`, - "⏰ Time's up!", + `⏱️ ${countdownData.title} (Beendet!)`, + "⏰ Die Zeit ist um!", ); await countdownData.message.edit({ @@ -111,14 +111,14 @@ async function countdownButtonHandler(interaction: ButtonInteraction, client: Ne const countdownData = activeCountdowns.get(countdownId); if (!countdownData) { return await interaction.reply({ - content: "This countdown has expired or was cancelled.", + content: "Dieser Countdown ist abgelaufen oder wurde abgebrochen.", flags: ["Ephemeral"], }); } if (!(interaction.member as GuildMember).permissions.has(PermissionFlagsBits.ManageMessages)) { return await interaction.reply({ - content: 'You need the "Manage Messages" permission to control countdowns.', + content: 'Du brauchst die Berechtigung „Nachrichten verwalten“, um Countdowns zu steuern.', flags: ["Ephemeral"], }); } @@ -137,7 +137,7 @@ async function countdownButtonHandler(interaction: ButtonInteraction, client: Ne }); await interaction.reply({ - content: "▶️ Countdown resumed!", + content: "▶️ Countdown fortgesetzt!", flags: ["Ephemeral"], }); } else { @@ -152,7 +152,7 @@ async function countdownButtonHandler(interaction: ButtonInteraction, client: Ne }); await interaction.reply({ - content: "⏸️ Countdown paused!", + content: "⏸️ Countdown pausiert!", flags: ["Ephemeral"], }); } @@ -162,8 +162,8 @@ async function countdownButtonHandler(interaction: ButtonInteraction, client: Ne clearInterval(countdownData.interval!); const embed = successEmbed( - `⏱️ ${countdownData.title} (Cancelled)`, - "The countdown was cancelled.", + `⏱️ ${countdownData.title} (Abgebrochen)`, + "Der Countdown wurde abgebrochen.", ); await countdownData.message.edit({ @@ -174,7 +174,7 @@ async function countdownButtonHandler(interaction: ButtonInteraction, client: Ne cleanupCountdown(countdownId, activeCountdowns); await interaction.reply({ - content: "❌ Countdown cancelled!", + content: "❌ Countdown abgebrochen!", flags: ["Ephemeral"], }); break; @@ -183,7 +183,7 @@ async function countdownButtonHandler(interaction: ButtonInteraction, client: Ne logger.error('Countdown button handler error:', error); try { if (!interaction.replied && !interaction.deferred) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred controlling the countdown.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Steuern des Countdowns ist ein Fehler aufgetreten.' }); } } catch (err) { logger.error('Failed to send error message:', err); diff --git a/src/handlers/counterButtons.ts b/src/handlers/counterButtons.ts index 132b7015ae..6a2f9282b4 100644 --- a/src/handlers/counterButtons.ts +++ b/src/handlers/counterButtons.ts @@ -20,25 +20,25 @@ export const counterDeleteActionHandler = { const [action, counterId, ownerId] = args; if (!interaction.inGuild()) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This action can only be used in a server.' }).catch(logger.error); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Diese Aktion kannst du nur auf einem Server nutzen.' }).catch(logger.error); return; } if (!action || !counterId) { - await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Counter delete action data is missing.' }).catch(logger.error); + await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Die Daten zum Löschen des Zählers fehlen.' }).catch(logger.error); return; } if (ownerId && interaction.user.id !== ownerId) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Only the user who initiated this deletion can use these buttons.' }).catch(logger.error); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Nur der Nutzer, der das Löschen gestartet hat, kann diese Buttons nutzen.' }).catch(logger.error); return; } if (action === 'cancel') { await interaction.editReply({ embeds: [createEmbed({ - title: '❌ Cancelled', - description: 'Counter deletion cancelled.', + title: '❌ Abgebrochen', + description: 'Löschen des Zählers abgebrochen.', color: 'error' })], components: [] @@ -47,7 +47,7 @@ export const counterDeleteActionHandler = { } if (action !== 'confirm') { - await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Unknown counter delete action.' }).catch(logger.error); + await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Unbekannte Aktion zum Löschen des Zählers.' }).catch(logger.error); return; } diff --git a/src/handlers/giveawayButtons.ts b/src/handlers/giveawayButtons.ts index 862b4f3f25..2dce6fac4f 100644 --- a/src/handlers/giveawayButtons.ts +++ b/src/handlers/giveawayButtons.ts @@ -25,7 +25,7 @@ export const giveawayJoinHandler = { try { if (isUserRateLimited(interaction.user.id, interaction.message.id)) { - return replyUserError(interaction, { type: ErrorTypes.RATE_LIMIT, message: 'Please wait a moment before interacting with this giveaway again.' }); + return replyUserError(interaction, { type: ErrorTypes.RATE_LIMIT, message: 'Warte bitte einen Moment, bevor du erneut mit diesem Giveaway interagierst.' }); } await recordUserInteraction(interaction.user.id, interaction.message.id); @@ -39,7 +39,7 @@ export const giveawayJoinHandler = { throw new NexoBotError( 'Giveaway not found in database', ErrorTypes.VALIDATION, - 'This giveaway is no longer active.', + 'Dieses Giveaway ist nicht mehr aktiv.', { messageId: interaction.message.id, guildId: interaction.guildId } ); } @@ -48,14 +48,14 @@ export const giveawayJoinHandler = { const endedByFlag = giveaway.ended || giveaway.isEnded; if (endedByTime || endedByFlag) { - return replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This giveaway has already ended.' }); + return replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Dieses Giveaway ist bereits beendet.' }); } const participants = giveaway.participants || []; const userId = interaction.user.id; if (participants.includes(userId)) { - return replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You have already entered this giveaway! 🎉' }); + return replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Du nimmst bereits an diesem Giveaway teil! 🎉' }); } participants.push(userId); @@ -76,8 +76,8 @@ export const giveawayJoinHandler = { await interaction.reply({ embeds: [ successEmbed( - 'Success! You have entered the giveaway! 🎉', - `Good luck! There are now ${participants.length} entry/entries.` + 'Geschafft! Du nimmst jetzt am Giveaway teil! 🎉', + `Viel Glück! Es gibt jetzt ${participants.length} Teilnahme(n).` ) ], flags: MessageFlags.Ephemeral @@ -103,13 +103,13 @@ export const giveawayEndHandler = { throw new NexoBotError( 'Button used outside guild', ErrorTypes.VALIDATION, - 'This button can only be used in a server.', + 'Diesen Button kannst du nur auf einem Server nutzen.', { userId: interaction.user.id } ); } if (!(interaction.member as GuildMember).permissions.has(PermissionFlagsBits.ManageGuild)) { - return replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the \'Manage Server\' permission to end a giveaway.' }); + return replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Du brauchst die Berechtigung „Server verwalten“, um ein Giveaway zu beenden.' }); } const guildGiveaways = await getGuildGiveaways(client, interaction.guildId); @@ -119,7 +119,7 @@ export const giveawayEndHandler = { throw new NexoBotError( 'Giveaway not found in database', ErrorTypes.VALIDATION, - 'This giveaway is no longer active.', + 'Dieses Giveaway ist nicht mehr aktiv.', { messageId: interaction.message.id, guildId: interaction.guildId } ); } @@ -128,7 +128,7 @@ export const giveawayEndHandler = { throw new NexoBotError( 'Giveaway already ended', ErrorTypes.VALIDATION, - 'This giveaway has already ended.', + 'Dieses Giveaway ist bereits beendet.', { messageId: interaction.message.id } ); } @@ -150,7 +150,7 @@ export const giveawayEndHandler = { const updatedRow = createGiveawayButtons(true); await interaction.message.edit({ - content: '🎉 **GIVEAWAY ENDED** 🎉', + content: '🎉 **GIVEAWAY BEENDET** 🎉', embeds: [updatedEmbed], components: [updatedRow] }); @@ -161,24 +161,24 @@ export const giveawayEndHandler = { guildId: interaction.guildId, eventType: EVENT_TYPES.GIVEAWAY_WINNER, data: { - description: `Giveaway ended with ${winners.length} winner(s)`, + description: `Giveaway mit ${winners.length} Gewinner(n) beendet`, channelId: interaction.channelId, userId: interaction.user.id, fields: [ { - name: '🎁 Prize', - value: giveaway.prize || 'Mystery Prize!', + name: '🎁 Preis', + value: giveaway.prize || 'Überraschungspreis!', inline: true }, { - name: '🏆 Winners', + name: '🏆 Gewinner', value: winners.length > 0 ? winners.map(id => `<@${id}>`).join(', ') - : 'No valid entries', + : 'Keine gültigen Teilnahmen', inline: false }, { - name: '👥 Total Entries', + name: '👥 Teilnahmen gesamt', value: participants.length.toString(), inline: true } @@ -192,8 +192,8 @@ export const giveawayEndHandler = { await interaction.reply({ embeds: [ successEmbed( - `Giveaway Ended ✅`, - `The giveaway has been ended and ${winners.length} winner(s) have been selected!` + `Giveaway beendet ✅`, + `Das Giveaway wurde beendet und ${winners.length} Gewinner ausgelost!` ) ], flags: MessageFlags.Ephemeral @@ -219,13 +219,13 @@ export const giveawayRerollHandler = { throw new NexoBotError( 'Button used outside guild', ErrorTypes.VALIDATION, - 'This button can only be used in a server.', + 'Diesen Button kannst du nur auf einem Server nutzen.', { userId: interaction.user.id } ); } if (!(interaction.member as GuildMember).permissions.has(PermissionFlagsBits.ManageGuild)) { - return replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the \'Manage Server\' permission to reroll a giveaway.' }); + return replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Du brauchst die Berechtigung „Server verwalten“, um ein Giveaway neu auszulosen.' }); } const guildGiveaways = await getGuildGiveaways(client, interaction.guildId); @@ -235,7 +235,7 @@ export const giveawayRerollHandler = { throw new NexoBotError( 'Giveaway not found in database', ErrorTypes.VALIDATION, - 'This giveaway is no longer active.', + 'Dieses Giveaway ist nicht mehr aktiv.', { messageId: interaction.message.id, guildId: interaction.guildId } ); } @@ -244,7 +244,7 @@ export const giveawayRerollHandler = { throw new NexoBotError( 'Giveaway still active', ErrorTypes.VALIDATION, - 'This giveaway has not ended yet. Please end it first.', + 'Dieses Giveaway läuft noch. Bitte beende es zuerst.', { messageId: interaction.message.id } ); } @@ -255,7 +255,7 @@ export const giveawayRerollHandler = { throw new NexoBotError( 'No participants to reroll', ErrorTypes.VALIDATION, - 'There are no entries to reroll from.', + 'Es gibt keine Teilnahmen für eine neue Auslosung.', { messageId: interaction.message.id } ); } @@ -274,7 +274,7 @@ export const giveawayRerollHandler = { const updatedRow = createGiveawayButtons(true); await interaction.message.edit({ - content: '🔄 **GIVEAWAY REROLLED** 🔄', + content: '🔄 **GIVEAWAY NEU AUSGELOST** 🔄', embeds: [updatedEmbed], components: [updatedRow] }); @@ -285,22 +285,22 @@ export const giveawayRerollHandler = { guildId: interaction.guildId, eventType: EVENT_TYPES.GIVEAWAY_REROLL, data: { - description: `Giveaway rerolled`, + description: `Giveaway neu ausgelost`, channelId: interaction.channelId, userId: interaction.user.id, fields: [ { - name: '🎁 Prize', - value: giveaway.prize || 'Mystery Prize!', + name: '🎁 Preis', + value: giveaway.prize || 'Überraschungspreis!', inline: true }, { - name: '🏆 New Winners', + name: '🏆 Neue Gewinner', value: newWinners.map(id => `<@${id}>`).join(', '), inline: false }, { - name: '👥 Total Entries', + name: '👥 Teilnahmen gesamt', value: participants.length.toString(), inline: true } @@ -314,8 +314,8 @@ export const giveawayRerollHandler = { await interaction.reply({ embeds: [ successEmbed( - 'Giveaway Rerolled ✅', - `New winner(s) have been selected!` + 'Giveaway neu ausgelost ✅', + `Neue Gewinner wurden ausgelost!` ) ], flags: MessageFlags.Ephemeral @@ -340,7 +340,7 @@ export const giveawayViewHandler = { throw new NexoBotError( 'Button used outside guild', ErrorTypes.VALIDATION, - 'This button can only be used in a server.', + 'Diesen Button kannst du nur auf einem Server nutzen.', { userId: interaction.user.id } ); } @@ -352,24 +352,24 @@ export const giveawayViewHandler = { throw new NexoBotError( 'Giveaway not found in database', ErrorTypes.VALIDATION, - 'This giveaway could not be found.', + 'Dieses Giveaway wurde nicht gefunden.', { messageId: interaction.message.id, guildId: interaction.guildId } ); } if (!giveaway.ended && !giveaway.isEnded && !isGiveawayEnded(giveaway)) { - return replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This giveaway has not ended yet, so winners are not available.' }); + return replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Dieses Giveaway läuft noch, daher gibt es noch keine Gewinner.' }); } const winnerIds = Array.isArray(giveaway.winnerIds) ? giveaway.winnerIds : []; const winnerMentions = winnerIds.length > 0 ? winnerIds.map(id => `<@${id}>`).join(', ') - : 'No valid winners were selected for this giveaway.'; + : 'Für dieses Giveaway wurden keine gültigen Gewinner ausgelost.'; await interaction.reply({ embeds: [ successEmbed( - `Winners for ${giveaway.prize || 'this giveaway'} 🎉`, + `Gewinner für ${giveaway.prize || 'dieses Giveaway'} 🎉`, winnerMentions ) ], diff --git a/src/handlers/help/helpButtons.ts b/src/handlers/help/helpButtons.ts index e5cfce83a5..81fa865c41 100644 --- a/src/handlers/help/helpButtons.ts +++ b/src/handlers/help/helpButtons.ts @@ -44,25 +44,25 @@ export const helpBugReportButton = { name: BUG_REPORT_BUTTON_ID, async execute(interaction, client) { const githubButton = new ButtonBuilder() - .setLabel('🐛 Report Bug on GitHub') + .setLabel('🐛 Bug auf GitHub melden') .setStyle(ButtonStyle.Link) .setURL('https://github.com/Robertwlt03/NexoBot/issues'); const bugRow = new ActionRowBuilder().addComponents(githubButton); const bugReportEmbed = createEmbed({ - title: '🐛 Bug Report', - description: 'Found a bug? Please report it on our GitHub Issues page!\n\n' + - '**When reporting a bug, please include:**\n' + - '• 📝 Detailed description of the issue\n' + - '• 📋 Steps to reproduce the problem\n' + - '• 📸 Screenshots if applicable\n' + - '• 💻 Your bot version and environment\n\n' + - 'This helps us fix issues faster and more effectively!', + title: '🐛 Bug melden', + description: 'Du hast einen Bug gefunden? Melde ihn bitte auf unserer GitHub-Issues-Seite!\n\n' + + '**Bitte gib beim Melden an:**\n' + + '• 📝 Genaue Beschreibung des Problems\n' + + '• 📋 Schritte, um das Problem nachzustellen\n' + + '• 📸 Screenshots, falls vorhanden\n' + + '• 💻 Deine Bot-Version und Umgebung\n\n' + + 'So können wir Fehler schneller und gezielter beheben!', color: 'error' }); bugReportEmbed.setFooter({ - text: 'Nexo Bug Reporting System', + text: 'Nexo Bug-Meldesystem', iconURL: client.user.displayAvatarURL() }); bugReportEmbed.setTimestamp(); @@ -80,7 +80,7 @@ function getPaginationInfo(components: readonly TopLevelComponent[] | undefined) for (const component of ('components' in row && row.components) || []) { if ('customId' in component && component.customId === `${PAGINATION_PREFIX}_page`) { const label = ('label' in component && component.label) || ''; - const match = label.match(/Page\s+(\d+)\s+of\s+(\d+)/i); + const match = label.match(/(?:Page|Seite)\s+(\d+)\s+(?:of|von)\s+(\d+)/i); if (match) { return { currentPage: Number(match[1]), diff --git a/src/handlers/help/helpSelectMenus.ts b/src/handlers/help/helpSelectMenus.ts index af65ba47f1..64b7885895 100644 --- a/src/handlers/help/helpSelectMenus.ts +++ b/src/handlers/help/helpSelectMenus.ts @@ -9,6 +9,7 @@ import type { ApplicationCommand, ButtonBuilder, EmbedBuilder } from 'discord.js import type { NexoClient, SelectMenuHandler } from '../../types/index.js'; import { logger } from '../../utils/logger.js'; import { resolveLegacyName } from '../../config/commands/legacyCommandMap.js'; +import { formatCategoryName, getCategoryIcon } from '../../config/commands/commandCategories.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; const __filename = fileURLToPath(import.meta.url); @@ -18,7 +19,7 @@ const BACK_BUTTON_ID = "help-back-to-main"; const ALL_COMMANDS_ID = "help-all-commands"; const PAGINATION_PREFIX = "help-page"; const CATEGORY_SELECT_ID = "help-category-select"; -const FOOTER_TEXT = "Made with ❤️"; +const FOOTER_TEXT = "Mit ❤️ gemacht"; const SUBCOMMAND_TYPE = 1; const SUBCOMMAND_GROUP_TYPE = 2; @@ -51,42 +52,14 @@ function hasToJSON(value: unknown): value is { toJSON(): unknown } { return typeof value === 'object' && value !== null && 'toJSON' in value && typeof value.toJSON === 'function'; } -const CATEGORY_ICONS: Record = { - Core: "ℹ️", - Moderation: "🛡️", - Economy: "💰", - Music: "🎵", - Fun: "🎮", - Leveling: "📊", - Utility: "🔧", - Ticket: "🎫", - Welcome: "👋", - Giveaway: "🎉", - Counter: "🔢", - Tools: "🛠️", - Search: "🔍", - "Reaction Roles": "🎭", - Community: "👥", - Birthday: "🎂", - "Join To Create": "🔌", - Verification: "✅", - Config: "⚙️", -}; - -function formatCategoryName(rawCategory: string): string { - return rawCategory - .replace(/_/g, ' ') - .replace(/([a-z])([A-Z])/g, '$1 $2') - .replace(/\b\w/g, (char) => char.toUpperCase()); -} -/** Appends the pre-consolidation name, e.g. "Check your balance (formerly /balance)". */ +/** Appends the pre-consolidation name, e.g. "Zeigt deinen Kontostand (früher /balance)". */ function withLegacyHint(description: string, command: string, group: string | null, subcommand: string): string { const legacyName = resolveLegacyName(command, group, subcommand); if (!legacyName || legacyName === command) { return description; } - return `${description} (formerly /${group ? `${legacyName} ${subcommand}` : legacyName})`; + return `${description} (früher /${group ? `${legacyName} ${subcommand}` : legacyName})`; } function buildHelpEntries(command: unknown, category: string): HelpEntry[] { @@ -96,7 +69,7 @@ function buildHelpEntries(command: unknown, category: string): HelpEntry[] { } const baseName = commandData.name; - const baseDescription = commandData.description || "No description"; + const baseDescription = commandData.description || "Keine Beschreibung"; const options = commandData.options || []; const entries: HelpEntry[] = []; @@ -165,7 +138,7 @@ function normalizeCommandData(command: unknown): HelpCommandData | null { async function createCategoryCommandsMenu(category: string, client: NexoClient): Promise { const categoryName = formatCategoryName(category); - const icon = CATEGORY_ICONS[categoryName] || "🔍"; + const icon = getCategoryIcon(category); const categoryCommands: HelpEntry[] = []; @@ -213,10 +186,10 @@ async function createCategoryCommandsMenu(category: string, client: NexoClient): } const embed = createEmbed({ - title: `${icon} ${categoryName} Commands`, + title: `${icon} ${categoryName}: Commands`, description: categoryCommands.length > 0 - ? `Click any command mention below to use it.` - : `No commands found in the **${categoryName}** category.` + ? `Klick auf einen Command, um ihn zu nutzen.` + : `In der Kategorie **${categoryName}** wurden keine Commands gefunden.` }); if (categoryCommands.length > 0) { @@ -254,7 +227,7 @@ async function createCategoryCommandsMenu(category: string, client: NexoClient): chunks.forEach((chunk, index) => { embed.addFields({ - name: `Commands (Part ${index + 1})`, + name: `Commands (Teil ${index + 1})`, value: chunk, inline: false, }); @@ -267,7 +240,7 @@ async function createCategoryCommandsMenu(category: string, client: NexoClient): const backButton = createButton( BACK_BUTTON_ID, - "Back", + "Zurück", "primary", "⬅️", false, @@ -353,8 +326,8 @@ export async function createAllCommandsMenu( const pageCommands = allCommands.slice(startIndex, endIndex); const embed = createEmbed({ - title: "📋 All Commands", - description: `Browse every available command in one list. Use the page buttons below to move through the full set.` + title: "📋 Alle Commands", + description: `Alle verfügbaren Commands in einer Liste. Mit den Buttons unten blätterst du durch die Seiten.` }); embed.setFooter({ text: FOOTER_TEXT }); @@ -380,7 +353,7 @@ export async function createAllCommandsMenu( if (!chunk) continue; embed.addFields({ - name: i === 0 ? `Commands (Page ${page})` : "Commands (cont.)", + name: i === 0 ? `Commands (Seite ${page})` : "Commands (Forts.)", value: chunk, inline: columnCount > 1, }); @@ -400,7 +373,7 @@ export async function createAllCommandsMenu( const backButton = createButton( BACK_BUTTON_ID, - "Back", + "Zurück", "primary", "⬅️", false, diff --git a/src/handlers/interactionHandlers/reactionRolesSelectMenu.ts b/src/handlers/interactionHandlers/reactionRolesSelectMenu.ts index 7bab6873cf..9582fac993 100644 --- a/src/handlers/interactionHandlers/reactionRolesSelectMenu.ts +++ b/src/handlers/interactionHandlers/reactionRolesSelectMenu.ts @@ -17,7 +17,7 @@ export async function handleReactionRolesSelectMenu(interaction: StringSelectMen throw createError( 'Reaction role interaction used outside a guild context', ErrorTypes.VALIDATION, - 'This reaction role menu can only be used inside a server.', + 'Dieses Reaktionsrollen-Menü kannst du nur auf einem Server nutzen.', { userId: interaction.user.id } ); } @@ -31,7 +31,7 @@ export async function handleReactionRolesSelectMenu(interaction: StringSelectMen return interaction.editReply({ embeds: [ new EmbedBuilder() - .setDescription('❌ This reaction role message is no longer active.') + .setDescription('❌ Diese Reaktionsrollen-Nachricht ist nicht mehr aktiv.') .setColor(getColor('error')) ] }); @@ -46,7 +46,7 @@ export async function handleReactionRolesSelectMenu(interaction: StringSelectMen throw createError( 'Unable to fetch bot member for permission validation', ErrorTypes.PERMISSION, - 'I could not verify my server permissions. Please try again.', + 'Ich konnte meine Server-Berechtigungen nicht prüfen. Bitte versuch es erneut.', { guildId: interaction.guildId } ); } @@ -55,7 +55,7 @@ export async function handleReactionRolesSelectMenu(interaction: StringSelectMen throw createError( 'Bot missing ManageRoles permission', ErrorTypes.PERMISSION, - 'I do not have permission to manage roles in this server.', + 'Mir fehlt auf diesem Server die Berechtigung „Rollen verwalten“.', { guildId: interaction.guildId } ); } @@ -137,22 +137,22 @@ export async function handleReactionRolesSelectMenu(interaction: StringSelectMen } } - let description = '🎭 **Roles updated successfully!**\n\n'; + let description = '🎭 **Rollen erfolgreich aktualisiert!**\n\n'; if (addedRoles.length > 0) { - description += `✅ **Added:** ${addedRoles.map(name => `**${name}**`).join(', ')}\n`; + description += `✅ **Hinzugefügt:** ${addedRoles.map(name => `**${name}**`).join(', ')}\n`; } if (removedRoles.length > 0) { - description += `❌ **Removed:** ${removedRoles.map(name => `**${name}**`).join(', ')}\n`; + description += `❌ **Entfernt:** ${removedRoles.map(name => `**${name}**`).join(', ')}\n`; } if (addedRoles.length === 0 && removedRoles.length === 0) { - description += 'No changes were made to your roles.'; + description += 'An deinen Rollen wurde nichts geändert.'; } if (skippedRoles.length > 0) { - description += `\n⚠️ **Skipped:** ${skippedRoles.length} role${skippedRoles.length !== 1 ? 's' : ''} (permission issues)`; + description += `\n⚠️ **Übersprungen:** ${skippedRoles.length} Rolle${skippedRoles.length !== 1 ? 'n' : ''} (fehlende Berechtigungen)`; } const responseEmbed = new EmbedBuilder() @@ -169,22 +169,22 @@ export async function handleReactionRolesSelectMenu(interaction: StringSelectMen guildId: interaction.guildId, eventType: EVENT_TYPES.REACTION_ROLE_UPDATE, data: { - description: `Reaction roles updated for ${member.user.tag}`, + description: `Reaktionsrollen für ${member.user.tag} aktualisiert`, userId: member.user.id, channelId: interaction.channelId, fields: [ { - name: '👤 Member', + name: '👤 Mitglied', value: `${member.user.tag} (${member.user.id})`, inline: false }, ...(addedRoles.length > 0 ? [{ - name: '✅ Roles Added', + name: '✅ Rollen hinzugefügt', value: addedRoles.join(', '), inline: false }] : []), ...(removedRoles.length > 0 ? [{ - name: '❌ Roles Removed', + name: '❌ Rollen entfernt', value: removedRoles.join(', '), inline: false }] : []) diff --git a/src/handlers/loggingButtons.ts b/src/handlers/loggingButtons.ts index 6d79620d29..97ce4e60cc 100644 --- a/src/handlers/loggingButtons.ts +++ b/src/handlers/loggingButtons.ts @@ -41,9 +41,9 @@ import { const LOGGING_CATEGORIES = [...new Set(Object.values(EVENT_TYPES).map((eventType) => eventType.split('.')[0]))]; const DESTINATION_LABELS: Record = { - audit: 'Audit Log', - applications: 'Applications', - reports: 'Reports', + audit: 'Audit-Log', + applications: 'Bewerbungen', + reports: 'Meldungen', }; export default { @@ -59,7 +59,7 @@ export default { try { if (!(interaction.member as GuildMember).permissions.has(PermissionFlagsBits.ManageGuild)) { return interaction.reply({ - content: '❌ You need **Manage Server** permissions to use this.', + content: '❌ Dafür brauchst du die Berechtigung **Server verwalten**.', ephemeral: true, }); } @@ -116,7 +116,7 @@ async function handleBackToMain(interaction: ButtonInteraction) { async function handleToggle(interaction: ButtonInteraction) { const eventType = interaction.customId.replace('log_dash_toggle:', ''); if (!eventType) { - return interaction.reply({ content: '❌ Invalid event type.', ephemeral: true }); + return interaction.reply({ content: '❌ Ungültiger Event-Typ.', ephemeral: true }); } const status = await getLoggingStatus(interaction.client, interaction.guildId!); @@ -146,7 +146,7 @@ async function handleToggle(interaction: ButtonInteraction) { async function handleAddFilterModal(interaction: ButtonInteraction) { const filterType = interaction.customId.replace('log_dash_add_filter:', ''); if (filterType !== 'user' && filterType !== 'channel') { - return interaction.reply({ content: '❌ Invalid filter type.', ephemeral: true }); + return interaction.reply({ content: '❌ Ungültiger Filtertyp.', ephemeral: true }); } const modalCustomId = `log_dash_filter_modal:add:${filterType}`; @@ -155,35 +155,35 @@ async function handleAddFilterModal(interaction: ButtonInteraction) { if (filterType === 'user') { const userSelect = new UserSelectMenuBuilder() .setCustomId('ignore_user') - .setPlaceholder('Select a user to ignore…') + .setPlaceholder('Wähl einen Nutzer zum Ignorieren…') .setMinValues(1) .setMaxValues(1); const userLabel = new LabelBuilder() - .setLabel('User to Ignore') - .setDescription('Choose a user whose actions should not be logged') + .setLabel('Zu ignorierender Nutzer') + .setDescription('Wähl einen Nutzer, dessen Aktionen nicht geloggt werden sollen') .setUserSelectMenuComponent(userSelect); modal = new ModalBuilder() .setCustomId(modalCustomId) - .setTitle('Add User Filter') + .setTitle('Nutzer-Filter hinzufügen') .addLabelComponents(userLabel); } else { const channelSelect = new ChannelSelectMenuBuilder() .setCustomId('ignore_channel') - .setPlaceholder('Select a channel to ignore…') + .setPlaceholder('Wähl einen Kanal zum Ignorieren…') .setMinValues(1) .setMaxValues(1) .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement, ChannelType.GuildVoice); const channelLabel = new LabelBuilder() - .setLabel('Channel to Ignore') - .setDescription('Choose a channel whose events should not be logged') + .setLabel('Zu ignorierender Kanal') + .setDescription('Wähl einen Kanal, dessen Events nicht geloggt werden sollen') .setChannelSelectMenuComponent(channelSelect); modal = new ModalBuilder() .setCustomId(modalCustomId) - .setTitle('Add Channel Filter') + .setTitle('Kanal-Filter hinzufügen') .addLabelComponents(channelLabel); } @@ -205,14 +205,14 @@ async function handleAddFilterModal(interaction: ButtonInteraction) { if (!id) { return replyUserError(modalSubmission, { type: ErrorTypes.VALIDATION, - message: `Please select a ${filterType} to ignore.`, + message: `Bitte wähl ${filterType === 'user' ? 'einen Nutzer' : 'einen Kanal'} zum Ignorieren aus.`, }); } await updateIgnoreList(interaction.client, interaction.guildId!, { action: 'add', type: filterType, id }); await modalSubmission.reply({ - embeds: [successEmbed('Filter Added', `${filterType === 'user' ? 'User' : 'Channel'} \`${id}\` will be ignored in audit logs.`)], + embeds: [successEmbed('Filter hinzugefügt', `${filterType === 'user' ? 'Nutzer' : 'Kanal'} \`${id}\` wird in den Audit-Logs ignoriert.`)], flags: MessageFlags.Ephemeral, }); @@ -235,8 +235,8 @@ async function handleRemoveFilterModal(interaction: ButtonInteraction) { for (const userId of ignore.users || []) { options.push( new StringSelectMenuOptionBuilder() - .setLabel(`User ${userId}`) - .setDescription('Remove this user from the ignore list') + .setLabel(`Nutzer ${userId}`) + .setDescription('Diesen Nutzer von der Ignorierliste entfernen') .setValue(`user:${userId}`), ); } @@ -244,8 +244,8 @@ async function handleRemoveFilterModal(interaction: ButtonInteraction) { for (const channelId of ignore.channels || []) { options.push( new StringSelectMenuOptionBuilder() - .setLabel(`Channel ${channelId}`) - .setDescription('Remove this channel from the ignore list') + .setLabel(`Kanal ${channelId}`) + .setDescription('Diesen Kanal von der Ignorierliste entfernen') .setValue(`channel:${channelId}`), ); } @@ -253,7 +253,7 @@ async function handleRemoveFilterModal(interaction: ButtonInteraction) { if (options.length === 0) { return replyUserError(interaction, { type: ErrorTypes.USER_INPUT, - message: 'There are no ignore filters to remove.', + message: 'Es gibt keine Ignorierfilter zum Entfernen.', }); } @@ -261,19 +261,19 @@ async function handleRemoveFilterModal(interaction: ButtonInteraction) { const filterSelect = new StringSelectMenuBuilder() .setCustomId('filter_entry') - .setPlaceholder('Select a filter to remove…') + .setPlaceholder('Wähl einen Filter zum Entfernen…') .setMinValues(1) .setMaxValues(1) .addOptions(options.slice(0, 25)); const filterLabel = new LabelBuilder() - .setLabel('Filter to Remove') - .setDescription('Choose a user or channel to un-ignore') + .setLabel('Zu entfernender Filter') + .setDescription('Wähl einen Nutzer oder Kanal, der nicht mehr ignoriert werden soll') .setStringSelectMenuComponent(filterSelect); const modal = new ModalBuilder() .setCustomId(modalCustomId) - .setTitle('Remove Ignore Filter') + .setTitle('Ignorierfilter entfernen') .addLabelComponents(filterLabel); await interaction.showModal(modal); @@ -288,7 +288,7 @@ async function handleRemoveFilterModal(interaction: ButtonInteraction) { if (!entry) { return replyUserError(modalSubmission, { type: ErrorTypes.VALIDATION, - message: 'Please select a filter to remove.', + message: 'Bitte wähl einen Filter zum Entfernen aus.', }); } @@ -296,7 +296,7 @@ async function handleRemoveFilterModal(interaction: ButtonInteraction) { await updateIgnoreList(interaction.client, interaction.guildId!, { action: 'remove', type, id }); await modalSubmission.reply({ - embeds: [successEmbed('Filter Removed', `Removed ${type} \`${id}\` from the ignore list.`)], + embeds: [successEmbed('Filter entfernt', `${type === 'user' ? 'Nutzer' : 'Kanal'} \`${id}\` wurde von der Ignorierliste entfernt.`)], flags: MessageFlags.Ephemeral, }); @@ -317,20 +317,20 @@ async function showChannelModal(interaction: StringSelectMenuInteraction, destin const channelSelect = new ChannelSelectMenuBuilder() .setCustomId('log_channel') - .setPlaceholder('Select a text channel…') + .setPlaceholder('Wähl einen Textkanal…') .setMinValues(1) .setMaxValues(1) .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement) .setRequired(true); const channelLabel = new LabelBuilder() - .setLabel(`${label} Channel`) - .setDescription(`Channel where ${label.toLowerCase()} logs will be sent`) + .setLabel(`${label}-Kanal`) + .setDescription(`Kanal, in den die ${label}-Logs gesendet werden`) .setChannelSelectMenuComponent(channelSelect); const modal = new ModalBuilder() .setCustomId(modalCustomId) - .setTitle(`Set ${label} Channel`) + .setTitle(`${label}-Kanal festlegen`) .addLabelComponents(channelLabel); await interaction.showModal(modal); @@ -347,7 +347,7 @@ async function showChannelModal(interaction: StringSelectMenuInteraction, destin if (!channel) { return modalSubmission.reply({ - content: '❌ That channel could not be found.', + content: '❌ Dieser Kanal wurde nicht gefunden.', flags: MessageFlags.Ephemeral, }); } @@ -355,7 +355,7 @@ async function showChannelModal(interaction: StringSelectMenuInteraction, destin const botPerms = channel.permissionsFor(interaction.guild!.members.me!); if (!botPerms?.has(['ViewChannel', 'SendMessages', 'EmbedLinks'])) { return modalSubmission.reply({ - content: '❌ I need View Channel, Send Messages, and Embed Links in that channel.', + content: '❌ Ich brauche in diesem Kanal die Berechtigungen „Kanal ansehen“, „Nachrichten senden“ und „Links einbetten“.', flags: MessageFlags.Ephemeral, }); } @@ -363,7 +363,7 @@ async function showChannelModal(interaction: StringSelectMenuInteraction, destin await setLogChannel(interaction.client, interaction.guildId!, destination, channel.id); await modalSubmission.reply({ - embeds: [successEmbed('Channel Updated', `**${label}** logs will be sent to ${channel}.`)], + embeds: [successEmbed('Kanal aktualisiert', `**${label}**-Logs werden jetzt an ${channel} gesendet.`)], flags: MessageFlags.Ephemeral, }); @@ -383,7 +383,7 @@ async function showChannelModal(interaction: StringSelectMenuInteraction, destin export async function handleLoggingMenuSelect(interaction: StringSelectMenuInteraction): Promise { if (!(interaction.member as GuildMember).permissions.has(PermissionFlagsBits.ManageGuild)) { return interaction.reply({ - content: '❌ You need **Manage Server** permissions to use this.', + content: '❌ Dafür brauchst du die Berechtigung **Server verwalten**.', ephemeral: true, }); } @@ -416,5 +416,5 @@ export async function handleLoggingMenuSelect(interaction: StringSelectMenuInter return interaction.update({ embeds: [embed], components, content: null }); } - return interaction.reply({ content: '❌ Unknown option.', ephemeral: true }); + return interaction.reply({ content: '❌ Unbekannte Option.', ephemeral: true }); } diff --git a/src/handlers/musicButtons.ts b/src/handlers/musicButtons.ts index c1187c5d2d..bd7bc1d4e2 100644 --- a/src/handlers/musicButtons.ts +++ b/src/handlers/musicButtons.ts @@ -24,7 +24,7 @@ async function handleMusicButton(interaction: ButtonInteraction, client: NexoCli if (customId === MUSIC_BUTTON_IDS.QUEUE) { if (!player?.current) { - return replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Nothing is playing right now.' }); + return replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Gerade läuft nichts.' }); } if (!canControlMusic(interaction.member as GuildMember, player)) { return replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: VOICE_CHANNEL_DENIAL }); @@ -47,7 +47,7 @@ async function handleMusicButton(interaction: ButtonInteraction, client: NexoCli if (queuePaginationIds.includes(customId)) { if (!player?.current) { - return replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Nothing is playing right now.' }); + return replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Gerade läuft nichts.' }); } if (!canControlMusic(interaction.member as GuildMember, player)) { return replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: VOICE_CHANNEL_DENIAL }); @@ -84,7 +84,7 @@ async function handleMusicButton(interaction: ButtonInteraction, client: NexoCli } if (!player) { - return replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'No music is playing. Use `/music play` first.' }); + return replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Es läuft keine Musik. Nutze zuerst `/music play`.' }); } if (!canControlMusic(interaction.member as GuildMember, player)) { @@ -151,7 +151,7 @@ export const musicButtonHandler = { async execute(interaction: ButtonInteraction, client: NexoClient): Promise { try { if (!client.riffy) { - return replyUserError(interaction, { type: ErrorTypes.CONFIGURATION, message: 'Music is unavailable — Lavalink is not configured.' }); + return replyUserError(interaction, { type: ErrorTypes.CONFIGURATION, message: 'Musik ist nicht verfügbar – Lavalink ist nicht konfiguriert.' }); } await handleMusicButton(interaction, client); } catch (error) { diff --git a/src/handlers/ticketButtons.ts b/src/handlers/ticketButtons.ts index d3f9ffc091..0207d58c3e 100644 --- a/src/handlers/ticketButtons.ts +++ b/src/handlers/ticketButtons.ts @@ -35,7 +35,7 @@ async function ensureGuildContext(interaction: TicketInteraction): Promise= maxTicketsPerUser) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `You have reached the maximum number of open tickets (${maxTicketsPerUser}).\n\nPlease close your existing tickets before creating a new one.\n\n**Current Tickets:** ${currentTicketCount}/${maxTicketsPerUser}` }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `Du hast die maximale Anzahl offener Tickets erreicht (${maxTicketsPerUser}).\n\nBitte schließ deine bestehenden Tickets, bevor du ein neues erstellst.\n\n**Aktuelle Tickets:** ${currentTicketCount}/${maxTicketsPerUser}` }); } const modal = new ModalBuilder() .setCustomId('create_ticket_modal') - .setTitle('Create a Ticket'); + .setTitle('Ticket erstellen'); const reasonInput = new TextInputBuilder() .setCustomId('reason') - .setLabel('Why are you creating this ticket?') + .setLabel('Warum erstellst du dieses Ticket?') .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('Describe your issue...') + .setPlaceholder('Beschreib dein Anliegen...') .setRequired(true) .setMaxLength(1000); @@ -167,7 +167,7 @@ const createTicketHandler = { } catch (error) { logger.error('Error creating ticket modal:', error); if (!interaction.replied && !interaction.deferred) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Could not open ticket creation form.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Das Formular zum Erstellen des Tickets konnte nicht geöffnet werden.' }); } } } @@ -194,8 +194,8 @@ const createTicketModalHandler = { ); await interaction.editReply({ embeds: [successEmbed( - 'Ticket Created', - `Your ticket has been created in ${channel}!` + 'Ticket erstellt', + `Dein Ticket wurde in ${channel} erstellt!` )] }); } catch (error) { @@ -210,17 +210,17 @@ const closeTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - await assertTicketPermission(interaction, client, 'close this ticket', { allowTicketCreator: true }, 2000); + await assertTicketPermission(interaction, client, 'dieses Ticket schließen', { allowTicketCreator: true }, 2000); const modal = new ModalBuilder() .setCustomId('ticket_close_modal') - .setTitle('Close Ticket'); + .setTitle('Ticket schließen'); const reasonInput = new TextInputBuilder() .setCustomId('reason') - .setLabel('Reason for closing (optional)') + .setLabel('Grund fürs Schließen (optional)') .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('Add an optional reason for closing this ticket...') + .setPlaceholder('Optional: Warum wird dieses Ticket geschlossen?') .setRequired(false) .setMaxLength(1000); @@ -232,7 +232,7 @@ const closeTicketHandler = { logger.error('Error closing ticket:', error); if (!interaction.replied && !interaction.deferred) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Could not open ticket close form.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Das Formular zum Schließen des Tickets konnte nicht geöffnet werden.' }); } } } @@ -244,22 +244,22 @@ const closeTicketModalHandler = { try { if (!(await ensureGuildContext(interaction))) return; - await assertTicketPermission(interaction, client, 'close this ticket', { allowTicketCreator: true }, 2000); + await assertTicketPermission(interaction, client, 'dieses Ticket schließen', { allowTicketCreator: true }, 2000); const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; const providedReason = interaction.fields.getTextInputValue('reason')?.trim(); - const reason = providedReason || 'Closed via ticket button without a specific reason.'; + const reason = providedReason || 'Über den Ticket-Button ohne Angabe eines Grundes geschlossen.'; await closeTicket(interaction.channel as TextChannel, interaction.user, reason); - await interaction.editReply({ embeds: [successEmbed('Ticket Closed', 'This ticket has been closed.')] }); + await interaction.editReply({ embeds: [successEmbed('Ticket geschlossen', 'Dieses Ticket wurde geschlossen.')] }); } catch (error) { logger.error('Error submitting close ticket modal:', error); if (!interaction.replied && !interaction.deferred) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while closing the ticket.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Schließen des Tickets ist ein Fehler aufgetreten.' }); } else if (interaction.deferred) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while closing the ticket.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Schließen des Tickets ist ein Fehler aufgetreten.' }); } } } @@ -271,19 +271,19 @@ const claimTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - await assertTicketPermission(interaction, client, 'claim tickets', {}, 2000); + await assertTicketPermission(interaction, client, 'Tickets übernehmen', {}, 2000); const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; await claimTicket(interaction.channel as TextChannel, interaction.user); - await interaction.editReply({ embeds: [successEmbed('Ticket Claimed', 'You have claimed this ticket.')] }); + await interaction.editReply({ embeds: [successEmbed('Ticket übernommen', 'Du hast dieses Ticket übernommen.')] }); } catch (error) { logger.error('Error claiming ticket:', error); if (!interaction.replied && !interaction.deferred) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while claiming the ticket.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Übernehmen des Tickets ist ein Fehler aufgetreten.' }); } else if (interaction.deferred) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while claiming the ticket.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Übernehmen des Tickets ist ein Fehler aufgetreten.' }); } } } @@ -295,25 +295,25 @@ const priorityTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - await assertTicketPermission(interaction, client, 'change ticket priority', {}, 2000); + await assertTicketPermission(interaction, client, 'die Ticket-Priorität ändern', {}, 2000); const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; const priority = args?.[0]; if (!priority) { - await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'A priority value is required.' }); + await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Eine Priorität ist erforderlich.' }); return; } await updateTicketPriority(interaction.channel as TextChannel, priority, interaction.user); - await interaction.editReply({ embeds: [successEmbed('Priority Updated', `Ticket priority set to **${priority.toUpperCase()}**.`)] }); + await interaction.editReply({ embeds: [successEmbed('Priorität aktualisiert', `Ticket-Priorität auf **${priority.toUpperCase()}** gesetzt.`)] }); } catch (error) { logger.error('Error updating ticket priority:', error); if (!interaction.replied && !interaction.deferred) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while updating the priority.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Aktualisieren der Priorität ist ein Fehler aufgetreten.' }); } else if (interaction.deferred) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while updating the priority.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Aktualisieren der Priorität ist ein Fehler aufgetreten.' }); } } } @@ -325,7 +325,7 @@ const pinTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - await assertTicketPermission(interaction, client, 'pin tickets', {}, 2000); + await assertTicketPermission(interaction, client, 'Tickets anpinnen', {}, 2000); const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; @@ -334,7 +334,7 @@ const pinTicketHandler = { const category = channel.parent; if (!category) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This ticket is not in a category.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Dieses Ticket befindet sich in keiner Kategorie.' }); return; } @@ -350,8 +350,8 @@ const pinTicketHandler = { await interaction.editReply({ embeds: [createEmbed({ - title: '📌 Ticket Unpinned', - description: 'This ticket has been unpinned and moved back to normal position.', + title: '📌 Ticket losgelöst', + description: 'Dieses Ticket ist nicht mehr angepinnt und wieder an seiner normalen Position.', color: 0x95A5A6 })], // JS also passed flags: MessageFlags.Ephemeral here; editReply cannot set it (Discord ignores it on edits). @@ -373,8 +373,8 @@ const pinTicketHandler = { await interaction.editReply({ embeds: [createEmbed({ - title: '📌 Ticket Pinned', - description: 'This ticket has been pinned to the top of the category.', + title: '📌 Ticket angepinnt', + description: 'Dieses Ticket wurde oben in der Kategorie angepinnt.', color: 0x3498db })], // JS also passed flags: MessageFlags.Ephemeral here; editReply cannot set it (Discord ignores it on edits). @@ -407,9 +407,9 @@ const pinTicketHandler = { } catch (error) { logger.error('Error pinning/unpinning ticket:', error); if (!interaction.replied && !interaction.deferred) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Failed to pin/unpin the ticket.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Das Ticket konnte nicht angepinnt/losgelöst werden.' }); } else if (interaction.deferred) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Failed to pin/unpin the ticket.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Das Ticket konnte nicht angepinnt/losgelöst werden.' }); } } } @@ -421,20 +421,20 @@ const unclaimTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - await assertTicketPermission(interaction, client, 'unclaim tickets', {}, 2000); + await assertTicketPermission(interaction, client, 'Tickets freigeben', {}, 2000); const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; const { unclaimTicket } = await import('../services/ticket.js'); await unclaimTicket(interaction.channel as TextChannel, interaction.member as GuildMember); - await interaction.editReply({ embeds: [successEmbed('Ticket Unclaimed', 'This ticket has been unclaimed.')] }); + await interaction.editReply({ embeds: [successEmbed('Ticket freigegeben', 'Dieses Ticket wurde freigegeben.')] }); } catch (error) { logger.error('Error unclaiming ticket:', error); if (!interaction.replied && !interaction.deferred) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while unclaiming the ticket.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Freigeben des Tickets ist ein Fehler aufgetreten.' }); } else if (interaction.deferred) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while unclaiming the ticket.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Freigeben des Tickets ist ein Fehler aufgetreten.' }); } } } @@ -446,24 +446,24 @@ const reopenTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - await assertTicketPermission(interaction, client, 'reopen tickets', {}, 2000); + await assertTicketPermission(interaction, client, 'Tickets wieder öffnen', {}, 2000); const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; const { reopenTicket } = await import('../services/ticket.js'); const { movedToOpenCategory, openCategoryMoveFailed } = await reopenTicket(interaction.channel as TextChannel, interaction.member as GuildMember); - let reopenMessage = 'This ticket has been reopened.'; + let reopenMessage = 'Dieses Ticket wurde wieder geöffnet.'; if (openCategoryMoveFailed) { - reopenMessage += ' Note: Could not move the channel back to the open tickets category.'; + reopenMessage += ' Hinweis: Der Kanal konnte nicht zurück in die Kategorie für offene Tickets verschoben werden.'; } - await interaction.editReply({ embeds: [successEmbed('Ticket Reopened', reopenMessage)] }); + await interaction.editReply({ embeds: [successEmbed('Ticket wieder geöffnet', reopenMessage)] }); } catch (error) { logger.error('Error reopening ticket:', error); if (!interaction.replied && !interaction.deferred) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while reopening the ticket.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Wiedereröffnen des Tickets ist ein Fehler aufgetreten.' }); } else if (interaction.deferred) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while reopening the ticket.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Wiedereröffnen des Tickets ist ein Fehler aufgetreten.' }); } } } @@ -475,20 +475,20 @@ const deleteTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - await assertTicketPermission(interaction, client, 'delete tickets', {}, 2000); + await assertTicketPermission(interaction, client, 'Tickets löschen', {}, 2000); const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; const { deleteTicket } = await import('../services/ticket.js'); await deleteTicket(interaction.channel as TextChannel, interaction.member as GuildMember); - await interaction.editReply({ embeds: [successEmbed('Ticket Deleted', 'This ticket will be deleted shortly.')] }); + await interaction.editReply({ embeds: [successEmbed('Ticket gelöscht', 'Dieses Ticket wird in Kürze gelöscht.')] }); } catch (error) { logger.error('Error deleting ticket:', error); if (!interaction.replied && !interaction.deferred) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while deleting the ticket.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Löschen des Tickets ist ein Fehler aufgetreten.' }); } else if (interaction.deferred) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while deleting the ticket.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Löschen des Tickets ist ein Fehler aufgetreten.' }); } } } diff --git a/src/handlers/todoButtons.ts b/src/handlers/todoButtons.ts index 8ee43f67ee..2eb546e287 100644 --- a/src/handlers/todoButtons.ts +++ b/src/handlers/todoButtons.ts @@ -34,25 +34,25 @@ function buildSharedTodoViewPayload(listData: StoredSharedTodoList, listId: stri 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}\`)` + `👑 **Besitzer:** ${ownerName}\n` + + `👥 **Mitglieder:** ${memberList}\n\n` + + '*Diese Liste ist noch leer. Füg mit dem Button „Aufgabe hinzufügen“ Aufgaben hinzu!*', + `Geteilte Liste (ID: \`${listId}\`)` ) ], components: [ new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`shared_todo_add_${listId}`) - .setLabel('Add Task') + .setLabel('Aufgabe hinzufügen') .setStyle(ButtonStyle.Primary), new ButtonBuilder() .setCustomId(`shared_todo_complete_${listId}`) - .setLabel('Complete Task') + .setLabel('Aufgabe erledigen') .setStyle(ButtonStyle.Success), new ButtonBuilder() .setCustomId(`shared_todo_remove_${listId}`) - .setLabel('Remove Task') + .setLabel('Aufgabe entfernen') .setStyle(ButtonStyle.Danger) ) ] @@ -63,7 +63,7 @@ function buildSharedTodoViewPayload(listData: StoredSharedTodoList, listId: stri .map(task => `${task.completed ? '✅' : '📝'} #${task.id} ${task.text} ` + `\`[${new Date(task.createdAt).toLocaleDateString()}]` + - (task.completed ? ` • Completed by <@${task.completedBy}>` : '') + '`' + (task.completed ? ` • Erledigt von <@${task.completedBy}>` : '') + '`' ) .join('\n'); @@ -71,25 +71,25 @@ function buildSharedTodoViewPayload(listData: StoredSharedTodoList, listId: stri embeds: [ successEmbed( `📋 **${listData.name}**\n\n` + - `👑 **Owner:** ${ownerName}\n` + - `👥 **Members:** ${memberList}\n\n` + - `**Tasks:**\n${taskList}`, - `Shared List (ID: \`${listId}\`)` + `👑 **Besitzer:** ${ownerName}\n` + + `👥 **Mitglieder:** ${memberList}\n\n` + + `**Aufgaben:**\n${taskList}`, + `Geteilte Liste (ID: \`${listId}\`)` ) ], components: [ new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`shared_todo_add_${listId}`) - .setLabel('Add Task') + .setLabel('Aufgabe hinzufügen') .setStyle(ButtonStyle.Primary), new ButtonBuilder() .setCustomId(`shared_todo_complete_${listId}`) - .setLabel('Complete Task') + .setLabel('Aufgabe erledigen') .setStyle(ButtonStyle.Success), new ButtonBuilder() .setCustomId(`shared_todo_remove_${listId}`) - .setLabel('Remove Task') + .setLabel('Aufgabe entfernen') .setStyle(ButtonStyle.Danger) ) ] @@ -133,17 +133,17 @@ const sharedTodoAddHandler = { const sourceMessageId = interaction.message?.id; if (!listId || !/^[a-zA-Z0-9_-]{1,64}$/.test(listId)) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Invalid shared list ID.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Ungültige ID der geteilten Liste.' }); return; } const modal = new ModalBuilder() .setCustomId(`shared_todo_add_modal:${listId}:${sourceMessageId || ''}`) - .setTitle('Add Task to Shared List'); + .setTitle('Aufgabe zur geteilten Liste hinzufügen'); const taskInput = new TextInputBuilder() .setCustomId('task_text') - .setLabel('Enter the task description') + .setLabel('Beschreibung der Aufgabe') .setStyle(TextInputStyle.Short) .setRequired(true) .setMaxLength(200); @@ -162,20 +162,20 @@ const sharedTodoCompleteHandler = { const sourceMessageId = interaction.message?.id; if (!listId || !/^[a-zA-Z0-9_-]{1,64}$/.test(listId)) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Invalid shared list ID.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Ungültige ID der geteilten Liste.' }); return; } const modal = new ModalBuilder() .setCustomId(`shared_todo_complete_modal:${listId}:${sourceMessageId || ''}`) - .setTitle('Complete Task in Shared List'); + .setTitle('Aufgabe in geteilter Liste erledigen'); const taskIdInput = new TextInputBuilder() .setCustomId('task_id') - .setLabel('Enter the task ID to complete') + .setLabel('ID der zu erledigenden Aufgabe') .setStyle(TextInputStyle.Short) .setRequired(true) - .setPlaceholder('e.g., 1, 2, 3'); + .setPlaceholder('z. B. 1, 2, 3'); const actionRow = new ActionRowBuilder().addComponents(taskIdInput); modal.addComponents(actionRow); @@ -191,20 +191,20 @@ const sharedTodoRemoveHandler = { const sourceMessageId = interaction.message?.id; if (!listId || !/^[a-zA-Z0-9_-]{1,64}$/.test(listId)) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Invalid shared list ID.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Ungültige ID der geteilten Liste.' }); return; } const modal = new ModalBuilder() .setCustomId(`shared_todo_remove_modal:${listId}:${sourceMessageId || ''}`) - .setTitle('Remove Task from Shared List'); + .setTitle('Aufgabe aus geteilter Liste entfernen'); const taskIdInput = new TextInputBuilder() .setCustomId('task_id') - .setLabel('Enter the task ID to remove') + .setLabel('ID der zu entfernenden Aufgabe') .setStyle(TextInputStyle.Short) .setRequired(true) - .setPlaceholder('e.g., 1, 2, 3'); + .setPlaceholder('z. B. 1, 2, 3'); const actionRow = new ActionRowBuilder().addComponents(taskIdInput); modal.addComponents(actionRow); @@ -224,26 +224,26 @@ const sharedTodoAddModalHandler = { try { const allowed = await checkRateLimit(`${userId}:shared_todo_add`, 5, 30000); if (!allowed) { - return await replyUserError(interaction, { type: ErrorTypes.RATE_LIMIT, message: 'You are adding tasks too quickly. Please wait and try again.' }); + return await replyUserError(interaction, { type: ErrorTypes.RATE_LIMIT, message: 'Du fügst Aufgaben zu schnell hinzu. Warte kurz und versuch es erneut.' }); } if (!listId || !/^[a-zA-Z0-9_-]{1,64}$/.test(listId)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Invalid shared list ID.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Ungültige ID der geteilten Liste.' }); } if (!taskText || taskText.trim().length === 0) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Task text cannot be empty.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Der Aufgabentext darf nicht leer sein.' }); } const listKey = `shared_todo_${listId}`; let listData = await getFromDb(listKey, null); if (!listData) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Shared list not found.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Geteilte Liste nicht gefunden.' }); } if (!listData.members || !listData.members.includes(userId)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You don\'t have access to this list.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Du hast keinen Zugriff auf diese Liste.' }); } if (!listData.tasks) listData.tasks = []; @@ -263,13 +263,13 @@ const sharedTodoAddModalHandler = { await refreshSharedTodoMessage(interaction, listId, sourceMessageId); return interaction.reply({ - embeds: [successEmbed("Task Added", `Added "${taskText}" to the shared list.`)], + embeds: [successEmbed("Aufgabe hinzugefügt", `„${taskText}“ wurde zur geteilten Liste hinzugefügt.`)], flags: MessageFlags.Ephemeral }); } catch (error) { logger.error('Error in shared todo add modal:', error); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while adding the task.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Hinzufügen der Aufgabe ist ein Fehler aufgetreten.' }); } } } satisfies ModalHandler; @@ -285,26 +285,26 @@ const sharedTodoCompleteModalHandler = { try { const allowed = await checkRateLimit(`${userId}:shared_todo_complete`, 5, 30000); if (!allowed) { - return await replyUserError(interaction, { type: ErrorTypes.RATE_LIMIT, message: 'You are completing tasks too quickly. Please wait and try again.' }); + return await replyUserError(interaction, { type: ErrorTypes.RATE_LIMIT, message: 'Du erledigst Aufgaben zu schnell. Warte kurz und versuch es erneut.' }); } if (!listId || !/^[a-zA-Z0-9_-]{1,64}$/.test(listId)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Invalid shared list ID.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Ungültige ID der geteilten Liste.' }); } if (!Number.isInteger(taskId) || taskId <= 0) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Task ID must be a positive number.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Die Aufgaben-ID muss eine positive Zahl sein.' }); } const listKey = `shared_todo_${listId}`; let listData = await getFromDb(listKey, null); if (!listData) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Shared list not found.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Geteilte Liste nicht gefunden.' }); } if (!listData.members || !listData.members.includes(userId)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You don\'t have access to this list.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Du hast keinen Zugriff auf diese Liste.' }); } if (!listData.tasks) listData.tasks = []; @@ -312,11 +312,11 @@ const sharedTodoCompleteModalHandler = { const task = listData.tasks.find(t => t.id === taskId); if (!task) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Task not found.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Aufgabe nicht gefunden.' }); } 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: `Aufgabe #${task.id} ist bereits erledigt.` }); } task.completed = true; @@ -328,13 +328,13 @@ const sharedTodoCompleteModalHandler = { await refreshSharedTodoMessage(interaction, listId, sourceMessageId); return interaction.reply({ - embeds: [successEmbed("Task Completed", `Marked "${task.text}" as complete!`)], + embeds: [successEmbed("Aufgabe erledigt", `„${task.text}“ wurde als erledigt markiert!`)], flags: MessageFlags.Ephemeral }); } catch (error) { logger.error('Error in shared todo complete modal:', error); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while completing the task.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Erledigen der Aufgabe ist ein Fehler aufgetreten.' }); } } } satisfies ModalHandler; @@ -350,26 +350,26 @@ const sharedTodoRemoveModalHandler = { try { const allowed = await checkRateLimit(`${userId}:shared_todo_remove`, 5, 30000); if (!allowed) { - return await replyUserError(interaction, { type: ErrorTypes.RATE_LIMIT, message: 'You are removing tasks too quickly. Please wait and try again.' }); + return await replyUserError(interaction, { type: ErrorTypes.RATE_LIMIT, message: 'Du entfernst Aufgaben zu schnell. Warte kurz und versuch es erneut.' }); } if (!listId || !/^[a-zA-Z0-9_-]{1,64}$/.test(listId)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Invalid shared list ID.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Ungültige ID der geteilten Liste.' }); } if (!Number.isInteger(taskId) || taskId <= 0) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Task ID must be a positive number.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Die Aufgaben-ID muss eine positive Zahl sein.' }); } const listKey = `shared_todo_${listId}`; const listData = await getFromDb(listKey, null); if (!listData) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Shared list not found.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Geteilte Liste nicht gefunden.' }); } if (!listData.members || !listData.members.includes(userId)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You don\'t have access to this list.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Du hast keinen Zugriff auf diese Liste.' }); } if (!Array.isArray(listData.tasks)) { @@ -378,7 +378,7 @@ const sharedTodoRemoveModalHandler = { const taskIndex = listData.tasks.findIndex(task => task.id === taskId); if (taskIndex === -1) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Task not found.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Aufgabe nicht gefunden.' }); } const [removedTask] = listData.tasks.splice(taskIndex, 1); @@ -387,12 +387,12 @@ const sharedTodoRemoveModalHandler = { await refreshSharedTodoMessage(interaction, listId, sourceMessageId); return interaction.reply({ - embeds: [successEmbed('Task Removed', `Removed "${removedTask.text}" from the shared list.`)], + embeds: [successEmbed('Aufgabe entfernt', `„${removedTask.text}“ wurde aus der geteilten Liste entfernt.`)], flags: MessageFlags.Ephemeral }); } catch (error) { logger.error('Error in shared todo remove modal:', error); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while removing the task.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Entfernen der Aufgabe ist ein Fehler aufgetreten.' }); } } } satisfies ModalHandler; diff --git a/src/handlers/verificationButtons.ts b/src/handlers/verificationButtons.ts index 40becb07d1..786fa04813 100644 --- a/src/handlers/verificationButtons.ts +++ b/src/handlers/verificationButtons.ts @@ -12,7 +12,7 @@ export async function handleVerificationButton(interaction: ButtonInteraction, c await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!interaction.guild) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This button can only be used in a server.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Diesen Button kannst du nur auf einem Server nutzen.' }); } const guild = interaction.guild; @@ -30,7 +30,7 @@ export async function handleVerificationButton(interaction: ButtonInteraction, c }); if (result.status === 'already_verified') { - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'You are already verified and have access to all server channels.' }); + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Du bist bereits verifiziert und hast Zugriff auf alle Kanäle des Servers.' }); } logger.info('User verified via button', { @@ -41,8 +41,8 @@ export async function handleVerificationButton(interaction: ButtonInteraction, c await InteractionHelper.safeEditReply(interaction, { embeds: [successEmbed( - "✅ Verification Successful!", - `You have been verified and given the **${result.roleName}** role!\n\nYou now have access to all server channels and features. Welcome! 🎉` + "✅ Verifizierung erfolgreich!", + `Du wurdest verifiziert und hast die Rolle **${result.roleName}** erhalten!\n\nDu hast jetzt Zugriff auf alle Kanäle und Funktionen des Servers. Willkommen! 🎉` )], }); diff --git a/src/handlers/warningHandlers.ts b/src/handlers/warningHandlers.ts index 08b481f724..2fa00df8bc 100644 --- a/src/handlers/warningHandlers.ts +++ b/src/handlers/warningHandlers.ts @@ -14,17 +14,17 @@ const warningDeleteSpecificHandler = { const [, targetUserId, originalModeratorId] = interaction.customId.split(':'); if (interaction.user.id !== originalModeratorId) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Only the moderator who viewed these warnings can delete them.' }); + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Nur der Moderator, der diese Verwarnungen aufgerufen hat, kann sie löschen.' }); } const modal = new ModalBuilder() .setCustomId(`warning_delete_modal:${targetUserId}:${interaction.user.id}`) - .setTitle('Delete Warning'); + .setTitle('Verwarnung löschen'); const warningNumberInput = new TextInputBuilder() .setCustomId('warning_number') - .setLabel('Warning Number (#1, #2, etc.)') - .setPlaceholder('Enter the warning number to delete') + .setLabel('Nummer der Verwarnung (#1, #2 usw.)') + .setPlaceholder('Nummer der zu löschenden Verwarnung') .setRequired(true) .setStyle(TextInputStyle.Short) .setMaxLength(10); @@ -35,7 +35,7 @@ const warningDeleteSpecificHandler = { await interaction.showModal(modal); } catch (error) { logger.error('Warning delete specific button error:', error); - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Failed to open delete warning modal.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Das Formular zum Löschen der Verwarnung konnte nicht geöffnet werden.' }); } } } satisfies ButtonHandler; @@ -47,20 +47,20 @@ const warningClearAllHandler = { const [, targetUserId, originalModeratorId] = interaction.customId.split(':'); if (interaction.user.id !== originalModeratorId) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Only the moderator who viewed these warnings can clear them.' }); + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Nur der Moderator, der diese Verwarnungen aufgerufen hat, kann sie zurücksetzen.' }); } const targetUser = await client.users.fetch(targetUserId).catch(() => null); - const targetName = targetUser ? targetUser.username : 'this user'; + const targetName = targetUser ? targetUser.username : 'diesen Nutzer'; const clearModal = new ModalBuilder() .setCustomId(`warning_clear_confirm_modal:${targetUserId}:${interaction.user.id}`) - .setTitle('Clear All Warnings') + .setTitle('Alle Verwarnungen löschen') .addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('delete_confirmation') - .setLabel(`Type "DELETE" to clear all warnings`) + .setLabel(`Gib „DELETE“ ein, um alle zu löschen`) .setStyle(TextInputStyle.Short) .setPlaceholder('DELETE') .setMaxLength(6) @@ -72,7 +72,7 @@ const warningClearAllHandler = { await interaction.showModal(clearModal); } catch (error) { logger.error('Warning clear all button error:', error); - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Failed to open confirmation modal.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Das Bestätigungsformular konnte nicht geöffnet werden.' }); } } } satisfies ButtonHandler; @@ -82,14 +82,14 @@ async function warningDeleteModalHandler(interaction: ModalSubmitInteraction, cl const [, targetUserId, originalModeratorId] = interaction.customId.split(':'); if (interaction.user.id !== originalModeratorId) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Only the original moderator can delete warnings.' }); + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Nur der ursprüngliche Moderator kann Verwarnungen löschen.' }); } const warningNumberInput = interaction.fields.getTextInputValue('warning_number'); const warningNumber = parseInt(warningNumberInput.replace('#', '').trim(), 10); if (isNaN(warningNumber) || warningNumber < 1) { - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please enter a valid warning number (e.g., 1, 2, 3).' }); + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Bitte gib eine gültige Verwarnungsnummer ein (z. B. 1, 2, 3).' }); } const deferSuccess = await InteractionHelper.safeDefer(interaction); @@ -99,14 +99,14 @@ async function warningDeleteModalHandler(interaction: ModalSubmitInteraction, cl 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: `Verwarnung #${warningNumber} gibt es nicht. Dieser Nutzer hat nur ${warnings.length} Verwarnung(en).` }); } const warningToDelete = warnings[warningNumber - 1]; await WarningService.removeWarning(guildId, targetUserId, warningToDelete.id); const targetUser = await client.users.fetch(targetUserId).catch(() => null); - const targetName = targetUser ? targetUser.username : 'the user'; + const targetName = targetUser ? targetUser.username : 'den Nutzer'; logger.info(`[MODERATION] Warning deleted for ${targetUserId} in ${guildId} by ${interaction.user.id}`, { warningId: warningToDelete.id, @@ -115,11 +115,11 @@ async function warningDeleteModalHandler(interaction: ModalSubmitInteraction, cl }); await interaction.editReply({ - embeds: [successEmbed('✅ Warning Deleted', `Warning #${warningNumber} for **${targetName}** has been deleted.\n\n**Reason was:** ${warningToDelete.reason.substring(0, 100)}`)] + embeds: [successEmbed('✅ Verwarnung gelöscht', `Verwarnung #${warningNumber} für **${targetName}** wurde gelöscht.\n\n**Grund war:** ${warningToDelete.reason.substring(0, 100)}`)] }); } catch (error) { logger.error('Warning delete modal handler error:', error); - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Failed to delete warning.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Die Verwarnung konnte nicht gelöscht werden.' }); } } @@ -128,13 +128,13 @@ async function warningClearConfirmModalHandler(interaction: ModalSubmitInteracti const [, targetUserId, originalModeratorId] = interaction.customId.split(':'); if (interaction.user.id !== originalModeratorId) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Only the original moderator can clear warnings.' }); + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Nur der ursprüngliche Moderator kann Verwarnungen zurücksetzen.' }); } const confirmation = interaction.fields.getTextInputValue('delete_confirmation').trim(); if (confirmation !== 'DELETE') { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You must type "DELETE" exactly to confirm clearing all warnings.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Du musst genau „DELETE“ eingeben, um das Löschen aller Verwarnungen zu bestätigen.' }); } await interaction.deferReply({ flags: MessageFlags.Ephemeral }); @@ -143,19 +143,19 @@ async function warningClearConfirmModalHandler(interaction: ModalSubmitInteracti const { count } = await WarningService.clearWarnings(guildId, targetUserId); const targetUser = await client.users.fetch(targetUserId).catch(() => null); - const targetName = targetUser ? targetUser.username : 'the user'; + const targetName = targetUser ? targetUser.username : 'den Nutzer'; 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. **${count}** warning(s) removed.`)] + embeds: [successEmbed('✅ Verwarnungen gelöscht', `Alle Verwarnungen für **${targetName}** wurden gelöscht. **${count}** Verwarnung(en) entfernt.`)] }); } catch (error) { logger.error('Warning clear confirm modal handler error:', error); if (!interaction.replied && !interaction.deferred) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Failed to clear warnings.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Die Verwarnungen konnten nicht gelöscht werden.' }); } else { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Failed to clear warnings.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Die Verwarnungen konnten nicht gelöscht werden.' }); } } } diff --git a/src/handlers/wipedataButtons.ts b/src/handlers/wipedataButtons.ts index 54fc6a0bb0..97c59de9c7 100644 --- a/src/handlers/wipedataButtons.ts +++ b/src/handlers/wipedataButtons.ts @@ -115,13 +115,13 @@ const wipedataConfirmHandler = { } const successMessage = - `✅ **Your data has been successfully wiped!**\n\n` + - `**Records Deleted:** ${deletedCount}\n\n` + - `Your account has been reset to default values. You can now start fresh!\n\n` + - `*All your economy balance, levels, items, and personal data have been removed.*`; + `✅ **Deine Daten wurden erfolgreich gelöscht!**\n\n` + + `**Gelöschte Einträge:** ${deletedCount}\n\n` + + `Dein Konto wurde auf die Standardwerte zurückgesetzt. Du kannst jetzt neu starten!\n\n` + + `*Dein Kontostand, deine Level, Items und persönlichen Daten wurden entfernt.*`; await interaction.editReply({ - embeds: [successEmbed('Data Wipe Complete', successMessage)], + embeds: [successEmbed('Daten gelöscht', successMessage)], components: [] }); @@ -133,7 +133,7 @@ const wipedataConfirmHandler = { } catch (error) { logger.error('Wipedata confirm button handler error:', error); - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while wiping your data. Please try again later or contact support.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Beim Löschen deiner Daten ist ein Fehler aufgetreten. Bitte versuch es später erneut oder wende dich an den Support.' }); } } } satisfies ButtonHandler; @@ -145,8 +145,8 @@ const wipedataCancelHandler = { await interaction.update({ embeds: [ createEmbed({ - title: '❌ Data Wipe Cancelled', - description: 'Your data has been preserved. Your account remains unchanged.', + title: '❌ Löschen abgebrochen', + description: 'Deine Daten bleiben erhalten. Dein Konto bleibt unverändert.', color: 'info' }) ], @@ -158,7 +158,7 @@ const wipedataCancelHandler = { logger.error('Wipedata cancel button handler error:', error); if (!interaction.replied && !interaction.deferred) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Could not cancel data wipe.' }); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Das Löschen konnte nicht abgebrochen werden.' }); } } } diff --git a/src/interactions/buttons/ticket/ticketFeedback.ts b/src/interactions/buttons/ticket/ticketFeedback.ts index 0ef7ef4cfa..a795144dca 100644 --- a/src/interactions/buttons/ticket/ticketFeedback.ts +++ b/src/interactions/buttons/ticket/ticketFeedback.ts @@ -7,11 +7,11 @@ import { InteractionHelper } from '../../../utils/interactionHelper.js'; import type { ButtonHandler } from '../../../types/index.js'; const STAR_LABELS: Record = { - '1': '⭐ 1 — Poor', - '2': '⭐ 2 — Below Average', - '3': '⭐ 3 — Average', - '4': '⭐ 4 — Good', - '5': '⭐ 5 — Excellent', + '1': '⭐ 1 — Schlecht', + '2': '⭐ 2 — Unterdurchschnittlich', + '3': '⭐ 3 — Durchschnittlich', + '4': '⭐ 4 — Gut', + '5': '⭐ 5 — Ausgezeichnet', }; const feedbackHandler = { @@ -25,8 +25,8 @@ const feedbackHandler = { await InteractionHelper.safeReply(interaction, { embeds: [ new EmbedBuilder() - .setTitle('⚠️ Invalid Feedback Link') - .setDescription('This feedback link appears to be malformed.') + .setTitle('⚠️ Ungültiger Feedback-Link') + .setDescription('Dieser Feedback-Link scheint fehlerhaft zu sein.') .setColor(getColor('error')), ], components: [], @@ -52,8 +52,8 @@ const feedbackHandler = { await InteractionHelper.safeEditReply(interaction, { embeds: [ new EmbedBuilder() - .setTitle('⚠️ Ticket Not Found') - .setDescription('Could not find the ticket associated with this survey.') + .setTitle('⚠️ Ticket nicht gefunden') + .setDescription('Das Ticket zu dieser Umfrage wurde nicht gefunden.') .setColor(getColor('error')), ], components: [], @@ -65,8 +65,8 @@ const feedbackHandler = { await InteractionHelper.safeEditReply(interaction, { embeds: [ new EmbedBuilder() - .setTitle('❌ Not Allowed') - .setDescription('Only the ticket creator can submit feedback for this ticket.') + .setTitle('❌ Nicht erlaubt') + .setDescription('Nur wer das Ticket erstellt hat, kann dafür Feedback abgeben.') .setColor(getColor('error')), ], components: [], @@ -78,8 +78,8 @@ const feedbackHandler = { await InteractionHelper.safeEditReply(interaction, { embeds: [ new EmbedBuilder() - .setTitle('✅ Already Submitted') - .setDescription(`You already rated this ticket **${STAR_LABELS[String(ticketData.feedback.rating)]}**.\nThank you for your feedback!`) + .setTitle('✅ Bereits abgegeben') + .setDescription(`Du hast dieses Ticket bereits mit **${STAR_LABELS[String(ticketData.feedback.rating)]}** bewertet.\nDanke für dein Feedback!`) .setColor(getColor('success')), ], components: [], @@ -88,7 +88,7 @@ const feedbackHandler = { } const rating = parseInt(ratingStr, 10); - const ratingLabel = STAR_LABELS[String(rating)] ?? `${rating} stars`; + const ratingLabel = STAR_LABELS[String(rating)] ?? `${rating} Sterne`; try { ticketData.feedback = { @@ -116,10 +116,10 @@ const feedbackHandler = { await InteractionHelper.safeEditReply(interaction, { embeds: [ new EmbedBuilder() - .setTitle('✅ Thanks for your feedback!') - .setDescription(`You rated your support experience **${ratingLabel}**.\n\nYour feedback has been recorded and helps us improve!`) + .setTitle('✅ Danke für dein Feedback!') + .setDescription(`Du hast deine Support-Erfahrung mit **${ratingLabel}** bewertet.\n\nDein Feedback wurde gespeichert und hilft uns, besser zu werden!`) .setColor(getColor('success')) - .setFooter({ text: 'Thank you for using our support system.' }) + .setFooter({ text: 'Danke, dass du unseren Support genutzt hast.' }) .setTimestamp(), ], components: [], @@ -144,8 +144,8 @@ const commentHandler = { await interaction.update({ embeds: [ new EmbedBuilder() - .setTitle('⚠️ Invalid Feedback Link') - .setDescription('This feedback action appears to be malformed.') + .setTitle('⚠️ Ungültiger Feedback-Link') + .setDescription('Diese Feedback-Aktion scheint fehlerhaft zu sein.') .setColor(getColor('error')), ], components: [], @@ -155,13 +155,13 @@ const commentHandler = { const modal = new ModalBuilder() .setCustomId(`ticket_feedback_comment_modal:${guildId}:${channelId}`) - .setTitle('Add Ticket Feedback'); + .setTitle('Ticket-Feedback abgeben'); const commentInput = new TextInputBuilder() .setCustomId('feedback_comment') - .setLabel('Your feedback') + .setLabel('Dein Feedback') .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('Share what went well or how we can improve...') + .setPlaceholder('Was lief gut, was können wir besser machen?') .setRequired(true) .setMaxLength(1000); @@ -178,8 +178,8 @@ const declineHandler = { await interaction.update({ embeds: [ new EmbedBuilder() - .setTitle('👋 No problem!') - .setDescription('You can always reach out again if you need further support.') + .setTitle('👋 Kein Problem!') + .setDescription('Du kannst dich jederzeit wieder melden, wenn du weitere Hilfe brauchst.') .setColor(getColor('default')), ], components: [], diff --git a/src/interactions/modals/config/config_modal.ts b/src/interactions/modals/config/config_modal.ts index cb2012dd89..530649ccc9 100644 --- a/src/interactions/modals/config/config_modal.ts +++ b/src/interactions/modals/config/config_modal.ts @@ -31,7 +31,7 @@ function parseValue(key: string, rawValue: string): string | boolean | null { } const id = extractId(value); if (!id) { - throw new Error('Please provide a valid mention or ID.'); + throw new Error('Bitte gib eine gültige Erwähnung oder ID an.'); } return id; } @@ -43,12 +43,12 @@ function parseValue(key: string, rawValue: string): string | boolean | null { if (['no', 'false', 'disabled', 'disable'].includes(value.toLowerCase())) { return false; } - throw new Error('Please enter either yes or no.'); + throw new Error('Bitte gib entweder „yes“ oder „no“ ein.'); } if (key === 'prefix') { if (value.length < 1 || value.length > 10 || /\s/.test(value)) { - throw new Error('Prefix must be 1-10 characters with no spaces.'); + throw new Error('Der Präfix muss 1–10 Zeichen lang sein und darf keine Leerzeichen enthalten.'); } return value; } @@ -60,7 +60,7 @@ function resolveModalValue(key: string, interaction: ModalSubmitInteraction): st if (key === 'logChannelId') { const channelId = (interaction.fields.getField('log_channel') as SelectMenuModalData)?.values?.[0]; if (!channelId) { - throw new Error('Please select a log channel.'); + throw new Error('Bitte wähl einen Log-Kanal aus.'); } return channelId; } @@ -68,7 +68,7 @@ function resolveModalValue(key: string, interaction: ModalSubmitInteraction): st if (key === 'modRole') { const roleId = (interaction.fields.getField('mod_role') as SelectMenuModalData)?.values?.[0]; if (!roleId) { - throw new Error('Please select a moderator role.'); + throw new Error('Bitte wähl eine Moderator-Rolle aus.'); } return roleId; } @@ -80,15 +80,15 @@ function resolveModalValue(key: string, interaction: ModalSubmitInteraction): st function buildSuccessMessage(key: string, value: string | boolean | null, guild: Guild | null): string { if (key === 'logChannelId') { const channel = guild?.channels?.cache?.get(String(value)); - return `Log channel set to ${channel ?? `<#${value}>`}.`; + return `Log-Kanal auf ${channel ?? `<#${value}>`} gesetzt.`; } if (key === 'modRole') { const role = guild?.roles?.cache?.get(String(value)); - return `Moderator role set to ${role ?? `<@&${value}>`}.`; + return `Moderator-Rolle auf ${role ?? `<@&${value}>`} gesetzt.`; } - return `The setting \`${key}\` has been updated successfully.`; + return `Die Einstellung \`${key}\` wurde erfolgreich aktualisiert.`; } export default { @@ -101,12 +101,12 @@ export default { await ConfigService.updateSetting(interaction.client, guildId, key, value, interaction.user.id); await interaction.reply({ - embeds: [successEmbed('Configuration Updated', buildSuccessMessage(key, value, interaction.guild))], + embeds: [successEmbed('Konfiguration aktualisiert', buildSuccessMessage(key, value, interaction.guild))], flags: MessageFlags.Ephemeral, }); } catch (error) { logger.error('Config modal handler error:', error); - await replyUserError(interaction, { type: ErrorTypes.CONFIGURATION, message: (error instanceof Error && error.message) || 'Please try again.' }); + await replyUserError(interaction, { type: ErrorTypes.CONFIGURATION, message: (error instanceof Error && error.message) || 'Bitte versuch es erneut.' }); } }, } satisfies ModalHandler; diff --git a/src/interactions/modals/ticket/ticketFeedbackComment.ts b/src/interactions/modals/ticket/ticketFeedbackComment.ts index 695746fc5f..ef6f93cf1e 100644 --- a/src/interactions/modals/ticket/ticketFeedbackComment.ts +++ b/src/interactions/modals/ticket/ticketFeedbackComment.ts @@ -23,8 +23,8 @@ export default { if (!guildId || !channelId) { await InteractionHelper.safeReply(interaction, { embeds: [buildEmbed( - '⚠️ Invalid Feedback Submission', - 'This feedback form appears to be malformed.', + '⚠️ Ungültiges Feedback', + 'Dieses Feedback-Formular scheint fehlerhaft zu sein.', getColor('error'), )], flags: MessageFlags.Ephemeral, @@ -36,8 +36,8 @@ export default { if (!comment) { await InteractionHelper.safeReply(interaction, { embeds: [buildEmbed( - '⚠️ Empty Feedback', - 'Please enter a comment before submitting your feedback.', + '⚠️ Leeres Feedback', + 'Bitte gib einen Kommentar ein, bevor du dein Feedback absendest.', getColor('warning'), )], flags: MessageFlags.Ephemeral, @@ -60,8 +60,8 @@ export default { if (!ticketData) { await InteractionHelper.safeEditReply(interaction, { embeds: [buildEmbed( - '⚠️ Ticket Not Found', - 'Could not find the ticket associated with this feedback.', + '⚠️ Ticket nicht gefunden', + 'Das Ticket zu diesem Feedback wurde nicht gefunden.', getColor('error'), )], }); @@ -71,8 +71,8 @@ export default { if (interaction.user.id !== ticketData.userId) { await InteractionHelper.safeEditReply(interaction, { embeds: [buildEmbed( - '❌ Not Allowed', - 'Only the ticket creator can submit feedback for this ticket.', + '❌ Nicht erlaubt', + 'Nur wer das Ticket erstellt hat, kann dafür Feedback abgeben.', getColor('error'), )], }); @@ -107,8 +107,8 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [buildEmbed( - '✅ Feedback Submitted', - 'Your written feedback has been recorded. Thank you for helping us improve!', + '✅ Feedback abgesendet', + 'Dein schriftliches Feedback wurde gespeichert. Danke, dass du uns hilfst, besser zu werden!', getColor('success'), )], }); diff --git a/src/interactions/selectMenus/ticket/ticketFeedback.ts b/src/interactions/selectMenus/ticket/ticketFeedback.ts index 2a2a4c2f11..4330fe7d2a 100644 --- a/src/interactions/selectMenus/ticket/ticketFeedback.ts +++ b/src/interactions/selectMenus/ticket/ticketFeedback.ts @@ -6,11 +6,11 @@ import { logTicketFeedback } from '../../../utils/ticket/ticketLogging.js'; import type { SelectMenuHandler } from '../../../types/index.js'; const STAR_LABELS: Record = { - '1': '⭐ 1 — Poor', - '2': '⭐ 2 — Below Average', - '3': '⭐ 3 — Average', - '4': '⭐ 4 — Good', - '5': '⭐ 5 — Excellent', + '1': '⭐ 1 — Schlecht', + '2': '⭐ 2 — Unterdurchschnittlich', + '3': '⭐ 3 — Durchschnittlich', + '4': '⭐ 4 — Gut', + '5': '⭐ 5 — Ausgezeichnet', }; export default { @@ -24,8 +24,8 @@ export default { await interaction.update({ embeds: [ new EmbedBuilder() - .setTitle('⚠️ Invalid Feedback Link') - .setDescription('This feedback link appears to be malformed.') + .setTitle('⚠️ Ungültiger Feedback-Link') + .setDescription('Dieser Feedback-Link scheint fehlerhaft zu sein.') .setColor(getColor('error')), ], components: [], @@ -44,8 +44,8 @@ export default { await interaction.update({ embeds: [ new EmbedBuilder() - .setTitle('⚠️ Ticket Not Found') - .setDescription('Could not find the ticket associated with this survey.') + .setTitle('⚠️ Ticket nicht gefunden') + .setDescription('Das Ticket zu dieser Umfrage wurde nicht gefunden.') .setColor(getColor('error')), ], components: [], @@ -57,8 +57,8 @@ export default { await interaction.reply({ embeds: [ new EmbedBuilder() - .setTitle('❌ Not Allowed') - .setDescription('Only the ticket creator can submit feedback for this ticket.') + .setTitle('❌ Nicht erlaubt') + .setDescription('Nur wer das Ticket erstellt hat, kann dafür Feedback abgeben.') .setColor(getColor('error')), ], ephemeral: true, @@ -70,8 +70,8 @@ export default { await interaction.update({ embeds: [ new EmbedBuilder() - .setTitle('✅ Already Submitted') - .setDescription(`You already rated this ticket **${STAR_LABELS[String(ticketData.feedback.rating)]}**.\nThank you for your feedback!`) + .setTitle('✅ Bereits abgegeben') + .setDescription(`Du hast dieses Ticket bereits mit **${STAR_LABELS[String(ticketData.feedback.rating)]}** bewertet.\nDanke für dein Feedback!`) .setColor(getColor('success')), ], components: [], @@ -80,7 +80,7 @@ export default { } const rating = parseInt(interaction.values[0], 10); - const ratingLabel = STAR_LABELS[String(rating)] ?? `${rating} stars`; + const ratingLabel = STAR_LABELS[String(rating)] ?? `${rating} Sterne`; try { ticketData.feedback = { @@ -106,10 +106,10 @@ export default { } const thankYouEmbed = new EmbedBuilder() - .setTitle('✅ Thanks for your feedback!') - .setDescription(`You rated your support experience **${ratingLabel}**.\n\nYour feedback has been recorded and helps us improve!`) + .setTitle('✅ Danke für dein Feedback!') + .setDescription(`Du hast deine Support-Erfahrung mit **${ratingLabel}** bewertet.\n\nDein Feedback wurde gespeichert und hilft uns, besser zu werden!`) .setColor(getColor('success')) - .setFooter({ text: 'Thank you for using our support system.' }) + .setFooter({ text: 'Danke, dass du unseren Support genutzt hast.' }) .setTimestamp(); await interaction.update({ diff --git a/src/services/applicationService.ts b/src/services/applicationService.ts index adcfe8bac2..d517140997 100644 --- a/src/services/applicationService.ts +++ b/src/services/applicationService.ts @@ -45,7 +45,7 @@ class ApplicationService { throw createError( 'Missing required fields for application submission', ErrorTypes.VALIDATION, - 'Invalid application data. Please try again.', + 'Ungültige Bewerbungsdaten. Bitte versuch es erneut.', { data } ); } @@ -54,7 +54,7 @@ class ApplicationService { throw createError( 'Application must have answers', ErrorTypes.VALIDATION, - 'You must answer all application questions.', + 'Du musst alle Fragen der Bewerbung beantworten.', { data } ); } @@ -67,7 +67,7 @@ class ApplicationService { throw createError( 'Invalid answer format', ErrorTypes.VALIDATION, - 'All questions must have answers.', + 'Alle Fragen müssen beantwortet werden.', { answer } ); } @@ -76,7 +76,7 @@ class ApplicationService { throw createError( 'Answer too long', ErrorTypes.VALIDATION, - 'Each answer must be less than 1000 characters.', + 'Jede Antwort muss kürzer als 1000 Zeichen sein.', { length: sanitizedAnswer.length } ); } @@ -85,7 +85,7 @@ class ApplicationService { throw createError( 'Answer too short', ErrorTypes.VALIDATION, - 'Please provide meaningful answers (at least 10 characters).', + 'Bitte gib aussagekräftige Antworten (mindestens 10 Zeichen).', { length: sanitizedAnswer.length } ); } @@ -104,7 +104,7 @@ class ApplicationService { throw createError( 'Application submission on cooldown', ErrorTypes.RATE_LIMIT, - `Please wait ${Math.ceil(remainingTime / 60)} minute(s) before submitting another application.`, + `Bitte warte ${Math.ceil(remainingTime / 60)} Minute(n), bevor du eine weitere Bewerbung abschickst.`, { remainingTime, userId } ); } @@ -125,7 +125,7 @@ class ApplicationService { throw createError( 'User lacks permission to manage applications', ErrorTypes.PERMISSION, - 'You do not have permission to manage applications.', + 'Du hast keine Berechtigung, Bewerbungen zu verwalten.', { userId: member.id, guildId } ); } @@ -145,7 +145,7 @@ class ApplicationService { throw createError( 'Applications are disabled', ErrorTypes.CONFIGURATION, - 'Applications are currently disabled in this server.', + 'Bewerbungen sind auf diesem Server derzeit deaktiviert.', { guildId: data.guildId } ); } @@ -157,7 +157,7 @@ class ApplicationService { throw createError( 'User already has pending application', ErrorTypes.VALIDATION, - 'You already have a pending application. Please wait for it to be reviewed.', + 'Du hast bereits eine offene Bewerbung. Bitte warte, bis sie geprüft wurde.', { userId: data.userId, pendingAppId: pendingApp.id } ); } @@ -200,7 +200,7 @@ class ApplicationService { throw createError( 'Invalid review action', ErrorTypes.VALIDATION, - 'Review action must be either approve or deny.', + 'Die Aktion muss entweder „annehmen“ oder „ablehnen“ sein.', { action } ); } @@ -210,7 +210,7 @@ class ApplicationService { throw createError( 'Application not found', ErrorTypes.CONFIGURATION, - 'The application you are trying to review does not exist.', + 'Die Bewerbung, die du prüfen willst, existiert nicht.', { applicationId, guildId } ); } @@ -219,13 +219,13 @@ class ApplicationService { throw createError( 'Application already processed', ErrorTypes.VALIDATION, - 'This application has already been reviewed.', + 'Diese Bewerbung wurde bereits geprüft.', { applicationId, status: application.status } ); } const status = action === 'approve' ? 'approved' : 'denied'; - const sanitizedReason = reason ? reason.trim().substring(0, 500) : 'No reason provided.'; + const sanitizedReason = reason ? reason.trim().substring(0, 500) : 'Kein Grund angegeben.'; const updatedApplication = await updateApplication(client, guildId, applicationId, { status, @@ -275,7 +275,7 @@ class ApplicationService { throw createError( 'Failed to retrieve applications', ErrorTypes.DATABASE, - 'An error occurred while retrieving applications.', + 'Beim Laden der Bewerbungen ist ein Fehler aufgetreten.', { guildId, filters } ); } @@ -288,7 +288,7 @@ class ApplicationService { throw createError( 'Invalid log channel ID', ErrorTypes.VALIDATION, - 'Invalid channel ID provided.', + 'Ungültige Kanal-ID angegeben.', { logChannelId: updates.logChannelId } ); } @@ -297,7 +297,7 @@ class ApplicationService { throw createError( 'Invalid manager roles format', ErrorTypes.VALIDATION, - 'Manager roles must be an array.', + 'Die Manager-Rollen müssen eine Liste sein.', { managerRoles: updates.managerRoles } ); } @@ -307,7 +307,7 @@ class ApplicationService { throw createError( 'Invalid questions format', ErrorTypes.VALIDATION, - 'Questions must be a non-empty array.', + 'Die Fragen müssen eine nicht-leere Liste sein.', { questions: updates.questions } ); } @@ -348,7 +348,7 @@ class ApplicationService { throw createError( 'Missing role ID', ErrorTypes.VALIDATION, - 'You must specify a role to add.', + 'Du musst eine Rolle zum Hinzufügen angeben.', { action } ); } @@ -357,14 +357,14 @@ class ApplicationService { throw createError( 'Role already configured', ErrorTypes.VALIDATION, - 'This role is already configured for applications.', + 'Diese Rolle ist bereits für Bewerbungen eingerichtet.', { roleId } ); } currentRoles.push({ roleId, - name: name ? name.trim().substring(0, 50) : 'Application Role' + name: name ? name.trim().substring(0, 50) : 'Bewerbungsrolle' }); await saveApplicationRoles(client, guildId, currentRoles); @@ -379,7 +379,7 @@ class ApplicationService { throw createError( 'Missing role ID', ErrorTypes.VALIDATION, - 'You must specify a role to remove.', + 'Du musst eine Rolle zum Entfernen angeben.', { action } ); } @@ -389,7 +389,7 @@ class ApplicationService { throw createError( 'Role not configured', ErrorTypes.VALIDATION, - 'This role is not configured for applications.', + 'Diese Rolle ist nicht für Bewerbungen eingerichtet.', { roleId } ); } @@ -436,7 +436,7 @@ class ApplicationService { throw createError( 'Failed to retrieve your applications', ErrorTypes.DATABASE, - 'An error occurred while retrieving your applications.', + 'Beim Laden deiner Bewerbungen ist ein Fehler aufgetreten.', { guildId, userId } ); } @@ -450,7 +450,7 @@ class ApplicationService { throw createError( 'Application not found', ErrorTypes.CONFIGURATION, - 'The application you are looking for does not exist.', + 'Die gesuchte Bewerbung existiert nicht.', { applicationId, guildId } ); } diff --git a/src/services/birthdayService.ts b/src/services/birthdayService.ts index 8c489c4661..bd634668eb 100644 --- a/src/services/birthdayService.ts +++ b/src/services/birthdayService.ts @@ -14,21 +14,21 @@ export function validateBirthday(month: unknown, day: unknown): BirthdayValidati if (typeof month !== 'number' || typeof day !== 'number') { return { isValid: false, - error: 'Month and day must be numbers' + error: 'Monat und Tag müssen Zahlen sein.' }; } if (month < 1 || month > 12) { return { isValid: false, - error: 'Month must be between 1 and 12' + error: 'Der Monat muss zwischen 1 und 12 liegen.' }; } if (day < 1 || day > 31) { return { isValid: false, - error: 'Day must be between 1 and 31' + error: 'Der Tag muss zwischen 1 und 31 liegen.' }; } @@ -38,7 +38,7 @@ export function validateBirthday(month: unknown, day: unknown): BirthdayValidati if (isNaN(date.getTime()) || date.getMonth() !== month - 1 || date.getDate() !== day) { return { isValid: false, - error: 'Invalid date. Please check the month and day combination (e.g., February 29th only exists in leap years)' + error: 'Ungültiges Datum. Bitte prüfe die Kombination aus Monat und Tag (z. B. gibt es den 29. Februar nur in Schaltjahren).' }; } @@ -72,7 +72,7 @@ export async function setBirthday(client: NexoClient, guildId: string, userId: s throw new NexoBotError( 'Failed to save birthday to database', ErrorTypes.DATABASE, - 'Failed to set your birthday. Please try again later.', + 'Dein Geburtstag konnte nicht gespeichert werden. Bitte versuch es später erneut.', { userId, guildId, month, day } ); } @@ -177,7 +177,7 @@ export async function deleteBirthday(client: NexoClient, guildId: string, userId throw new NexoBotError( 'Failed to delete birthday from database', ErrorTypes.DATABASE, - 'Failed to remove your birthday. Please try again.', + 'Dein Geburtstag konnte nicht entfernt werden. Bitte versuch es erneut.', { userId, guildId } ); } @@ -317,7 +317,7 @@ export async function checkBirthdays(client: NexoClient): Promise { 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"); + await member.roles.remove(birthdayRoleId, "Geburtstagsrolle abgelaufen"); } } delete updatedTrackingData[userId]; @@ -340,7 +340,7 @@ export async function checkBirthdays(client: NexoClient): Promise { birthdayMembers.push(member); if (birthdayRoleId) { try { - await member.roles.add(birthdayRoleId, "Happy Birthday! 🎉"); + await member.roles.add(birthdayRoleId, "Alles Gute zum Geburtstag! 🎉"); updatedTrackingData[userId] = true; } catch (error) { logger.error(`Error adding birthday role to ${member.user.tag}:`, error); @@ -356,10 +356,10 @@ export async function checkBirthdays(client: NexoClient): Promise { await channel.send({ embeds: [{ - title: '🎉 Happy Birthday! 🎂', - description: `A very happy birthday to ${mentionList}! Wishing you an amazing day! 🎈`, + title: '🎉 Alles Gute zum Geburtstag! 🎂', + description: `Alles Liebe zum Geburtstag, ${mentionList}! Auf einen großartigen Tag! 🎈`, color: 0xff69b4, - footer: { text: 'Birthday Bot' }, + footer: { text: 'Nexo Geburtstage' }, timestamp: new Date().toISOString() }] }); diff --git a/src/services/commandAccessService.ts b/src/services/commandAccessService.ts index 273a3ccb91..b974c7ccb2 100644 --- a/src/services/commandAccessService.ts +++ b/src/services/commandAccessService.ts @@ -99,7 +99,7 @@ export function buildCommandRegistry(client: NexoClient): Map ({ service: 'ConfigService', operation: methodName, message: `Configuration service operation failed: ${methodName}`, - userMessage: 'A configuration operation failed. Please try again in a moment.' + userMessage: 'Ein Konfigurationsvorgang ist fehlgeschlagen. Bitte versuch es gleich noch mal.' })); export default ConfigService; \ No newline at end of file diff --git a/src/services/config/guildConfig.ts b/src/services/config/guildConfig.ts index 99c62b5c88..48f8044315 100644 --- a/src/services/config/guildConfig.ts +++ b/src/services/config/guildConfig.ts @@ -16,7 +16,7 @@ export const getGuildConfig = wrapServiceBoundary(async function getGuildConfig( service: 'guildConfigService', operation: 'getGuildConfig', message: 'Failed to fetch guild configuration', - userMessage: 'Failed to load server configuration. Please try again.', + userMessage: 'Die Server-Konfiguration konnte nicht geladen werden. Bitte versuch es erneut.', }); export const setGuildConfig = wrapServiceBoundary(async function setGuildConfig(client: NexoClient, guildId: string, config: unknown, context: GuildConfigContext = {}): Promise { @@ -26,7 +26,7 @@ export const setGuildConfig = wrapServiceBoundary(async function setGuildConfig( service: 'guildConfigService', operation: 'setGuildConfig', message: 'Failed to save guild configuration', - userMessage: 'Failed to save server configuration. Please try again.', + userMessage: 'Die Server-Konfiguration konnte nicht gespeichert werden. Bitte versuch es erneut.', }); export const updateGuildConfig = wrapServiceBoundary(async function updateGuildConfig(client: NexoClient, guildId: string, updates: Record, context: GuildConfigContext = {}): Promise { @@ -38,7 +38,7 @@ export const updateGuildConfig = wrapServiceBoundary(async function updateGuildC service: 'guildConfigService', operation: 'updateGuildConfig', message: 'Failed to update guild configuration', - userMessage: 'Failed to update server configuration. Please try again.', + userMessage: 'Die Server-Konfiguration konnte nicht aktualisiert werden. Bitte versuch es erneut.', }); export const getConfigValue = wrapServiceBoundary(async function getConfigValue(client: NexoClient, guildId: string, key: string, defaultValue: unknown = null, context: GuildConfigContext = {}): Promise { @@ -48,7 +48,7 @@ export const getConfigValue = wrapServiceBoundary(async function getConfigValue( service: 'guildConfigService', operation: 'getConfigValue', message: 'Failed to read guild configuration value', - userMessage: 'Failed to read a server setting. Please try again.', + userMessage: 'Eine Server-Einstellung konnte nicht gelesen werden. Bitte versuch es erneut.', }); export const setConfigValue = wrapServiceBoundary(async function setConfigValue(client: NexoClient, guildId: string, key: string, value: unknown, context: GuildConfigContext = {}): Promise { @@ -57,7 +57,7 @@ export const setConfigValue = wrapServiceBoundary(async function setConfigValue( service: 'guildConfigService', operation: 'setConfigValue', message: 'Failed to update guild configuration value', - userMessage: 'Failed to update a server setting. Please try again.', + userMessage: 'Eine Server-Einstellung konnte nicht aktualisiert werden. Bitte versuch es erneut.', }); /** @@ -68,7 +68,7 @@ export const patchGuildConfig = wrapServiceBoundary(async function patchGuildCon throw createError( 'Invalid guild config patch', ErrorTypes.VALIDATION, - 'Invalid configuration update.', + 'Ungültige Konfigurationsänderung.', { guildId, ...context }, ); } @@ -82,7 +82,7 @@ export const patchGuildConfig = wrapServiceBoundary(async function patchGuildCon service: 'guildConfigService', operation: 'patchGuildConfig', message: 'Failed to patch guild configuration', - userMessage: 'Failed to update server configuration. Please try again.', + userMessage: 'Die Server-Konfiguration konnte nicht aktualisiert werden. Bitte versuch es erneut.', }); function deepMergeGuildConfig(base: Record, patch: Record): Record { diff --git a/src/services/countingGameService.ts b/src/services/countingGameService.ts index f6042d6556..89f475708d 100644 --- a/src/services/countingGameService.ts +++ b/src/services/countingGameService.ts @@ -26,8 +26,8 @@ const COUNTING_GAME_KEY_PREFIX = 'countingGame:'; const COUNTING_SYSTEMS: Record = { decimal: { - label: 'Decimal', - description: 'Standard 10-number system using 0-9', + label: 'Dezimal', + description: 'Standard-Zehnersystem mit 0-9', toString: (n) => n.toString(10), parse: (value) => { if (!/^[0-9]+$/.test(value)) return null; @@ -35,8 +35,8 @@ const COUNTING_SYSTEMS: Record = { }, }, hexadecimal: { - label: 'Hexadecimal', - description: '16-number system using 0-9 and A-F', + label: 'Hexadezimal', + description: '16er-System mit 0-9 und A-F', toString: (n) => n.toString(16).toUpperCase(), parse: (value) => { if (!/^[0-9A-Fa-f]+$/.test(value)) return null; @@ -44,8 +44,8 @@ const COUNTING_SYSTEMS: Record = { }, }, binary: { - label: 'Binary', - description: '2-number system using 0-1', + label: 'Binär', + description: 'Zweiersystem mit 0-1', toString: (n) => n.toString(2), parse: (value) => { if (!/^[01]+$/.test(value)) return null; @@ -54,7 +54,7 @@ const COUNTING_SYSTEMS: Record = { }, base36: { label: 'Base36', - description: '36-number system using 0-9 and A-Z', + description: '36er-System mit 0-9 und A-Z', toString: (n) => n.toString(36).toUpperCase(), parse: (value) => { if (!/^[0-9A-Za-z]+$/.test(value)) return null; @@ -63,7 +63,7 @@ const COUNTING_SYSTEMS: Record = { }, base64: { label: 'Base64', - description: '64-number system using A-Z, a-z, 0-9, +, /', + description: '64er-System mit A-Z, a-z, 0-9, +, /', alphabet: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/', toString: (n) => { if (n === 0) return 'A'; @@ -93,8 +93,8 @@ const COUNTING_SYSTEMS: Record = { }, }, roman: { - label: 'Roman', - description: 'Roman numerals like I, II, III, IV, V', + label: 'Römisch', + description: 'Römische Zahlen wie I, II, III, IV, V', toString: (n) => { const romanNumerals: Array<[string, number]> = [ ['M', 1000], ['CM', 900], ['D', 500], ['CD', 400], @@ -133,8 +133,8 @@ const COUNTING_SYSTEMS: Record = { }, }, math: { - label: 'Math Expressions', - description: 'Use a math expression that equals the next number, like 4*4=16', + label: 'Mathe-Ausdrücke', + description: 'Ein Rechenausdruck, der die nächste Zahl ergibt, z. B. 4*4=16', toString: (n) => `${n}`, parse: (value) => { const expression = value.replace(/\s+/g, ''); @@ -168,7 +168,7 @@ const COUNTING_SYSTEMS: Record = { }, alphabet: { label: 'Alphabet', - description: 'Letters A-Z in sequence', + description: 'Buchstaben A-Z der Reihe nach', toString: (n) => { let num = n; let result = ''; @@ -328,6 +328,6 @@ export function buildCountingLeaderboard(config: CountingGameState, guild: Guild .map(([userId, count], index) => { const member = guild?.members?.cache?.get(userId); const username = member ? `${member.user.username}#${member.user.discriminator}` : `<@${userId}>`; - return `**${index + 1}.** ${username} — ${count} ${count === 1 ? 'count' : 'counts'}`; + return `**${index + 1}.** ${username} — ${count} ${count === 1 ? 'Zahl' : 'Zahlen'}`; }); } diff --git a/src/services/economyService.ts b/src/services/economyService.ts index 488b13f9ef..62cd4d4a25 100644 --- a/src/services/economyService.ts +++ b/src/services/economyService.ts @@ -27,7 +27,7 @@ class EconomyService { throw createError( "Invalid balance state", ErrorTypes.VALIDATION, - "Operation would create an invalid account balance.", + "Dieser Vorgang würde einen ungültigen Kontostand erzeugen.", { value, ...context } ); } @@ -42,7 +42,7 @@ class EconomyService { throw createError( "Failed to load economy data", ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", + "Deine Economy-Daten konnten nicht geladen werden. Bitte versuch es später erneut.", { userId, guildId } ); } @@ -59,7 +59,7 @@ class EconomyService { throw createError( "Daily cooldown active", ErrorTypes.RATE_LIMIT, - `You need to wait before claiming daily again. Try again in **${this.formatDuration(remaining)}**.`, + `Du musst noch warten, bevor du deine tägliche Belohnung wieder abholen kannst. Versuch es in **${this.formatDuration(remaining)}** erneut.`, { remaining, cooldownType: 'daily' } ); } @@ -96,7 +96,7 @@ class EconomyService { throw createError( "Failed to save daily claim", ErrorTypes.DATABASE, - "Failed to process your daily. Please try again.", + "Deine tägliche Belohnung konnte nicht verarbeitet werden. Bitte versuch es erneut.", { userId, guildId } ); } @@ -114,7 +114,7 @@ class EconomyService { throw createError( "Invalid transfer amount", ErrorTypes.VALIDATION, - "Amount must be greater than zero.", + "Der Betrag muss größer als null sein.", { amount, senderId } ); } @@ -123,7 +123,7 @@ class EconomyService { throw createError( "Cannot pay self", ErrorTypes.VALIDATION, - "You cannot pay yourself.", + "Du kannst dir nicht selbst Geld schicken.", { senderId, receiverId } ); } @@ -143,7 +143,7 @@ class EconomyService { throw createError( "Failed to load economy data", ErrorTypes.DATABASE, - "Failed to load economy data. Please try again later.", + "Die Economy-Daten konnten nicht geladen werden. Bitte versuch es später erneut.", { senderId, receiverId, guildId } ); } @@ -157,7 +157,7 @@ class EconomyService { throw createError( "Insufficient funds", ErrorTypes.VALIDATION, - `You only have **$${senderData.wallet.toLocaleString()}** in cash.`, + `Du hast nur **$${senderData.wallet.toLocaleString()}** in deiner Geldbörse.`, { required: amount, available: senderData.wallet, senderId } ); } @@ -223,7 +223,7 @@ class EconomyService { throw createError( "Failed to save transfer", ErrorTypes.DATABASE, - "Failed to process transfer. Please try again.", + "Die Überweisung konnte nicht verarbeitet werden. Bitte versuch es erneut.", { senderId, receiverId, amount } ); } @@ -234,7 +234,7 @@ class EconomyService { throw createError( "Invalid amount", ErrorTypes.VALIDATION, - "Amount must be positive", + "Der Betrag muss positiv sein.", { amount, userId, source } ); } @@ -268,7 +268,7 @@ class EconomyService { throw createError( "Invalid amount", ErrorTypes.VALIDATION, - "Amount must be positive", + "Der Betrag muss positiv sein.", { amount, userId, reason } ); } @@ -282,7 +282,7 @@ class EconomyService { throw createError( "Insufficient funds", ErrorTypes.VALIDATION, - `You only have **$${balanceBefore.toLocaleString()}**.`, + `Du hast nur **$${balanceBefore.toLocaleString()}**.`, { required: amount, available: balanceBefore, reason } ); } @@ -315,7 +315,7 @@ class EconomyService { throw createError( "Insufficient cash", ErrorTypes.VALIDATION, - `You only have **$${userData.wallet.toLocaleString()}** in cash.`, + `Du hast nur **$${userData.wallet.toLocaleString()}** in deiner Geldbörse.`, { required: amount, available: userData.wallet } ); } @@ -325,7 +325,7 @@ class EconomyService { throw createError( "Bank capacity exceeded", ErrorTypes.VALIDATION, - `Your bank can only hold **$${maxBank.toLocaleString()}**. You would exceed capacity by **$${(currentBank + amount - maxBank).toLocaleString()}**.`, + `Deine Bank fasst nur **$${maxBank.toLocaleString()}**. Du würdest das Limit um **$${(currentBank + amount - maxBank).toLocaleString()}** überschreiten.`, { capacity: maxBank, current: currentBank, requested: amount } ); } @@ -363,7 +363,7 @@ class EconomyService { throw createError( "Insufficient bank balance", ErrorTypes.VALIDATION, - `You only have **$${bank.toLocaleString()}** in your bank.`, + `Du hast nur **$${bank.toLocaleString()}** auf deiner Bank.`, { required: amount, available: bank } ); } @@ -410,7 +410,7 @@ class EconomyService { throw createError( "Invalid amount - not an integer", ErrorTypes.VALIDATION, - "Amount must be a whole number", + "Der Betrag muss eine ganze Zahl sein.", context ); } @@ -419,7 +419,7 @@ class EconomyService { throw createError( "Invalid amount - not positive", ErrorTypes.VALIDATION, - "Amount must be positive", + "Der Betrag muss positiv sein.", context ); } @@ -429,7 +429,7 @@ class EconomyService { throw createError( "Amount too large", ErrorTypes.VALIDATION, - "The amount is too large to process", + "Der Betrag ist zu groß.", context ); } @@ -460,7 +460,7 @@ wrapServiceClassMethods(EconomyService, (methodName) => ({ service: 'EconomyService', operation: methodName, message: `Economy service operation failed: ${methodName}`, - userMessage: 'An economy operation failed. Please try again in a moment.' + userMessage: 'Ein Economy-Vorgang ist fehlgeschlagen. Bitte versuch es gleich noch mal.' })); export default EconomyService; \ No newline at end of file diff --git a/src/services/giveawayService.ts b/src/services/giveawayService.ts index 7da363f78a..c3c7203b57 100644 --- a/src/services/giveawayService.ts +++ b/src/services/giveawayService.ts @@ -23,7 +23,7 @@ export function parseDuration(durationString: unknown): number { throw new NexoBotError( 'Invalid duration format provided', ErrorTypes.VALIDATION, - 'Please provide a valid duration (e.g., 1h, 30m, 5d, 10s).', + 'Bitte gib eine gültige Dauer an (z. B. 1h, 30m, 5d, 10s).', { durationString } ); } @@ -35,7 +35,7 @@ export function parseDuration(durationString: unknown): number { throw new NexoBotError( `Invalid duration format: ${durationString}`, ErrorTypes.VALIDATION, - 'Invalid duration format. Use: 1h, 30m, 5d, 10s (min: 10s, max: 30d)', + 'Ungültiges Format für die Dauer. Nutze: 1h, 30m, 5d, 10s (min.: 10s, max.: 30d)', { input: durationString } ); } @@ -47,7 +47,7 @@ export function parseDuration(durationString: unknown): number { throw new NexoBotError( `Duration amount out of range: ${amount}`, ErrorTypes.VALIDATION, - 'Duration amount must be between 1 and 999.', + 'Der Wert für die Dauer muss zwischen 1 und 999 liegen.', { amount, unit } ); } @@ -70,7 +70,7 @@ export function parseDuration(durationString: unknown): number { throw new NexoBotError( `Unknown duration unit: ${unit}`, ErrorTypes.VALIDATION, - 'Please use s (seconds), m (minutes), h (hours), or d (days).', + 'Bitte nutze s (Sekunden), m (Minuten), h (Stunden) oder d (Tage).', { unit } ); } @@ -80,7 +80,7 @@ export function parseDuration(durationString: unknown): number { throw new NexoBotError( `Duration exceeds maximum: ${ms}ms > ${maxDuration}ms`, ErrorTypes.VALIDATION, - `Maximum duration is ${Math.floor(maxDuration / (24 * 60 * 60 * 1000))} days.`, + `Die maximale Dauer beträgt ${Math.floor(maxDuration / (24 * 60 * 60 * 1000))} Tage.`, { requestedMs: ms, maxMs: maxDuration } ); } @@ -90,7 +90,7 @@ export function parseDuration(durationString: unknown): number { throw new NexoBotError( `Duration below minimum: ${ms}ms < ${minDuration}ms`, ErrorTypes.VALIDATION, - `Minimum duration is ${Math.ceil(minDuration / 1000)} seconds.`, + `Die minimale Dauer beträgt ${Math.ceil(minDuration / 1000)} Sekunden.`, { requestedMs: ms, minMs: minDuration } ); } @@ -103,7 +103,7 @@ export function validatePrize(prize: unknown): string { throw new NexoBotError( 'Prize must be a non-empty string', ErrorTypes.VALIDATION, - 'Please provide a valid prize description.', + 'Bitte gib eine gültige Preisbeschreibung an.', { prize } ); } @@ -113,7 +113,7 @@ export function validatePrize(prize: unknown): string { throw new NexoBotError( `Prize length out of range: ${trimmed.length}`, ErrorTypes.VALIDATION, - 'Prize must be between 1 and 256 characters.', + 'Der Preis muss zwischen 1 und 256 Zeichen lang sein.', { length: trimmed.length } ); } @@ -129,7 +129,7 @@ export function validateWinnerCount(winnerCount: number): void { throw new NexoBotError( `Invalid winner count: ${winnerCount}`, ErrorTypes.VALIDATION, - `Winner count must be between ${minimumWinners} and ${maximumWinners}.`, + `Die Anzahl der Gewinner muss zwischen ${minimumWinners} und ${maximumWinners} liegen.`, { winnerCount, minimumWinners, maximumWinners } ); } @@ -143,22 +143,22 @@ export function createGiveawayEmbed(giveaway: Giveaway, status: string, winners: const embed = new EmbedBuilder() .setTitle(`${statusEmoji} ${giveaway.prize}`) - .setDescription('React with the button below to enter!') + .setDescription('Klick auf den Button unten, um teilzunehmen!') .setColor(color) .addFields( - { name: '👤 Hosted by', value: `<@${giveaway.hostId}>`, inline: true }, - { name: '🏆 Winners', value: giveaway.winnerCount!.toString(), inline: true }, - { name: '👥 Entries', value: giveaway.participants?.length?.toString() || '0', inline: true } + { name: '👤 Veranstaltet von', value: `<@${giveaway.hostId}>`, inline: true }, + { name: '🏆 Gewinner', value: giveaway.winnerCount!.toString(), inline: true }, + { name: '👥 Teilnahmen', value: giveaway.participants?.length?.toString() || '0', inline: true } ); if (isEnded) { const winnerDisplay = winners.length > 0 ? winners.map(id => `<@${id}>`).join(', ') - : 'No valid entries'; - embed.addFields({ name: '🎯 Winners', value: winnerDisplay, inline: false }); + : 'Keine gültigen Teilnahmen'; + embed.addFields({ name: '🎯 Gewinner', value: winnerDisplay, inline: false }); } else { const endTime = giveaway.endsAt || giveaway.endTime; - embed.addFields({ name: '⏰ Ends', value: ``, inline: false }); + embed.addFields({ name: '⏰ Endet', value: ``, inline: false }); } embed.setTimestamp(); @@ -169,7 +169,7 @@ export function createGiveawayEmbed(giveaway: Giveaway, status: string, winners: throw new NexoBotError( 'Failed to create giveaway embed', ErrorTypes.UNKNOWN, - 'An internal error occurred while formatting the giveaway.', + 'Beim Erstellen des Giveaways ist ein interner Fehler aufgetreten.', { error: (error as Error).message } ); } @@ -183,12 +183,12 @@ export function createGiveawayButtons(ended: boolean = false): ActionRowBuilder< row.addComponents( new ButtonBuilder() .setCustomId('giveaway_reroll') - .setLabel('🎲 Reroll') + .setLabel('🎲 Neu auslosen') .setStyle(ButtonStyle.Secondary) .setDisabled(false), new ButtonBuilder() .setCustomId('giveaway_view') - .setLabel('👁️ View Winners') + .setLabel('👁️ Gewinner anzeigen') .setStyle(ButtonStyle.Primary) .setDisabled(false) ); @@ -196,12 +196,12 @@ export function createGiveawayButtons(ended: boolean = false): ActionRowBuilder< row.addComponents( new ButtonBuilder() .setCustomId('giveaway_join') - .setLabel('🎉 Join') + .setLabel('🎉 Teilnehmen') .setStyle(ButtonStyle.Primary) .setDisabled(false), new ButtonBuilder() .setCustomId('giveaway_end') - .setLabel('🛑 End') + .setLabel('🛑 Beenden') .setStyle(ButtonStyle.Danger) .setDisabled(false) ); @@ -213,7 +213,7 @@ export function createGiveawayButtons(ended: boolean = false): ActionRowBuilder< throw new NexoBotError( 'Failed to create giveaway buttons', ErrorTypes.UNKNOWN, - 'An internal error occurred while creating interactive buttons.', + 'Beim Erstellen der Buttons ist ein interner Fehler aufgetreten.', { error: (error as Error).message } ); } @@ -230,7 +230,7 @@ export function selectWinners(participants: string[], winnerCount: number): stri throw new NexoBotError( 'Invalid winner count for selection', ErrorTypes.VALIDATION, - 'Winner count must be at least 1.', + 'Es muss mindestens 1 Gewinner geben.', { winnerCount } ); } @@ -250,7 +250,7 @@ export function selectWinners(participants: string[], winnerCount: number): stri throw new NexoBotError( 'Failed to select winners', ErrorTypes.UNKNOWN, - 'An error occurred while selecting winners.', + 'Beim Auslosen der Gewinner ist ein Fehler aufgetreten.', { error: (error as Error).message, participantCount: participants.length } ); } @@ -283,7 +283,7 @@ export async function endGiveaway( throw new NexoBotError( 'Giveaway object is null or undefined', ErrorTypes.VALIDATION, - 'Cannot end a non-existent giveaway.', + 'Ein nicht existierendes Giveaway kann nicht beendet werden.', { giveaway } ); } @@ -292,7 +292,7 @@ export async function endGiveaway( throw new NexoBotError( `Giveaway ${giveaway.messageId} is already ended`, ErrorTypes.VALIDATION, - 'This giveaway has already ended.', + 'Dieses Giveaway ist bereits beendet.', { giveawayId: giveaway.messageId, status: 'already_ended' } ); } @@ -326,7 +326,7 @@ export async function endGiveaway( throw new NexoBotError( 'Failed to end giveaway', ErrorTypes.UNKNOWN, - 'An error occurred while ending the giveaway.', + 'Beim Beenden des Giveaways ist ein Fehler aufgetreten.', { error: (error as Error).message, giveawayId: giveaway?.messageId } ); } @@ -376,7 +376,7 @@ export async function checkGiveaways(client: NexoClient): Promise { const winnerMentions = winners.length > 0 ? winners.map(id => `<@${id}>`).join(', ') - : 'No valid entries!'; + : 'Keine gültigen Teilnahmen!'; const endedEmbed = createGiveawayEmbed(giveaway, 'ended', winners); @@ -396,7 +396,7 @@ export async function checkGiveaways(client: NexoClient): Promise { } if (winners.length > 0) { - const winnerAnnouncement = `🎉 Congratulations ${winnerMentions}! You won the **${giveaway.prize || 'giveaway'}**! Please contact <@${giveaway.hostId}> to claim your prize.`; + const winnerAnnouncement = `🎉 Glückwunsch ${winnerMentions}! Gewonnen: **${giveaway.prize || 'das Giveaway'}**! Meldet euch bei <@${giveaway.hostId}>, um den Preis abzuholen.`; const winnerPingMsg = await channel.send({ content: winnerAnnouncement }); giveaway.winnerPingMessageId = winnerPingMsg.id; await markGiveawayEnded(client, giveawayId, giveaway); @@ -407,21 +407,21 @@ export async function checkGiveaways(client: NexoClient): Promise { guildId, eventType: EVENT_TYPES.GIVEAWAY_WINNER, data: { - description: `Giveaway ended with ${winners.length} winner(s)`, + description: `Giveaway mit ${winners.length} Gewinner(n) beendet`, channelId: channel.id, fields: [ { - name: '🎁 Prize', - value: giveaway.prize || 'Mystery Prize!', + name: '🎁 Preis', + value: giveaway.prize || 'Überraschungspreis!', inline: true }, { - name: '🏆 Winners', + name: '🏆 Gewinner', value: winners.map(id => `<@${id}>`).join(', '), inline: false }, { - name: '👥 Entries', + name: '👥 Teilnahmen', value: participants.length.toString(), inline: true } @@ -432,7 +432,7 @@ export async function checkGiveaways(client: NexoClient): Promise { logger.debug('Error logging giveaway winner:', error); } } else { - await channel.send({ content: `The giveaway for **${giveaway.prize}** has ended with no valid entries.` }); + await channel.send({ content: `Das Giveaway für **${giveaway.prize}** ist ohne gültige Teilnahmen zu Ende gegangen.` }); } logger.info(`Ended giveaway ${messageId} in guild ${guildId}`); diff --git a/src/services/joinToCreateService.ts b/src/services/joinToCreateService.ts index fdcbfb7ff8..299ccf57f5 100644 --- a/src/services/joinToCreateService.ts +++ b/src/services/joinToCreateService.ts @@ -56,7 +56,7 @@ export function validateChannelNameTemplate(template: unknown): boolean { throw new NexoBotError( 'Invalid channel template: must be a non-empty string', ErrorTypes.VALIDATION, - 'Channel name template must be valid text.' + 'Die Vorlage für den Kanalnamen muss gültiger Text sein.' ); } @@ -66,7 +66,7 @@ export function validateChannelNameTemplate(template: unknown): boolean { throw new NexoBotError( 'Channel template exceeds maximum length', ErrorTypes.VALIDATION, - `Channel name template cannot exceed ${CHANNEL_NAME_MAX_LENGTH} characters.` + `Die Vorlage für den Kanalnamen darf höchstens ${CHANNEL_NAME_MAX_LENGTH} Zeichen lang sein.` ); } @@ -74,7 +74,7 @@ export function validateChannelNameTemplate(template: unknown): boolean { throw new NexoBotError( 'Channel template contains forbidden characters', ErrorTypes.VALIDATION, - 'Channel template cannot contain @, #, :, or backtick characters.' + 'Die Kanalvorlage darf keine @, #, : oder Backticks enthalten.' ); } @@ -84,7 +84,7 @@ export function validateChannelNameTemplate(template: unknown): boolean { throw new NexoBotError( 'Channel template contains unknown placeholders', ErrorTypes.VALIDATION, - `Unknown placeholder: ${placeholder}. Allowed placeholders are ${Array.from(ALLOWED_TEMPLATE_PLACEHOLDERS).join(', ')}` + `Unbekannter Platzhalter: ${placeholder}. Erlaubte Platzhalter: ${Array.from(ALLOWED_TEMPLATE_PLACEHOLDERS).join(', ')}` ); } } @@ -99,7 +99,7 @@ export function validateBitrate(bitrate: string | number): boolean { throw new NexoBotError( 'Bitrate must be a valid number', ErrorTypes.VALIDATION, - 'Please enter a valid number for bitrate.' + 'Bitte gib eine gültige Zahl für die Bitrate ein.' ); } @@ -107,7 +107,7 @@ export function validateBitrate(bitrate: string | number): boolean { throw new NexoBotError( 'Bitrate out of valid range', ErrorTypes.VALIDATION, - 'Bitrate must be between 8 and 384 kbps.' + 'Die Bitrate muss zwischen 8 und 384 kbps liegen.' ); } @@ -121,7 +121,7 @@ export function validateUserLimit(limit: string | number): boolean { throw new NexoBotError( 'User limit must be a valid number', ErrorTypes.VALIDATION, - 'Please enter a valid number for user limit.' + 'Bitte gib eine gültige Zahl für das Nutzerlimit ein.' ); } @@ -129,7 +129,7 @@ export function validateUserLimit(limit: string | number): boolean { throw new NexoBotError( 'User limit out of valid range', ErrorTypes.VALIDATION, - 'User limit must be between 0 (no limit) and 99.' + 'Das Nutzerlimit muss zwischen 0 (kein Limit) und 99 liegen.' ); } @@ -152,7 +152,7 @@ export function formatChannelName(template: string, variables: ChannelNameVariab const entries: [string, unknown][] = Object.entries(variables); for (const [key, value] of entries) { if (value === null || value === undefined) { - sanitized[key] = 'Unknown'; + sanitized[key] = 'Unbekannt'; } else { sanitized[key] = String(value) @@ -165,14 +165,14 @@ export function formatChannelName(template: string, variables: ChannelNameVariab } const replacements = { - '{username}': sanitized.username || 'User', - '{user_tag}': sanitized.userTag || 'User#0000', - '{displayName}': sanitized.displayName || 'User', - '{display_name}': sanitized.displayName || 'User', + '{username}': sanitized.username || 'Nutzer', + '{user_tag}': sanitized.userTag || 'Nutzer#0000', + '{displayName}': sanitized.displayName || 'Nutzer', + '{display_name}': sanitized.displayName || 'Nutzer', '{guildName}': sanitized.guildName || 'Server', '{guild_name}': sanitized.guildName || 'Server', - '{channelName}': sanitized.channelName || 'Voice Channel', - '{channel_name}': sanitized.channelName || 'Voice Channel', + '{channelName}': sanitized.channelName || 'Sprachkanal', + '{channel_name}': sanitized.channelName || 'Sprachkanal', }; let formatted = safeTemplate; @@ -188,7 +188,7 @@ export function formatChannelName(template: string, variables: ChannelNameVariab .trim(); if (formatted.length === 0) { - formatted = 'Voice Channel'; + formatted = 'Sprachkanal'; } else if (formatted.length > CHANNEL_NAME_MAX_LENGTH) { formatted = formatted.substring(0, CHANNEL_NAME_MAX_LENGTH); } @@ -213,7 +213,7 @@ export async function initializeJoinToCreate( throw new NexoBotError( 'Database service not available', ErrorTypes.DATABASE, - 'System error occurred. Please try again.' + 'Ein Systemfehler ist aufgetreten. Bitte versuch es erneut.' ); } @@ -221,7 +221,7 @@ export async function initializeJoinToCreate( throw new NexoBotError( 'Missing required guild or channel ID', ErrorTypes.VALIDATION, - 'Invalid guild or channel information provided.' + 'Ungültige Server- oder Kanalangaben.' ); } @@ -241,7 +241,7 @@ export async function initializeJoinToCreate( throw new NexoBotError( 'Channel already configured as Join to Create trigger', ErrorTypes.VALIDATION, - 'This channel is already set up as a Join to Create trigger.' + 'Dieser Kanal ist bereits als Join-to-Create-Auslöser eingerichtet.' ); } @@ -249,7 +249,7 @@ export async function initializeJoinToCreate( throw new NexoBotError( 'Guild already has a Join to Create trigger configured', ErrorTypes.VALIDATION, - 'This server already has a Join to Create channel configured. Use `/jointocreate dashboard` to modify it, or remove it before creating a new one.', + 'Dieser Server hat bereits einen Join-to-Create-Kanal. Nutze `/jointocreate dashboard`, um ihn zu bearbeiten, oder entferne ihn, bevor du einen neuen erstellst.', { guildId, existingTriggerChannelId: config.triggerChannels[0], @@ -281,7 +281,7 @@ export async function initializeJoinToCreate( throw new NexoBotError( 'Failed to save Join to Create configuration', ErrorTypes.DATABASE, - 'Failed to set up Join to Create system. Please try again.' + 'Join-to-Create konnte nicht eingerichtet werden. Bitte versuch es erneut.' ); } @@ -296,7 +296,7 @@ export async function initializeJoinToCreate( throw new NexoBotError( `Failed to initialize Join to Create: ${(error as Error).message}`, ErrorTypes.DATABASE, - 'Failed to set up Join to Create system.' + 'Join-to-Create konnte nicht eingerichtet werden.' ); } } @@ -312,7 +312,7 @@ export async function updateChannelConfig( throw new NexoBotError( 'Database service not available', ErrorTypes.DATABASE, - 'Database service is currently unavailable. Please try again later.' + 'Die Datenbank ist gerade nicht erreichbar. Bitte versuch es später erneut.' ); } @@ -322,7 +322,7 @@ export async function updateChannelConfig( throw new NexoBotError( 'Channel is not configured as a Join to Create trigger', ErrorTypes.VALIDATION, - 'This channel is not set up as a Join to Create trigger.' + 'Dieser Kanal ist nicht als Join-to-Create-Auslöser eingerichtet.' ); } @@ -362,7 +362,7 @@ export async function updateChannelConfig( throw new NexoBotError( `Failed to update channel config: ${(error as Error).message}`, ErrorTypes.DATABASE, - 'Failed to update configuration.' + 'Die Konfiguration konnte nicht aktualisiert werden.' ); } } @@ -373,7 +373,7 @@ export async function removeTriggerChannel(client: NexoClient, guildId: string, throw new NexoBotError( 'Database service not available', ErrorTypes.DATABASE, - 'Database service is currently unavailable. Please try again later.' + 'Die Datenbank ist gerade nicht erreichbar. Bitte versuch es später erneut.' ); } @@ -384,7 +384,7 @@ export async function removeTriggerChannel(client: NexoClient, guildId: string, throw new NexoBotError( 'Channel not found in Join to Create triggers', ErrorTypes.VALIDATION, - 'This channel is not configured as a Join to Create trigger.' + 'Dieser Kanal ist nicht als Join-to-Create-Auslöser eingerichtet.' ); } @@ -416,7 +416,7 @@ export async function removeTriggerChannel(client: NexoClient, guildId: string, throw new NexoBotError( `Failed to remove trigger channel: ${(error as Error).message}`, ErrorTypes.DATABASE, - 'Failed to remove trigger channel.' + 'Der Auslöser-Kanal konnte nicht entfernt werden.' ); } } @@ -427,7 +427,7 @@ export async function getConfiguration(client: NexoClient, guildId: string): Pro throw new NexoBotError( 'Database service not available', ErrorTypes.DATABASE, - 'Database service is currently unavailable. Please try again later.' + 'Die Datenbank ist gerade nicht erreichbar. Bitte versuch es später erneut.' ); } @@ -440,7 +440,7 @@ export async function getConfiguration(client: NexoClient, guildId: string): Pro throw new NexoBotError( `Failed to retrieve configuration: ${(error as Error).message}`, ErrorTypes.DATABASE, - 'Failed to retrieve settings.' + 'Die Einstellungen konnten nicht geladen werden.' ); } } @@ -463,7 +463,7 @@ export async function getChannelConfiguration(client: NexoClient, guildId: strin throw new NexoBotError( 'Channel is not a valid Join to Create trigger', ErrorTypes.VALIDATION, - 'This channel is not set up as a Join to Create trigger.' + 'Dieser Kanal ist nicht als Join-to-Create-Auslöser eingerichtet.' ); } @@ -479,7 +479,7 @@ export async function getChannelConfiguration(client: NexoClient, guildId: strin throw new NexoBotError( `Failed to get channel configuration: ${(error as Error).message}`, ErrorTypes.DATABASE, - 'Failed to retrieve channel configuration. Please try again.' + 'Die Kanalkonfiguration konnte nicht geladen werden. Bitte versuch es erneut.' ); } } @@ -503,9 +503,9 @@ export async function logConfigurationChange(client: NexoClient, guildId: string guildId, eventType: EVENT_TYPES.COUNTER_CONFIG, data: { - title: 'Join to Create Updated', + title: 'Join-to-Create aktualisiert', lines: [ - formatLogLine('Action', action), + formatLogLine('Aktion', action), formatLogLine('Details', typeof details === 'string' ? details : JSON.stringify(details)), ], userId, @@ -546,7 +546,7 @@ export async function createTemporaryChannel( validateBitrate(bitrate / 1000); } - const channelName = formatChannelName(nameTemplate || '{username}\'s Room', { + const channelName = formatChannelName(nameTemplate || 'Raum von {username}', { username: member.user.username, displayName: member.displayName, userTag: member.user.tag, @@ -586,7 +586,7 @@ export async function createTemporaryChannel( throw new NexoBotError( `Failed to create temporary channel: ${(error as Error).message}`, ErrorTypes.DISCORD_API, - 'Failed to create your temporary voice channel. Please contact an administrator.' + 'Dein temporärer Sprachkanal konnte nicht erstellt werden. Bitte wende dich an einen Administrator.' ); } } diff --git a/src/services/leveling/levelRoleSyncService.ts b/src/services/leveling/levelRoleSyncService.ts index 6949d3cbbe..d5ff0b93b5 100644 --- a/src/services/leveling/levelRoleSyncService.ts +++ b/src/services/leveling/levelRoleSyncService.ts @@ -31,7 +31,7 @@ async function tryAwardRole(member: GuildMember, roleId: string, level: number): const role = member.guild.roles.cache.get(roleId) || (await member.guild.roles.fetch(roleId).catch(() => null)); if (!role || member.roles.cache.has(roleId)) return false; - await member.roles.add(role, `Level ${level} reward (startup sync)`); + await member.roles.add(role, `Belohnung für Level ${level} (Sync beim Start)`); return true; } diff --git a/src/services/leveling/leveling.ts b/src/services/leveling/leveling.ts index fa5f02f5b8..230e618ec1 100644 --- a/src/services/leveling/leveling.ts +++ b/src/services/leveling/leveling.ts @@ -52,7 +52,7 @@ export function getXpForLevel(level: number): number { throw new NexoBotError( `Invalid level: ${level}. Must be between ${MIN_LEVEL} and ${MAX_LEVEL}`, ErrorTypes.VALIDATION, - 'The level must be a valid number.' + 'Das Level muss eine gültige Zahl sein.' ); } return 5 * Math.pow(level, 2) + 50 * level + 50; @@ -63,7 +63,7 @@ export function getLevelFromXp(xp: number): { level: number; currentXp: number; throw new NexoBotError( `Invalid XP: ${xp}`, ErrorTypes.VALIDATION, - 'XP must be a non-negative number.' + 'XP müssen eine nicht-negative Zahl sein.' ); } @@ -98,7 +98,7 @@ export async function getLeaderboard(client: NexoClient, guildId: string, limit throw new NexoBotError( 'Invalid guild ID', ErrorTypes.VALIDATION, - 'Guild ID is required.' + 'Eine Server-ID ist erforderlich.' ); } @@ -147,19 +147,19 @@ export async function getLeaderboard(client: NexoClient, guildId: string, limit throw new NexoBotError( `Failed to fetch leaderboard: ${error instanceof Error ? error.message : String(error)}`, ErrorTypes.DATABASE, - 'Could not fetch the leaderboard at this time.' + 'Die Bestenliste konnte gerade nicht geladen werden.' ); } } export function createLeaderboardEmbed(leaderboard: LeaderboardEntry[] | null | undefined, guild: Guild): EmbedBuilder { const embed = new EmbedBuilder() - .setTitle(`🏆 ${guild.name} Leaderboard`) + .setTitle(`🏆 Bestenliste von ${guild.name}`) .setColor('#2ecc71') .setTimestamp(); if (!leaderboard || leaderboard.length === 0) { - embed.setDescription('No users on the leaderboard yet!'); + embed.setDescription('Noch keine Nutzer auf der Bestenliste!'); return embed; } @@ -176,7 +176,7 @@ export function createLeaderboardEmbed(leaderboard: LeaderboardEntry[] | null | }).join('\n'); embed.setDescription( - `**Top Members**\n${top3Text}${restText ? '\n\n' + restText : ''}` + `**Top-Mitglieder**\n${top3Text}${restText ? '\n\n' + restText : ''}` ); return embed; @@ -189,7 +189,7 @@ export async function getLevelingConfig(client: NexoClient, guildId: string): Pr enabled: true, xpPerMessage: { min: 15, max: 25 }, xpCooldown: 20, - levelUpMessage: '{user} has leveled up to level {level}!', + levelUpMessage: '{user} ist auf Level {level} aufgestiegen!', levelUpChannel: null, ignoredChannels: [], ignoredRoles: [], @@ -204,7 +204,7 @@ export async function getLevelingConfig(client: NexoClient, guildId: string): Pr enabled: true, xpPerMessage: { min: 15, max: 25 }, xpCooldown: 20, - levelUpMessage: '{user} has leveled up to level {level}!', + levelUpMessage: '{user} ist auf Level {level} aufgestiegen!', levelUpChannel: null, ignoredChannels: [], ignoredRoles: [], @@ -251,7 +251,7 @@ export async function getUserLevelData(client: NexoClient, guildId: string, user throw new NexoBotError( `Failed to fetch user data: ${error instanceof Error ? error.message : String(error)}`, ErrorTypes.DATABASE, - 'Could not fetch level data at this time.' + 'Die Level-Daten konnten gerade nicht geladen werden.' ); } } @@ -288,7 +288,7 @@ export async function saveUserLevelData(client: NexoClient, guildId: string, use throw new NexoBotError( `Failed to save user data: ${error instanceof Error ? error.message : String(error)}`, ErrorTypes.DATABASE, - 'Could not save level data at this time.' + 'Die Level-Daten konnten gerade nicht gespeichert werden.' ); } } @@ -308,7 +308,7 @@ export async function saveLevelingConfig(client: NexoClient, guildId: string, co throw new NexoBotError( 'XP cooldown must be between 0 and 3600 seconds', ErrorTypes.VALIDATION, - 'Cooldown must be between 0 and 3600 seconds.' + 'Der Cooldown muss zwischen 0 und 3600 Sekunden liegen.' ); } @@ -316,7 +316,7 @@ export async function saveLevelingConfig(client: NexoClient, guildId: string, co throw new NexoBotError( 'Invalid XP range configuration', ErrorTypes.VALIDATION, - 'Minimum XP must be less than maximum XP, and both must be positive.' + 'Die minimalen XP müssen kleiner als die maximalen XP sein, und beide müssen positiv sein.' ); } @@ -330,7 +330,7 @@ export async function saveLevelingConfig(client: NexoClient, guildId: string, co throw new NexoBotError( `Failed to save config: ${error instanceof Error ? error.message : String(error)}`, ErrorTypes.DATABASE, - 'Could not save configuration at this time.' + 'Die Konfiguration konnte gerade nicht gespeichert werden.' ); } } @@ -342,7 +342,7 @@ export async function addLevels(client: NexoClient, guildId: string, userId: str throw new NexoBotError( 'Leveling system is disabled on this server', ErrorTypes.CONFIGURATION, - 'The leveling system is currently disabled on this server.' + 'Das Levelsystem ist auf diesem Server derzeit deaktiviert.' ); } @@ -350,7 +350,7 @@ export async function addLevels(client: NexoClient, guildId: string, userId: str throw new NexoBotError( `Invalid level amount: ${levels}`, ErrorTypes.VALIDATION, - 'You must add a positive number of levels.' + 'Du musst eine positive Anzahl an Leveln hinzufügen.' ); } @@ -361,7 +361,7 @@ export async function addLevels(client: NexoClient, guildId: string, userId: str throw new NexoBotError( `Level ${newLevel} exceeds maximum level ${MAX_LEVEL}`, ErrorTypes.VALIDATION, - `Maximum level is ${MAX_LEVEL}.` + `Das maximale Level ist ${MAX_LEVEL}.` ); } @@ -382,7 +382,7 @@ export async function addLevels(client: NexoClient, guildId: string, userId: str throw new NexoBotError( `Failed to add levels: ${error instanceof Error ? error.message : String(error)}`, ErrorTypes.DATABASE, - 'Could not add levels at this time.' + 'Level konnten gerade nicht hinzugefügt werden.' ); } } @@ -394,7 +394,7 @@ export async function removeLevels(client: NexoClient, guildId: string, userId: throw new NexoBotError( 'Leveling system is disabled on this server', ErrorTypes.CONFIGURATION, - 'The leveling system is currently disabled on this server.' + 'Das Levelsystem ist auf diesem Server derzeit deaktiviert.' ); } @@ -402,7 +402,7 @@ export async function removeLevels(client: NexoClient, guildId: string, userId: throw new NexoBotError( `Invalid level amount: ${levels}`, ErrorTypes.VALIDATION, - 'You must remove a positive number of levels.' + 'Du musst eine positive Anzahl an Leveln entfernen.' ); } @@ -426,7 +426,7 @@ export async function removeLevels(client: NexoClient, guildId: string, userId: throw new NexoBotError( `Failed to remove levels: ${error instanceof Error ? error.message : String(error)}`, ErrorTypes.DATABASE, - 'Could not remove levels at this time.' + 'Level konnten gerade nicht entfernt werden.' ); } } @@ -438,7 +438,7 @@ export async function setUserLevel(client: NexoClient, guildId: string, userId: throw new NexoBotError( 'Leveling system is disabled on this server', ErrorTypes.CONFIGURATION, - 'The leveling system is currently disabled on this server.' + 'Das Levelsystem ist auf diesem Server derzeit deaktiviert.' ); } @@ -446,7 +446,7 @@ export async function setUserLevel(client: NexoClient, guildId: string, userId: throw new NexoBotError( `Invalid level: ${level}`, ErrorTypes.VALIDATION, - `Level must be between ${MIN_LEVEL} and ${MAX_LEVEL}.` + `Das Level muss zwischen ${MIN_LEVEL} und ${MAX_LEVEL} liegen.` ); } @@ -469,7 +469,7 @@ export async function setUserLevel(client: NexoClient, guildId: string, userId: throw new NexoBotError( `Failed to set level: ${error instanceof Error ? error.message : String(error)}`, ErrorTypes.DATABASE, - 'Could not set level at this time.' + 'Das Level konnte gerade nicht gesetzt werden.' ); } } diff --git a/src/services/leveling/xpSystem.ts b/src/services/leveling/xpSystem.ts index f17c7f94b3..8420d860e5 100644 --- a/src/services/leveling/xpSystem.ts +++ b/src/services/leveling/xpSystem.ts @@ -69,12 +69,12 @@ export const addXp = wrapServiceBoundary(async function addXp(client: NexoClient guildId: guild.id, eventType: EVENT_TYPES.LEVELING_LEVELUP, data: { - title: 'Level Up', + 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()), + formatLogLine('Mitglied', `${member.user.tag} (\`${member.user.id}\`)`), + formatLogLine('Neues Level', levelData.level.toString()), + formatLogLine('Gewonnene Level', (levelData.level - initialLevel).toString()), + formatLogLine('Gesamt-XP', levelData.totalXp.toString()), ], userId: member.user.id, }, @@ -97,7 +97,7 @@ export const addXp = wrapServiceBoundary(async function addXp(client: NexoClient }, { service: 'xpSystem', operation: 'addXp', - userMessage: 'Failed to award XP. Please try again.', + userMessage: 'XP konnten nicht vergeben werden. Bitte versuch es erneut.', }); async function awardRoleReward(guild: Guild, member: GuildMember, roleId: string, level: number): Promise { @@ -113,7 +113,7 @@ async function awardRoleReward(guild: Guild, member: GuildMember, roleId: string return; } - await member.roles.add(role, `Level ${level} reward`); + await member.roles.add(role, `Belohnung für Level ${level}`); 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); diff --git a/src/services/loggingService.ts b/src/services/loggingService.ts index 5bdb9ebba4..6a0b51ca4b 100644 --- a/src/services/loggingService.ts +++ b/src/services/loggingService.ts @@ -182,6 +182,47 @@ const EVENT_ICONS: Record = { 'report.file': '🚨', }; +/** German display titles for log embeds that do not pass their own title. */ +const EVENT_TITLES: Record = { + 'moderation.ban': 'Mitglied gebannt', + 'moderation.kick': 'Mitglied gekickt', + 'moderation.mute': 'Mitglied stummgeschaltet', + 'moderation.warn': 'Mitglied verwarnt', + 'moderation.purge': 'Nachrichten gelöscht', + 'moderation.timeout': 'Timeout', + 'moderation.untimeout': 'Timeout aufgehoben', + 'moderation.unban': 'Bann aufgehoben', + 'moderation.lock': 'Kanal gesperrt', + 'moderation.unlock': 'Kanal entsperrt', + 'moderation.dm': 'Moderations-DM', + 'moderation.config': 'Moderations-Einstellungen', + 'leveling.levelup': 'Level-Up', + 'leveling.milestone': 'Level-Meilenstein', + 'message.delete': 'Nachricht gelöscht', + 'message.edit': 'Nachricht bearbeitet', + 'message.bulkdelete': 'Nachrichten gesammelt gelöscht', + 'role.create': 'Rolle erstellt', + 'role.delete': 'Rolle gelöscht', + 'role.update': 'Rolle aktualisiert', + 'member.join': 'Mitglied beigetreten', + 'member.leave': 'Mitglied hat den Server verlassen', + 'member.namechange': 'Name geändert', + 'reactionrole.add': 'Reaktionsrolle vergeben', + 'reactionrole.remove': 'Reaktionsrolle entfernt', + 'reactionrole.create': 'Reaktionsrollen-Panel erstellt', + 'reactionrole.delete': 'Reaktionsrollen-Panel gelöscht', + 'reactionrole.update': 'Reaktionsrollen-Panel aktualisiert', + 'giveaway.create': 'Giveaway erstellt', + 'giveaway.winner': 'Giveaway-Gewinner', + 'giveaway.reroll': 'Giveaway neu ausgelost', + 'giveaway.delete': 'Giveaway gelöscht', + 'counter.update': 'Zähler aktualisiert', + 'counter.config': 'Zähler-Einstellungen', + 'application.submit': 'Bewerbung eingereicht', + 'application.review': 'Bewerbung geprüft', + 'report.file': 'Meldung eingereicht', +}; + const CATEGORY_DESTINATION: Record = { application: 'applications', report: 'reports', @@ -308,7 +349,7 @@ export async function logEvent({ function createLogEmbed(guild: Guild, eventType: string, data: LogEventData) { const color = data.color ?? EVENT_COLORS[eventType] ?? 0x0099ff; const icon = EVENT_ICONS[eventType] || '📌'; - const title = data.title || `${icon} ${formatEventType(eventType)}`; + const title = data.title || `${icon} ${EVENT_TITLES[eventType] ?? formatEventType(eventType)}`; const inlineFields: LogField[] = []; let description = data.description || ''; @@ -323,8 +364,8 @@ function createLogEmbed(guild: Guild, eventType: string, data: LogEventData) { if (data.fields?.length) { const { before, after } = splitComparisonFields(data.fields); - if (before !== null) inlineFields.push({ name: 'Before', value: before, inline: true }); - if (after !== null) inlineFields.push({ name: 'After', value: after, inline: true }); + if (before !== null) inlineFields.push({ name: 'Vorher', value: before, inline: true }); + if (after !== null) inlineFields.push({ name: 'Nachher', value: after, inline: true }); } } else if (data.fields?.length) { const { before, after, rest } = splitComparisonFields(data.fields); @@ -338,10 +379,10 @@ function createLogEmbed(guild: Guild, eventType: string, data: LogEventData) { }); if (before !== null) { - inlineFields.push({ name: 'Before', value: before, inline: true }); + inlineFields.push({ name: 'Vorher', value: before, inline: true }); } if (after !== null) { - inlineFields.push({ name: 'After', value: after, inline: true }); + inlineFields.push({ name: 'Nachher', value: after, inline: true }); } } else { description = buildLogDescription({ @@ -358,7 +399,7 @@ function createLogEmbed(guild: Guild, eventType: string, data: LogEventData) { } if (data.section?.body) { - description = appendContentSection(description, data.section.title || 'Message', data.section.body); + description = appendContentSection(description, data.section.title || 'Nachricht', data.section.body); } if (data.inlineFields?.length) { @@ -380,7 +421,7 @@ function createLogEmbed(guild: Guild, eventType: string, data: LogEventData) { function formatEventType(eventType: string): string { if (!eventType || typeof eventType !== 'string') { - return 'Unknown Event'; + return 'Unbekanntes Ereignis'; } return eventType diff --git a/src/services/moderation/moderationService.ts b/src/services/moderation/moderationService.ts index 6c7010fdeb..96928bcf65 100644 --- a/src/services/moderation/moderationService.ts +++ b/src/services/moderation/moderationService.ts @@ -16,7 +16,20 @@ export interface ModerationActionResult { } function getTargetLabel(target: GuildMember): string { - return target.user?.tag ?? target.displayName ?? 'this user'; + return target.user?.tag ?? target.displayName ?? 'diesen Nutzer'; +} + +/** German verb for the internal action keys passed to the hierarchy checks. */ +const ACTION_VERBS: Record = { + ban: 'bannen', + kick: 'kicken', + timeout: 'in den Timeout schicken', + warn: 'verwarnen', + untimeout: 'aus dem Timeout holen', +}; + +function actionVerb(action: string): string { + return ACTION_VERBS[action] ?? action; } function getHighestRole(member: GuildMember | null | undefined): Role | null { @@ -34,14 +47,14 @@ export class ModerationService { }): string { if (actor === 'moderator') { return ( - `You cannot ${action} **${targetLabel}** — their role **${targetRole.name}** is equal to or above yours (**${actorRole.name}**). ` + - `In **Server Settings → Roles**, drag your moderator role above **${targetRole.name}**.` + `Du kannst **${targetLabel}** nicht ${actionVerb(action)} – die Rolle **${targetRole.name}** ist gleich hoch oder höher als deine (**${actorRole.name}**). ` + + `Zieh unter **Servereinstellungen → Rollen** deine Moderator-Rolle über **${targetRole.name}**.` ); } return ( - `I cannot ${action} **${targetLabel}** — my role **${actorRole.name}** is equal to or below theirs (**${targetRole.name}**). ` + - `In **Server Settings → Roles**, drag my bot role above **${targetRole.name}**.` + `Ich kann **${targetLabel}** nicht ${actionVerb(action)} – meine Rolle **${actorRole.name}** ist gleich hoch oder niedriger als deren Rolle (**${targetRole.name}**). ` + + `Zieh unter **Servereinstellungen → Rollen** meine Bot-Rolle über **${targetRole.name}**.` ); } @@ -53,21 +66,21 @@ export class ModerationService { const botMember = target.guild?.members?.me; const botRole = getHighestRole(botMember); if (!botRole || !targetRole) { - return `Bot role hierarchy blocked ${action} for ${targetLabel}`; + return `Bot-Rollenhierarchie: ${targetLabel} lässt sich nicht ${actionVerb(action)}`; } - return `Bot role **${botRole.name}** is too low for **${targetRole.name}** — move the bot role higher`; + return `Bot-Rolle **${botRole.name}** ist zu niedrig für **${targetRole.name}** – schieb die Bot-Rolle höher`; } const modRole = getHighestRole(moderator); if (!modRole || !targetRole) { - return `Role hierarchy blocked ${action} for ${targetLabel}`; + return `Rollenhierarchie: ${targetLabel} lässt sich nicht ${actionVerb(action)}`; } - return `Your role **${modRole.name}** is too low for **${targetRole.name}** — move your role higher`; + return `Deine Rolle **${modRole.name}** ist zu niedrig für **${targetRole.name}** – schieb deine Rolle höher`; } static validateHierarchy(moderator: GuildMember, target: GuildMember, action: string): HierarchyCheck { if (!moderator || !target) { - return { valid: false, error: 'Invalid moderator or target' }; + return { valid: false, error: 'Ungültiger Moderator oder ungültiges Ziel' }; } if (moderator.guild?.ownerId === moderator.id) { @@ -80,7 +93,7 @@ export class ModerationService { if (!modRole || !targetRole) { return { valid: false, - error: 'Could not resolve role hierarchy. Try mentioning the user or use the slash command.', + error: 'Die Rollenhierarchie konnte nicht ermittelt werden. Erwähne den Nutzer oder nutze den Slash-Command.', }; } @@ -102,12 +115,12 @@ export class ModerationService { static validateBotHierarchy(target: GuildMember, action: string): HierarchyCheck { if (!target) { - return { valid: false, error: 'Invalid target' }; + return { valid: false, error: 'Ungültiges Ziel' }; } const botMember = target.guild?.members?.me; if (!botMember) { - return { valid: false, error: 'Bot is not in the guild' }; + return { valid: false, error: 'Der Bot ist nicht auf dem Server' }; } const botRole = getHighestRole(botMember); @@ -116,7 +129,7 @@ export class ModerationService { if (!botRole || !targetRole) { return { valid: false, - error: 'Could not resolve bot role hierarchy. Check that my role is configured in this server.', + error: 'Die Rollenhierarchie des Bots konnte nicht ermittelt werden. Prüfe, ob meine Rolle auf diesem Server eingerichtet ist.', }; } @@ -152,7 +165,7 @@ export class ModerationService { guild, user, moderator, - reason = 'No reason provided', + reason = 'Kein Grund angegeben', deleteDays = 0 }: { guild: Guild; @@ -166,7 +179,7 @@ export class ModerationService { throw new NexoBotError( 'Missing required parameters', ErrorTypes.VALIDATION, - 'Guild, user, and moderator are required' + 'Server, Nutzer und Moderator sind erforderlich.' ); } @@ -191,7 +204,7 @@ export class ModerationService { throw new NexoBotError( 'You do not have sufficient permissions to ban users who are not in the server.', ErrorTypes.PERMISSION, - 'You need "Manage Server" or "Administrator" permissions to ban users not currently in the guild.' + 'Du brauchst die Berechtigung „Server verwalten“ oder „Administrator“, um Nutzer zu bannen, die nicht auf dem Server sind.' ); } } @@ -232,7 +245,7 @@ export class ModerationService { guild, member, moderator, - reason = 'No reason provided' + reason = 'Kein Grund angegeben' }: { guild: Guild; member: GuildMember; @@ -244,7 +257,7 @@ export class ModerationService { throw new NexoBotError( 'Missing required parameters', ErrorTypes.VALIDATION, - 'Guild, member, and moderator are required' + 'Server, Mitglied und Moderator sind erforderlich.' ); } @@ -255,8 +268,8 @@ export class ModerationService { throw new NexoBotError( 'Cannot kick member', ErrorTypes.PERMISSION, - `I cannot kick **${targetLabel}**. They may have **Administrator** permission or a managed/integration role. ` + - 'Ensure my bot role is above theirs in **Server Settings → Roles** and that they do not have Admin.' + `Ich kann **${targetLabel}** nicht kicken. Eventuell hat das Mitglied die Berechtigung **Administrator** oder eine verwaltete/Integrationsrolle. ` + + 'Stell sicher, dass meine Bot-Rolle unter **Servereinstellungen → Rollen** darüber liegt und das Mitglied kein Admin ist.' ); } @@ -295,7 +308,7 @@ export class ModerationService { member, moderator, durationMs, - reason = 'No reason provided' + reason = 'Kein Grund angegeben' }: { guild: Guild; member: GuildMember; @@ -308,7 +321,7 @@ export class ModerationService { throw new NexoBotError( 'Missing required parameters', ErrorTypes.VALIDATION, - 'Guild, member, moderator, and duration are required' + 'Server, Mitglied, Moderator und Dauer sind erforderlich.' ); } @@ -319,8 +332,8 @@ export class ModerationService { throw new NexoBotError( 'Cannot timeout member', ErrorTypes.PERMISSION, - `I cannot timeout **${targetLabel}**. They may have **Administrator** permission or a managed/integration role. ` + - 'Ensure my bot role is above theirs in **Server Settings → Roles** and that they do not have Admin.' + `Ich kann **${targetLabel}** nicht in den Timeout schicken. Eventuell hat das Mitglied die Berechtigung **Administrator** oder eine verwaltete/Integrationsrolle. ` + + 'Stell sicher, dass meine Bot-Rolle unter **Servereinstellungen → Rollen** darüber liegt und das Mitglied kein Admin ist.' ); } @@ -335,7 +348,7 @@ export class ModerationService { target: `${member.user.tag} (${member.id})`, executor: `${moderator.user.tag} (${moderator.id})`, reason, - duration: `${durationMinutes} minutes`, + duration: `${durationMinutes} Minuten`, metadata: { userId: member.id, moderatorId: moderator.id, @@ -362,7 +375,7 @@ export class ModerationService { guild, member, moderator, - reason = 'Timeout removed by moderator' + reason = 'Timeout durch Moderator aufgehoben' }: { guild: Guild; member: GuildMember; @@ -374,19 +387,19 @@ export class ModerationService { throw new NexoBotError( 'Missing required parameters', ErrorTypes.VALIDATION, - 'Guild, member, and moderator are required' + 'Server, Mitglied und Moderator sind erforderlich.' ); } - this.assertModerationHierarchy(moderator, member, 'remove the timeout from'); + this.assertModerationHierarchy(moderator, member, 'untimeout'); if (!member.moderatable) { const targetLabel = getTargetLabel(member); throw new NexoBotError( 'Cannot modify member', ErrorTypes.PERMISSION, - `I cannot modify **${targetLabel}**. They may have **Administrator** permission or a managed/integration role. ` + - 'Ensure my bot role is above theirs in **Server Settings → Roles**.' + `Ich kann **${targetLabel}** nicht bearbeiten. Eventuell hat das Mitglied die Berechtigung **Administrator** oder eine verwaltete/Integrationsrolle. ` + + 'Stell sicher, dass meine Bot-Rolle unter **Servereinstellungen → Rollen** darüber liegt.' ); } @@ -394,7 +407,7 @@ export class ModerationService { throw new NexoBotError( 'User not timed out', ErrorTypes.VALIDATION, - `${member.user.tag} is not currently timed out` + `${member.user.tag} ist derzeit nicht im Timeout` ); } @@ -430,7 +443,7 @@ export class ModerationService { guild, user, moderator, - reason = 'No reason provided' + reason = 'Kein Grund angegeben' }: { guild: Guild; user: User; @@ -442,7 +455,7 @@ export class ModerationService { throw new NexoBotError( 'Missing required parameters', ErrorTypes.VALIDATION, - 'Guild, user, and moderator are required' + 'Server, Nutzer und Moderator sind erforderlich.' ); } @@ -453,7 +466,7 @@ export class ModerationService { throw new NexoBotError( 'User not banned', ErrorTypes.VALIDATION, - `${user.tag} is not currently banned from this server` + `${user.tag} ist auf diesem Server derzeit nicht gebannt` ); } diff --git a/src/services/moderation/warningService.ts b/src/services/moderation/warningService.ts index a534c9874a..c8a8af90be 100644 --- a/src/services/moderation/warningService.ts +++ b/src/services/moderation/warningService.ts @@ -44,7 +44,7 @@ class WarningService { throw createError( 'Corrupted warning data', ErrorTypes.DATABASE, - 'Warning data was corrupted and has been reset. Please try again.', + 'Die Verwarnungsdaten waren beschädigt und wurden zurückgesetzt. Bitte versuch es erneut.', { guildId, userId, service: 'warningService', operation: 'addWarning' } ); } @@ -93,7 +93,7 @@ class WarningService { throw createError( 'Warning not found', ErrorTypes.USER_INPUT, - 'That warning could not be found. It may have already been removed.', + 'Diese Verwarnung wurde nicht gefunden. Vielleicht wurde sie bereits entfernt.', { guildId, userId, warningId, service: 'warningService', operation: 'removeWarning' } ); } diff --git a/src/services/music/musicActions.ts b/src/services/music/musicActions.ts index 2b1081c046..dc8dca216d 100644 --- a/src/services/music/musicActions.ts +++ b/src/services/music/musicActions.ts @@ -44,7 +44,7 @@ export function assertLavalinkNodeAvailable(client: NexoClient): void { throw new NexoBotError( 'Lavalink unavailable', ErrorTypes.CONFIGURATION, - 'Music is temporarily unavailable — no Lavalink nodes are connected. Try again shortly or configure your own Lavalink server.', + 'Musik ist gerade nicht verfügbar – keine Lavalink-Nodes verbunden. Versuch es gleich noch mal oder richte deinen eigenen Lavalink-Server ein.', ); } } @@ -54,7 +54,7 @@ function assertBotVoicePermissions(channel: VoiceBasedChannel | null | undefined throw new NexoBotError( 'Voice channel unavailable', ErrorTypes.CONFIGURATION, - 'Could not access that voice channel.', + 'Auf diesen Sprachkanal konnte nicht zugegriffen werden.', ); } @@ -62,7 +62,7 @@ function assertBotVoicePermissions(channel: VoiceBasedChannel | null | undefined throw new NexoBotError( 'Missing voice permissions', ErrorTypes.PERMISSION, - 'I need **Connect** and **Speak** permissions in your voice channel.', + 'Ich brauche die Berechtigungen **Verbinden** und **Sprechen** in deinem Sprachkanal.', ); } } @@ -94,7 +94,7 @@ async function waitForPlayerConnection(player: Player): Promise { throw new NexoBotError( 'Voice connection failed', ErrorTypes.CONFIGURATION, - 'Could not connect to the voice channel. Ensure Lavalink is online, the bot has Connect and Speak permissions, then try again.', + 'Verbindung zum Sprachkanal fehlgeschlagen. Prüfe, ob Lavalink online ist und der Bot die Berechtigungen Verbinden und Sprechen hat, und versuch es erneut.', ); } } @@ -113,7 +113,7 @@ export function assertRiffyAvailable(client: NexoClient): asserts client is Nexo throw new NexoBotError( 'Lavalink not configured', ErrorTypes.CONFIGURATION, - 'Music is unavailable — Lavalink is not configured.', + 'Musik ist nicht verfügbar – Lavalink ist nicht konfiguriert.', ); } } @@ -123,7 +123,7 @@ export function assertInVoice(member: GuildMember | null | undefined): void { throw new NexoBotError( 'Not in voice channel', ErrorTypes.USER_INPUT, - 'You need to be in a voice channel.', + 'Du musst in einem Sprachkanal sein.', ); } } @@ -204,8 +204,8 @@ export async function joinVoiceChannel(client: NexoClient, interaction: NexoInte player.setVolume(guildData.volume); return successEmbed( - 'Joined Voice Channel', - `Connected to **${channel.name}**. Use /music play to start music, or /music for playback controls.`, + 'Sprachkanal beigetreten', + `Verbunden mit **${channel.name}**. Nutze /music play, um Musik zu starten, oder /music für die Wiedergabesteuerung.`, ); } @@ -214,7 +214,7 @@ export async function playQuery(client: NexoClient, interaction: NexoInteraction throw new NexoBotError( 'YouTube URL blocked', ErrorTypes.USER_INPUT, - 'YouTube links are not supported. Try a song name instead.', + 'YouTube-Links werden nicht unterstützt. Versuch es stattdessen mit einem Songnamen.', ); } @@ -247,8 +247,8 @@ export async function playQuery(client: NexoClient, interaction: NexoInteraction return { embed: successEmbed( - 'Playlist Added', - `**${playlistInfo?.name || 'Playlist'}**\nAdded ${added} of ${tracks.length} track(s).${skipped ? ` Skipped ${skipped} duplicate(s).` : ''}`, + 'Playlist hinzugefügt', + `**${playlistInfo?.name || 'Playlist'}**\n${added} von ${tracks.length} Titel(n) hinzugefügt.${skipped ? ` ${skipped} Duplikat(e) übersprungen.` : ''}`, ), }; } @@ -261,14 +261,14 @@ export async function playQuery(client: NexoClient, interaction: NexoInteraction ) { const track = tracks?.[0]; if (!track) { - throw new NexoBotError('No results', ErrorTypes.USER_INPUT, 'No results found for that query.'); + throw new NexoBotError('No results', ErrorTypes.USER_INPUT, 'Für diese Suche wurden keine Ergebnisse gefunden.'); } if (isDuplicateTrack(player, track)) { throw new NexoBotError( 'Duplicate track', ErrorTypes.USER_INPUT, - `**${track.info.title}** is already in the queue or playing.`, + `**${track.info.title}** ist bereits in der Warteschlange oder läuft gerade.`, ); } @@ -284,37 +284,37 @@ export async function playQuery(client: NexoClient, interaction: NexoInteraction return { embed: successEmbed( - willPlayNow ? 'Now Playing' : 'Track Added', + willPlayNow ? 'Läuft gerade' : 'Titel hinzugefügt', willPlayNow ? `**${track.info.title}**\n${track.info.author}` - : `**${track.info.title}**\n${track.info.author}\nPosition: #${queuePosition} in queue`, + : `**${track.info.title}**\n${track.info.author}\nPosition #${queuePosition} in der Warteschlange`, ), }; } - throw new NexoBotError('No results', ErrorTypes.USER_INPUT, `No results found. (loadType: ${loadType})`); + throw new NexoBotError('No results', ErrorTypes.USER_INPUT, `Keine Ergebnisse gefunden. (loadType: ${loadType})`); } export async function skipTrack(client: NexoClient, interaction: NexoInteraction): Promise { const player = getPlayer(client, interaction.guild!.id); if (!player?.current) { - throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'Nothing is playing right now.'); + throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'Gerade läuft nichts.'); } assertCanControl(interaction.member as GuildMember, player); - const title = player.current.info?.title || 'Unknown'; + const title = player.current.info?.title || 'Unbekannt'; // 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}**.`); + return successEmbed('Übersprungen', `**${title}** übersprungen.`); } export async function stopPlayback(client: NexoClient, interaction: NexoInteraction): Promise { const player = getPlayer(client, interaction.guild!.id); if (!player) { - throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'No active music player.'); + throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'Kein aktiver Musik-Player.'); } assertCanControl(interaction.member as GuildMember, player); @@ -329,14 +329,14 @@ export async function stopPlayback(client: NexoClient, interaction: NexoInteract } }, 15000); return successEmbed( - 'Confirm Stop', - `There are **${queueLength}** tracks in the queue. Run **/music stop** again within 15 seconds to confirm.`, + 'Stoppen bestätigen', + `Es sind **${queueLength}** Titel in der Warteschlange. Führe **/music stop** innerhalb von 15 Sekunden erneut aus, um zu bestätigen.`, ); } guildData.stopConfirmPending = null; await destroyPlayerSession(client, interaction.guild!.id, player, guildData); - return successEmbed('Stopped', 'Playback stopped and the queue was cleared.'); + return successEmbed('Gestoppt', 'Wiedergabe gestoppt und Warteschlange geleert.'); } export async function applyPause(client: NexoClient, guildId: string): Promise { @@ -364,49 +364,49 @@ export async function applyResume(client: NexoClient, guildId: string): Promise< export async function pausePlayback(client: NexoClient, interaction: NexoInteraction): Promise { const player = getPlayer(client, interaction.guild!.id); if (!player?.current) { - throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'Nothing is playing right now.'); + throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'Gerade läuft nichts.'); } assertCanControl(interaction.member as GuildMember, player); if (player.paused) { - throw new NexoBotError('Already paused', ErrorTypes.USER_INPUT, 'Playback is already paused.'); + throw new NexoBotError('Already paused', ErrorTypes.USER_INPUT, 'Die Wiedergabe ist bereits pausiert.'); } await applyPause(client, interaction.guild!.id); - return successEmbed('Paused', 'Playback paused.'); + return successEmbed('Pausiert', 'Wiedergabe pausiert.'); } export async function resumePlayback(client: NexoClient, interaction: NexoInteraction): Promise { const player = getPlayer(client, interaction.guild!.id); if (!player?.current) { - throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'Nothing is playing right now.'); + throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'Gerade läuft nichts.'); } assertCanControl(interaction.member as GuildMember, player); if (!player.paused) { - throw new NexoBotError('Not paused', ErrorTypes.USER_INPUT, 'Playback is not paused.'); + throw new NexoBotError('Not paused', ErrorTypes.USER_INPUT, 'Die Wiedergabe ist nicht pausiert.'); } await applyResume(client, interaction.guild!.id); - return successEmbed('Resumed', 'Playback resumed.'); + return successEmbed('Fortgesetzt', 'Wiedergabe fortgesetzt.'); } export async function shuffleQueue(client: NexoClient, interaction: NexoInteraction): Promise { const player = getPlayer(client, interaction.guild!.id); if (!player?.queue?.length) { - throw new NexoBotError('Empty queue', ErrorTypes.USER_INPUT, 'The queue is empty.'); + throw new NexoBotError('Empty queue', ErrorTypes.USER_INPUT, 'Die Warteschlange ist leer.'); } assertCanControl(interaction.member as GuildMember, player); player.queue.shuffle(); getGuildMusicData(interaction.guild!.id).shuffle = true; await refreshPlayerMessage(client, interaction.guild!.id); - return successEmbed('Shuffled', 'The queue has been shuffled.'); + return successEmbed('Gemischt', 'Die Warteschlange wurde gemischt.'); } export async function setLoopMode(client: NexoClient, interaction: NexoInteraction, mode: LoopOption): Promise { const player = getPlayer(client, interaction.guild!.id); if (!player) { - throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'No active music player.'); + throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'Kein aktiver Musik-Player.'); } assertCanControl(interaction.member as GuildMember, player); @@ -414,9 +414,9 @@ export async function setLoopMode(client: NexoClient, interaction: NexoInteracti guildData.loop = mode; player.setLoop(mode); - const labels: Record = { none: 'Off', track: 'Track', queue: 'Queue' }; + const labels: Record = { none: 'Aus', track: 'Titel', queue: 'Warteschlange' }; await refreshPlayerMessage(client, interaction.guild!.id); - return successEmbed('Loop Updated', `Loop mode set to **${labels[mode] || mode}**.`); + return successEmbed('Wiederholung aktualisiert', `Wiederholmodus auf **${labels[mode] || mode}** gesetzt.`); } export async function toggleLoop(client: NexoClient, interaction: NexoInteraction): Promise { @@ -428,7 +428,7 @@ export async function toggleLoop(client: NexoClient, interaction: NexoInteractio export async function setVolume(client: NexoClient, interaction: NexoInteraction, volume: number): Promise { const player = getPlayer(client, interaction.guild!.id); if (!player) { - throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'No active music player.'); + throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'Kein aktiver Musik-Player.'); } assertCanControl(interaction.member as GuildMember, player); @@ -436,7 +436,7 @@ export async function setVolume(client: NexoClient, interaction: NexoInteraction guildData.volume = Math.max(0, Math.min(100, volume)); player.setVolume(guildData.volume); await refreshPlayerMessage(client, interaction.guild!.id); - return successEmbed('Volume Updated', `Volume set to **${guildData.volume}%**.`); + return successEmbed('Lautstärke aktualisiert', `Lautstärke auf **${guildData.volume}%** gesetzt.`); } export async function adjustVolume(client: NexoClient, interaction: NexoInteraction, delta: number): Promise { @@ -447,7 +447,7 @@ export async function adjustVolume(client: NexoClient, interaction: NexoInteract export async function seekTrack(client: NexoClient, interaction: NexoInteraction, seconds: number): Promise { const player = getPlayer(client, interaction.guild!.id); if (!player?.current) { - throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'Nothing is playing right now.'); + throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'Gerade läuft nichts.'); } assertCanControl(interaction.member as GuildMember, player); @@ -456,7 +456,7 @@ export async function seekTrack(client: NexoClient, interaction: NexoInteraction throw new NexoBotError( 'Not seekable', ErrorTypes.USER_INPUT, - 'This track cannot be seeked (it may be a live stream).', + 'In diesem Titel kann nicht gespult werden (evtl. ein Livestream).', ); } @@ -465,79 +465,79 @@ export async function seekTrack(client: NexoClient, interaction: NexoInteraction throw new NexoBotError( 'Seek out of range', ErrorTypes.USER_INPUT, - `You can only seek up to ${Math.floor(info.length / 1000)}s for this track.`, + `Du kannst bei diesem Titel höchstens bis ${Math.floor(info.length / 1000)}s spulen.`, ); } player.seek(position); await refreshPlayerMessage(client, interaction.guild!.id); - return successEmbed('Seeked', `Seeked to **${seconds}s**.`); + return successEmbed('Gespult', `Zu **${seconds}s** gespult.`); } export async function removeFromQueue(client: NexoClient, interaction: NexoInteraction, index: number): Promise { const player = getPlayer(client, interaction.guild!.id); if (!player?.queue?.length) { - throw new NexoBotError('Empty queue', ErrorTypes.USER_INPUT, 'The queue is empty.'); + throw new NexoBotError('Empty queue', ErrorTypes.USER_INPUT, 'Die Warteschlange ist leer.'); } assertCanControl(interaction.member as GuildMember, player); const queueIndex = index - 1; if (queueIndex < 0 || queueIndex >= player.queue.length) { - throw new NexoBotError('Invalid index', ErrorTypes.USER_INPUT, `Invalid queue position. Queue has ${player.queue.length} track(s).`); + throw new NexoBotError('Invalid index', ErrorTypes.USER_INPUT, `Ungültige Position. Die Warteschlange enthält ${player.queue.length} Titel.`); } 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.`); + return successEmbed('Entfernt', `**${removed.info?.title || 'Titel'}** aus der Warteschlange entfernt.`); } export async function moveInQueue(client: NexoClient, interaction: NexoInteraction, from: number, to: number): Promise { const player = getPlayer(client, interaction.guild!.id); if (!player?.queue?.length) { - throw new NexoBotError('Empty queue', ErrorTypes.USER_INPUT, 'The queue is empty.'); + throw new NexoBotError('Empty queue', ErrorTypes.USER_INPUT, 'Die Warteschlange ist leer.'); } assertCanControl(interaction.member as GuildMember, player); const fromIndex = from - 1; const toIndex = to - 1; if (fromIndex < 0 || fromIndex >= player.queue.length || toIndex < 0 || toIndex >= player.queue.length) { - throw new NexoBotError('Invalid index', ErrorTypes.USER_INPUT, 'Invalid queue positions.'); + throw new NexoBotError('Invalid index', ErrorTypes.USER_INPUT, 'Ungültige Positionen in der Warteschlange.'); } 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}.`); + return successEmbed('Verschoben', `**${track.info?.title || 'Titel'}** auf Position #${to} verschoben.`); } export async function clearQueue(client: NexoClient, interaction: NexoInteraction): Promise { const player = getPlayer(client, interaction.guild!.id); if (!player?.queue?.length) { - throw new NexoBotError('Empty queue', ErrorTypes.USER_INPUT, 'The queue is already empty.'); + throw new NexoBotError('Empty queue', ErrorTypes.USER_INPUT, 'Die Warteschlange ist bereits leer.'); } assertCanControl(interaction.member as GuildMember, player); player.queue.clear(); await refreshPlayerMessage(client, interaction.guild!.id); - return successEmbed('Queue Cleared', 'All queued tracks were removed.'); + return successEmbed('Warteschlange geleert', 'Alle Titel in der Warteschlange wurden entfernt.'); } export async function setTwentyFourSeven(client: NexoClient, interaction: NexoInteraction, enabled: boolean): Promise { const guildData = getGuildMusicData(interaction.guild!.id); guildData.twentyFourSeven = enabled; return successEmbed( - '24/7 Mode', + '24/7-Modus', enabled - ? '24/7 mode enabled. The bot will stay in the voice channel when the queue ends.' - : '24/7 mode disabled. The bot will leave after 30 seconds of idle time.', + ? '24/7-Modus aktiviert. Der Bot bleibt im Sprachkanal, wenn die Warteschlange endet.' + : '24/7-Modus deaktiviert. Der Bot verlässt den Kanal nach 30 Sekunden Leerlauf.', ); } export function buildNowPlayingReply(client: NexoClient, guildId: string): { embeds: EmbedBuilder[] } { const player = getPlayer(client, guildId); if (!player?.current) { - throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'Nothing is playing right now.'); + throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'Gerade läuft nichts.'); } const guildData = getGuildMusicData(guildId); return { @@ -553,7 +553,7 @@ export function buildQueueReply(client: NexoClient, guildId: string, page = 0): } { const player = getPlayer(client, guildId); if (!player) { - throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'No active music player.'); + throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'Kein aktiver Musik-Player.'); } const totalPages = Math.max(1, Math.ceil((player.queue?.length || 0) / getQueuePageSize())); @@ -615,17 +615,17 @@ export async function leaveVoiceChannel(client: NexoClient, interaction: NexoInt const guildId = interaction.guild!.id; const player = getPlayer(client, guildId); if (!player) { - throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'I am not in a voice channel.'); + throw new NexoBotError('No player', ErrorTypes.USER_INPUT, 'Ich bin in keinem Sprachkanal.'); } assertCanControl(interaction.member as GuildMember, player); const channel = interaction.guild!.channels.cache.get(player.voiceChannel); - const channelName = channel?.name || 'voice channel'; + const channelName = channel?.name || 'Sprachkanal'; const guildData = getGuildMusicData(guildId); await destroyPlayerSession(client, guildId, player, guildData, { forceDisconnect: true }); - return successEmbed('Left Voice Channel', `Disconnected from **${channelName}**.`); + return successEmbed('Sprachkanal verlassen', `Verbindung zu **${channelName}** getrennt.`); } export async function replyMusicSuccess(interaction: NexoInteraction, embed: EmbedBuilder): Promise { diff --git a/src/services/music/musicEmbeds.ts b/src/services/music/musicEmbeds.ts index 82d7986e4c..7f02b7d4e1 100644 --- a/src/services/music/musicEmbeds.ts +++ b/src/services/music/musicEmbeds.ts @@ -52,11 +52,11 @@ function getTrackArtwork(track: Track | null | undefined): string | null { function getLoopLabel(loop: string | undefined): string { switch (loop) { case 'track': - return 'Track'; + return 'Titel'; case 'queue': - return 'Queue'; + return 'Warteschlange'; default: - return 'Off'; + return 'Aus'; } } @@ -67,26 +67,26 @@ export function buildNowPlayingEmbed( ): EmbedBuilder { const requester = track?.info?.requester as Partial> | null | undefined; const requesterLabel = requester - ? (requester.username || requester.tag || 'Unknown') - : 'Unknown'; + ? (requester.username || requester.tag || 'Unbekannt') + : 'Unbekannt'; const position = formatDuration(player?.position || 0); const duration = formatDuration(track?.info?.length || 0); return createEmbed({ - title: 'Now Playing', - description: track?.info?.title || 'Unknown track', + title: 'Läuft gerade', + description: track?.info?.title || 'Unbekannter Titel', color: 'primary', fields: [ - { name: 'Artist', value: track?.info?.author || 'Unknown', inline: true }, - { name: 'Requester', value: requesterLabel, inline: true }, - { name: 'Progress', value: `${position} / ${duration}`, inline: true }, - { name: 'Volume', value: `${guildData?.volume ?? 75}%`, inline: true }, - { name: 'Loop', value: getLoopLabel(guildData?.loop), inline: true }, - { name: 'Queue', value: `${player?.queue?.length || 0} track(s)`, inline: true }, + { name: 'Interpret', value: track?.info?.author || 'Unbekannt', inline: true }, + { name: 'Angefragt von', value: requesterLabel, inline: true }, + { name: 'Fortschritt', value: `${position} / ${duration}`, inline: true }, + { name: 'Lautstärke', value: `${guildData?.volume ?? 75}%`, inline: true }, + { name: 'Wiederholen', value: getLoopLabel(guildData?.loop), inline: true }, + { name: 'Warteschlange', value: `${player?.queue?.length || 0} Titel`, inline: true }, ], thumbnail: getTrackArtwork(track), - footer: player?.paused ? 'Paused' : 'Playing', + footer: player?.paused ? 'Pausiert' : 'Läuft', }); } @@ -99,25 +99,25 @@ export function buildQueueEmbed(queue: Track[] | null | undefined, currentTrack: let description = ''; if (currentTrack) { - description += `**Now Playing**\n${currentTrack.info?.title || 'Unknown'} — ${currentTrack.info?.author || 'Unknown'}\n\n`; + description += `**Läuft gerade**\n${currentTrack.info?.title || 'Unbekannt'} — ${currentTrack.info?.author || 'Unbekannt'}\n\n`; } if (slice.length === 0) { - description += 'The queue is empty.'; + description += 'Die Warteschlange ist leer.'; } else { description += slice .map((track, index) => { const num = start + index + 1; - return `${num}. ${track.info?.title || 'Unknown'} — ${track.info?.author || 'Unknown'}`; + return `${num}. ${track.info?.title || 'Unbekannt'} — ${track.info?.author || 'Unbekannt'}`; }) .join('\n'); } return createEmbed({ - title: 'Music Queue', + title: 'Musik-Warteschlange', description: description.substring(0, 4096), color: 'info', - footer: `Page ${safePage + 1} of ${totalPages} • ${totalTracks} queued`, + footer: `Seite ${safePage + 1} von ${totalPages} • ${totalTracks} in der Warteschlange`, }); } @@ -126,29 +126,29 @@ export function buildPlayerButtonRows(player: Player | null | undefined, guildDa const row1 = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(MUSIC_BUTTON_IDS.PAUSE) - .setLabel('Pause') + .setLabel('Pausieren') .setStyle(ButtonStyle.Primary) .setEmoji('⏸️') .setDisabled(Boolean(paused)), new ButtonBuilder() .setCustomId(MUSIC_BUTTON_IDS.RESUME) - .setLabel('Resume') + .setLabel('Fortsetzen') .setStyle(ButtonStyle.Success) .setEmoji('▶️') .setDisabled(!paused), new ButtonBuilder() .setCustomId(MUSIC_BUTTON_IDS.SKIP) - .setLabel('Skip') + .setLabel('Überspringen') .setStyle(ButtonStyle.Secondary) .setEmoji('⏭️'), new ButtonBuilder() .setCustomId(MUSIC_BUTTON_IDS.STOP) - .setLabel('Stop') + .setLabel('Stoppen') .setStyle(ButtonStyle.Danger) .setEmoji('⏹️'), new ButtonBuilder() .setCustomId(MUSIC_BUTTON_IDS.SHUFFLE) - .setLabel('Shuffle') + .setLabel('Mischen') .setStyle(guildData?.shuffle ? ButtonStyle.Success : ButtonStyle.Secondary) .setEmoji('🔀'), ); @@ -156,22 +156,22 @@ export function buildPlayerButtonRows(player: Player | null | undefined, guildDa const row2 = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(MUSIC_BUTTON_IDS.LOOP) - .setLabel('Loop') + .setLabel('Wiederholen') .setStyle(guildData?.loop !== 'none' ? ButtonStyle.Success : ButtonStyle.Secondary) .setEmoji('🔁'), new ButtonBuilder() .setCustomId(MUSIC_BUTTON_IDS.VOL_DOWN) - .setLabel('Vol -') + .setLabel('Leiser') .setStyle(ButtonStyle.Secondary) .setEmoji('🔉'), new ButtonBuilder() .setCustomId(MUSIC_BUTTON_IDS.VOL_UP) - .setLabel('Vol +') + .setLabel('Lauter') .setStyle(ButtonStyle.Secondary) .setEmoji('🔊'), new ButtonBuilder() .setCustomId(MUSIC_BUTTON_IDS.QUEUE) - .setLabel('Queue') + .setLabel('Warteschlange') .setStyle(ButtonStyle.Secondary) .setEmoji('📋'), ); diff --git a/src/services/music/musicVoiceState.ts b/src/services/music/musicVoiceState.ts index a7a9c55e6c..eccaad173e 100644 --- a/src/services/music/musicVoiceState.ts +++ b/src/services/music/musicVoiceState.ts @@ -34,7 +34,7 @@ export async function handleMusicVoiceState(client: NexoClient, oldState: VoiceS if (guildData.playerChannelId) { const channel = client.channels.cache.get(guildData.playerChannelId) as GuildTextBasedChannel | undefined; if (channel) { - channel.send({ embeds: [successEmbed('Paused', 'Voice channel is empty. Music paused until someone joins.')] }).catch(() => null); + channel.send({ embeds: [successEmbed('Pausiert', 'Der Sprachkanal ist leer. Die Musik ist pausiert, bis jemand beitritt.')] }).catch(() => null); } } return; @@ -46,7 +46,7 @@ export async function handleMusicVoiceState(client: NexoClient, oldState: VoiceS if (guildData.playerChannelId) { const channel = client.channels.cache.get(guildData.playerChannelId) as GuildTextBasedChannel | undefined; if (channel) { - channel.send({ embeds: [successEmbed('Resumed', 'Someone joined the voice channel. Playback resumed.')] }).catch(() => null); + channel.send({ embeds: [successEmbed('Fortgesetzt', 'Jemand ist dem Sprachkanal beigetreten. Die Wiedergabe läuft weiter.')] }).catch(() => null); } } } diff --git a/src/services/music/permissions.ts b/src/services/music/permissions.ts index a4d657ed50..903afd7ade 100644 --- a/src/services/music/permissions.ts +++ b/src/services/music/permissions.ts @@ -2,7 +2,7 @@ import type { GuildMember } from 'discord.js'; import type { Player } from 'riffy'; export const VOICE_CHANNEL_DENIAL = - 'You need to be in the same voice channel as the bot to use music controls.'; + 'Du musst im selben Sprachkanal wie der Bot sein, um die Musik zu steuern.'; export function canControlMusic(member: GuildMember | null | undefined, player: Player | null | undefined): boolean { const memberChannel = member?.voice?.channel; diff --git a/src/services/music/playerHandler.ts b/src/services/music/playerHandler.ts index ca7dc72f26..231f1a6190 100644 --- a/src/services/music/playerHandler.ts +++ b/src/services/music/playerHandler.ts @@ -237,7 +237,7 @@ export function setupPlayerHandler(client: Client): void { if (guildData.playerChannelId) { const channel = client.channels.cache.get(guildData.playerChannelId) as GuildTextBasedChannel | undefined; if (channel) { - channel.send(`Failed to play **${track?.info?.title || 'track'}**. Skipping...`).catch(() => null); + channel.send(`**${track?.info?.title || 'Titel'}** konnte nicht abgespielt werden. Wird übersprungen …`).catch(() => null); } } }); diff --git a/src/services/reactionRoleService.ts b/src/services/reactionRoleService.ts index 1b8efbf510..8d2c164dcd 100644 --- a/src/services/reactionRoleService.ts +++ b/src/services/reactionRoleService.ts @@ -45,7 +45,7 @@ function validateGuildId(guildId: string): void { throw createError( `Invalid guild ID: ${guildId}`, ErrorTypes.VALIDATION, - 'Invalid server ID provided.', + 'Ungültige Server-ID angegeben.', { guildId } ); } @@ -56,7 +56,7 @@ function validateMessageId(messageId: string): void { throw createError( `Invalid message ID: ${messageId}`, ErrorTypes.VALIDATION, - 'Invalid message ID provided.', + 'Ungültige Nachrichten-ID angegeben.', { messageId } ); } @@ -67,7 +67,7 @@ function validateRoleId(roleId: string): void { throw createError( `Invalid role ID: ${roleId}`, ErrorTypes.VALIDATION, - 'Invalid role ID provided.', + 'Ungültige Rollen-ID angegeben.', { roleId } ); } @@ -90,7 +90,7 @@ async function validateRoleSafety(client: NexoClient, guildId: string, roleId: s throw createError( `Guild not found for role validation: ${guildId}`, ErrorTypes.VALIDATION, - 'Server not found while validating reaction roles.', + 'Beim Prüfen der Reaktionsrollen wurde der Server nicht gefunden.', { guildId, roleId } ); } @@ -100,7 +100,7 @@ async function validateRoleSafety(client: NexoClient, guildId: string, roleId: s throw createError( `Role not found: ${roleId}`, ErrorTypes.VALIDATION, - 'One or more selected roles no longer exist.', + 'Eine oder mehrere ausgewählte Rollen existieren nicht mehr.', { guildId, roleId } ); } @@ -109,7 +109,7 @@ async function validateRoleSafety(client: NexoClient, guildId: string, roleId: s throw createError( `Dangerous role permission detected: ${roleId}`, ErrorTypes.PERMISSION, - 'For security reasons, high-privilege roles cannot be assigned through reaction roles.', + 'Aus Sicherheitsgründen können Rollen mit weitreichenden Berechtigungen nicht über Reaktionsrollen vergeben werden.', { guildId, roleId, roleName: role.name, dangerousPermissions: DANGEROUS_PERMISSIONS } ); } @@ -119,7 +119,7 @@ async function validateRoleSafety(client: NexoClient, guildId: string, roleId: s throw createError( `Role above bot hierarchy: ${roleId}`, ErrorTypes.PERMISSION, - 'I cannot assign this role because it is equal to or above my highest role.', + 'Ich kann diese Rolle nicht vergeben, weil sie gleich hoch oder höher als meine höchste Rolle ist.', { guildId, roleId, rolePosition: role.position, botRolePosition: botHighestRole?.position } ); } @@ -141,7 +141,7 @@ export async function getReactionRoleMessage(client: NexoClient, guildId: string throw createError( `Database error retrieving reaction role message`, ErrorTypes.DATABASE, - 'Failed to retrieve reaction role data. Please try again.', + 'Die Reaktionsrollen-Daten konnten nicht geladen werden. Bitte versuch es erneut.', { guildId, messageId, originalError: (error as Error).message } ); } @@ -162,7 +162,7 @@ export async function createReactionRoleMessage( throw createError( `Invalid channel ID: ${channelId}`, ErrorTypes.VALIDATION, - 'Invalid channel ID provided.', + 'Ungültige Kanal-ID angegeben.', { channelId } ); } @@ -171,7 +171,7 @@ export async function createReactionRoleMessage( throw createError( 'No roles provided', ErrorTypes.VALIDATION, - 'You must provide at least one role.', + 'Du musst mindestens eine Rolle angeben.', { roleIds } ); } @@ -180,7 +180,7 @@ export async function createReactionRoleMessage( throw createError( `Too many roles: ${roleIds.length}`, ErrorTypes.VALIDATION, - `You can only add up to ${MAX_ROLES_PER_MESSAGE} roles per reaction role message.`, + `Du kannst höchstens ${MAX_ROLES_PER_MESSAGE} Rollen pro Reaktionsrollen-Nachricht hinzufügen.`, { roleIds, limit: MAX_ROLES_PER_MESSAGE } ); } @@ -211,7 +211,7 @@ export async function createReactionRoleMessage( throw createError( `Database error creating reaction role message`, ErrorTypes.DATABASE, - 'Failed to save reaction role data. Please try again.', + 'Die Reaktionsrollen-Daten konnten nicht gespeichert werden. Bitte versuch es erneut.', { guildId, messageId, originalError: (error as Error).message } ); } @@ -245,7 +245,7 @@ export async function addReactionRole(client: NexoClient, guildId: string, messa throw createError( `Database error adding reaction role`, ErrorTypes.DATABASE, - 'Failed to add reaction role. Please try again.', + 'Die Reaktionsrolle konnte nicht hinzugefügt werden. Bitte versuch es erneut.', { guildId, messageId, originalError: (error as Error).message } ); } @@ -276,7 +276,7 @@ export async function deleteReactionRoleMessage(client: NexoClient, guildId: str throw createError( `Database error deleting reaction role message`, ErrorTypes.DATABASE, - 'Failed to delete reaction role message. Please try again.', + 'Die Reaktionsrollen-Nachricht konnte nicht gelöscht werden. Bitte versuch es erneut.', { guildId, messageId, originalError: (error as Error).message } ); } @@ -313,7 +313,7 @@ export async function removeReactionRole(client: NexoClient, guildId: string, me throw createError( `Database error removing reaction role`, ErrorTypes.DATABASE, - 'Failed to remove reaction role. Please try again.', + 'Die Reaktionsrolle konnte nicht entfernt werden. Bitte versuch es erneut.', { guildId, messageId, originalError: (error as Error).message } ); } @@ -354,7 +354,7 @@ export async function getAllReactionRoleMessages(client: NexoClient, guildId: st throw createError( 'Database error listing reaction roles', ErrorTypes.DATABASE, - 'Failed to retrieve reaction role list. Please try again.', + 'Die Liste der Reaktionsrollen konnte nicht geladen werden. Bitte versuch es erneut.', { guildId, originalError: (listError as Error).message } ); } @@ -400,7 +400,7 @@ export async function getAllReactionRoleMessages(client: NexoClient, guildId: st throw createError( 'Database error retrieving reaction roles', ErrorTypes.DATABASE, - 'Failed to retrieve reaction role messages. Please try again.', + 'Die Reaktionsrollen-Nachrichten konnten nicht geladen werden. Bitte versuch es erneut.', { guildId, originalError: (error as Error).message } ); } @@ -415,7 +415,7 @@ export async function setReactionRoleChannel(client: NexoClient, guildId: string throw createError( `Invalid channel ID: ${channelId}`, ErrorTypes.VALIDATION, - 'Invalid channel ID provided.', + 'Ungültige Kanal-ID angegeben.', { channelId } ); } @@ -440,7 +440,7 @@ export async function setReactionRoleChannel(client: NexoClient, guildId: string throw createError( `Database error setting reaction role channel`, ErrorTypes.DATABASE, - 'Failed to update reaction role channel. Please try again.', + 'Der Reaktionsrollen-Kanal konnte nicht aktualisiert werden. Bitte versuch es erneut.', { guildId, messageId, channelId, originalError: (error as Error).message } ); } diff --git a/src/services/serverstatsService.ts b/src/services/serverstatsService.ts index c30636a8e0..ae2b221de3 100644 --- a/src/services/serverstatsService.ts +++ b/src/services/serverstatsService.ts @@ -38,17 +38,17 @@ export interface ServerCounterInput { export const COUNTER_TYPE_CONFIG: Record = { members: { - label: 'Members + Bots', - baseName: 'Members & Bots', + label: 'Mitglieder + Bots', + baseName: 'Mitglieder & Bots', emoji: '👥' }, members_only: { - label: 'Members Only', - baseName: 'Members', + label: 'Nur Mitglieder', + baseName: 'Mitglieder', emoji: '👤' }, bots: { - label: 'Bots Only', + label: 'Nur Bots', baseName: 'Bots', emoji: '🤖' } @@ -56,8 +56,8 @@ export const COUNTER_TYPE_CONFIG: Record = { function getCounterConfig(type: string): CounterTypeConfig { return COUNTER_TYPE_CONFIG[type] || { - label: 'Unknown', - baseName: 'Counter', + label: 'Unbekannt', + baseName: 'Zähler', emoji: '❓' }; } @@ -222,11 +222,11 @@ export async function updateCounter(client: NexoClient, guild: Guild, counter: S guildId: guild.id, eventType: EVENT_TYPES.COUNTER_UPDATE, data: { - title: 'Counter Updated', + title: 'Zähler aktualisiert', lines: [ - formatLogLine('Type', getCounterTypeLabel(type)), - formatLogLine('Count', count.toString()), - formatLogLine('Channel', channel.toString()), + formatLogLine('Typ', getCounterTypeLabel(type)), + formatLogLine('Anzahl', count.toString()), + formatLogLine('Kanal', channel.toString()), ], channelId: channel.id, }, diff --git a/src/services/ticket.ts b/src/services/ticket.ts index 62894e43ae..9c6497fd3e 100644 --- a/src/services/ticket.ts +++ b/src/services/ticket.ts @@ -29,6 +29,12 @@ import { createError, ErrorTypes } from '../utils/errorHandler.js'; import type { ErrorContext, ErrorLike, ErrorType } from '../utils/errorHandler.js'; import { ensureTypedServiceError, wrapServiceBoundary } from '../utils/serviceErrorBoundary.js'; import { PRIORITY_MAP } from '../utils/helpers.js'; + +// Ticket embeds posted before the German translation still carry the English labels. +const isClaimedByField = (name: string): boolean => name === 'Übernommen von' || name === 'Claimed By'; +const isClaimStatusTitle = (title: string | null): boolean => + title === 'Ticket übernommen' || title === 'Ticket freigegeben' || title === 'Ticket Claimed' || title === 'Ticket Unclaimed'; +const isClosedTitle = (title: string | null): boolean => title === 'Ticket geschlossen' || title === 'Ticket Closed'; const TICKET_DELETE_DELAY_MS = 3000; const TICKET_DELETE_DELAY_SECONDS = Math.floor(TICKET_DELETE_DELAY_MS / 1000); const TICKET_SERVICE = 'ticketService'; @@ -48,7 +54,7 @@ function requireTicket(ticketData: TicketData | null, channel: TextChannel | nul if (!ticketData) { ticketUserError( 'Not a ticket channel', - 'This is not a ticket channel.', + 'Das ist kein Ticket-Kanal.', ErrorTypes.VALIDATION, { channelId: channel?.id, guildId: channel?.guild?.id } ); @@ -72,18 +78,18 @@ function buildTicketControlRow({ claimedBy = null }: { claimedBy?: string | null return new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('ticket_claim') - .setLabel(claimedBy ? 'Claimed' : 'Claim') + .setLabel(claimedBy ? 'Übernommen' : 'Übernehmen') .setStyle(claimedBy ? ButtonStyle.Secondary : ButtonStyle.Primary) .setEmoji('🙋') .setDisabled(!!claimedBy), new ButtonBuilder() .setCustomId('ticket_pin') - .setLabel('Pin') + .setLabel('Anpinnen') .setStyle(ButtonStyle.Secondary) .setEmoji('📌'), new ButtonBuilder() .setCustomId('ticket_close') - .setLabel('Close') + .setLabel('Schließen') .setStyle(ButtonStyle.Danger) .setEmoji('🔒'), ); @@ -94,7 +100,7 @@ export const getUserTicketCount = wrapServiceBoundary(async function getUserTick }, { service: TICKET_SERVICE, operation: 'getUserTicketCount', - userMessage: 'Failed to count open tickets.', + userMessage: 'Offene Tickets konnten nicht gezählt werden.', context: {}, }); @@ -102,7 +108,7 @@ export async function createTicket( guild: Guild, member: GuildMember, categoryId: string | null | undefined, - reason: string = 'No reason provided', + reason: string = 'Kein Grund angegeben', priority: string = 'none', ): Promise<{ channel: TextChannel; ticketData: TicketData }> { try { @@ -115,7 +121,7 @@ export async function createTicket( if (currentTicketCount >= maxTicketsPerUser) { 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.`, + `Du hast die maximale Anzahl offener Tickets erreicht (${maxTicketsPerUser}). Bitte schließe deine bestehenden Tickets, bevor du ein neues erstellst.`, ErrorTypes.VALIDATION, { guildId: guild.id, userId: member.id, operation: 'createTicket' } ); @@ -199,12 +205,12 @@ export async function createTicket( const embed = createEmbed({ title: `Ticket #${ticketNumber}`, - description: `${member.toString()}, thanks for creating a ticket!\n\n**Reason:** ${reason}\n**Priority:** ${priorityInfo.emoji} ${priorityInfo.label}`, + description: `${member.toString()}, danke für dein Ticket!\n\n**Grund:** ${reason}\n**Priorität:** ${priorityInfo.emoji} ${priorityInfo.label}`, color: priorityInfo.color, fields: [ - { name: 'Status', value: '🟢 Open', inline: true }, - { name: 'Claimed By', value: 'Not claimed', inline: true }, - { name: 'Created', value: ``, inline: true }, + { name: 'Status', value: '🟢 Offen', inline: true }, + { name: 'Übernommen von', value: 'Nicht übernommen', inline: true }, + { name: 'Erstellt', value: ``, inline: true }, ], }); @@ -214,12 +220,12 @@ export async function createTicket( row.addComponents( new ButtonBuilder() .setCustomId('ticket_priority:low') - .setLabel('Low') + .setLabel('Niedrig') .setStyle(ButtonStyle.Secondary) .setEmoji('🔵'), new ButtonBuilder() .setCustomId('ticket_priority:high') - .setLabel('High') + .setLabel('Hoch') .setStyle(ButtonStyle.Danger) .setEmoji('🔴') ); @@ -257,11 +263,11 @@ export async function createTicket( return { channel, ticketData }; } catch (error) { - rethrowTicketError(error, 'createTicket', 'Failed to create ticket. Please try again in a moment.', { guildId: guild?.id, userId: member?.id }); + rethrowTicketError(error, 'createTicket', 'Das Ticket konnte nicht erstellt werden. Bitte versuch es gleich noch mal.', { guildId: guild?.id, userId: member?.id }); } } -export async function closeTicket(channel: TextChannel, closer: User, reason: string = 'No reason provided'): Promise { +export async function closeTicket(channel: TextChannel, closer: User, reason: string = 'Kein Grund angegeben'): Promise { try { const ticketData = requireTicket(await getTicketData(channel.guild.id, channel.id), channel); @@ -298,20 +304,20 @@ export async function closeTicket(channel: TextChannel, closer: User, reason: st const ticketCreator = await channel.client.users.fetch(ticketData.userId).catch(() => null); if (ticketCreator) { const dmEmbed = createEmbed({ - title: '🎫 Your Ticket Has Been Closed', - description: `Your ticket **${channel.name}** has been closed.\n\n**Reason:** ${reason}\n**Closed by:** ${closer.tag}\n**Closed at:** \n\nThank you for using our support system! If you have any further questions, feel free to create a new ticket.`, + title: '🎫 Dein Ticket wurde geschlossen', + description: `Dein Ticket **${channel.name}** wurde geschlossen.\n\n**Grund:** ${reason}\n**Geschlossen von:** ${closer.tag}\n**Geschlossen am:** \n\nDanke, dass du unseren Support genutzt hast! Wenn du weitere Fragen hast, erstell einfach ein neues Ticket.`, color: '#e74c3c', - footer: { text: `Ticket ID: ${ticketData.id}` } + footer: { text: `Ticket-ID: ${ticketData.id}` } }); await ticketCreator.send({ embeds: [dmEmbed] }); try { const feedbackEmbed = createEmbed({ - title: '⭐ How was your support experience?', - description: `We'd love to know how we did with **${channel.name}**.\nSelect a rating below — it only takes a second!`, + title: '⭐ Wie zufrieden warst du mit dem Support?', + description: `Wir würden gern wissen, wie es mit **${channel.name}** gelaufen ist.\nWähl unten eine Bewertung – dauert nur eine Sekunde!`, color: '#F1C40F', - footer: { text: 'Your feedback helps us improve.' }, + footer: { text: 'Dein Feedback hilft uns, besser zu werden.' }, }); const base = `ticket_feedback:${channel.guild.id}:${channel.id}`; @@ -325,11 +331,11 @@ export async function closeTicket(channel: TextChannel, closer: User, reason: st const declineRow = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`ticket_feedback_comment:${channel.guild.id}:${channel.id}`) - .setLabel('✍️ Add Comment') + .setLabel('✍️ Kommentar hinzufügen') .setStyle(ButtonStyle.Secondary), new ButtonBuilder() .setCustomId(`ticket_feedback_decline:${channel.guild.id}:${channel.id}`) - .setLabel('❌ No thanks') + .setLabel('❌ Nein, danke') .setStyle(ButtonStyle.Secondary), ); @@ -379,12 +385,12 @@ export async function closeTicket(channel: TextChannel, closer: User, reason: st const statusField = embed.fields?.find(f => f.name === 'Status'); if (statusField) { - statusField.value = '🔴 Closed'; + statusField.value = '🔴 Geschlossen'; } const updatedEmbed = createEmbed({ title: embed.title || 'Ticket', - description: embed.description || 'Ticket discussion', + description: embed.description || 'Ticket-Unterhaltung', color: '#e74c3c', fields: embed.fields || [], footer: embed.footer @@ -397,21 +403,21 @@ components: [] } const closeEmbed = createEmbed({ - title: 'Ticket Closed', - description: `This ticket has been closed by ${closer}.\n**Reason:** ${reason}${dmOnClose ? '\n\n📩 A DM has been sent to the ticket creator.' : ''}`, + title: 'Ticket geschlossen', + description: `Dieses Ticket wurde von ${closer} geschlossen.\n**Grund:** ${reason}${dmOnClose ? '\n\n📩 Der Ticket-Ersteller hat eine DM erhalten.' : ''}`, color: '#e74c3c', - footer: { text: `Ticket ID: ${ticketData.id}` } + footer: { text: `Ticket-ID: ${ticketData.id}` } }); const controlRow = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('ticket_reopen') - .setLabel('Reopen Ticket') + .setLabel('Ticket wieder öffnen') .setStyle(ButtonStyle.Success) .setEmoji('🔓'), new ButtonBuilder() .setCustomId('ticket_delete') - .setLabel('Delete Ticket') + .setLabel('Ticket löschen') .setStyle(ButtonStyle.Danger) .setEmoji('🗑️') ); @@ -439,7 +445,7 @@ components: [] return ticketData; } catch (error) { - rethrowTicketError(error, 'closeTicket', 'Failed to close ticket. Please try again in a moment.', { guildId: channel?.guild?.id, channelId: channel?.id, closerId: closer?.id }); + rethrowTicketError(error, 'closeTicket', 'Das Ticket konnte nicht geschlossen werden. Bitte versuch es gleich noch mal.', { guildId: channel?.guild?.id, channelId: channel?.id, closerId: closer?.id }); } } @@ -450,7 +456,7 @@ export async function claimTicket(channel: TextChannel, claimer: User): Promise< if (ticketData.claimedBy) { ticketUserError( 'Ticket already claimed', - `This ticket is already claimed by <@${ticketData.claimedBy}>`, + `Dieses Ticket wurde bereits von <@${ticketData.claimedBy}> übernommen.`, ErrorTypes.VALIDATION, { channelId: channel.id, claimedBy: ticketData.claimedBy, operation: 'claimTicket' } ); @@ -469,7 +475,7 @@ export async function claimTicket(channel: TextChannel, claimer: User): Promise< if (ticketMessage) { const embed = ticketMessage.embeds[0]; - const claimedField = embed.fields?.find(f => f.name === 'Claimed By'); + const claimedField = embed.fields?.find(f => isClaimedByField(f.name)); if (claimedField) { claimedField.value = claimer.toString(); @@ -484,22 +490,22 @@ export async function claimTicket(channel: TextChannel, claimer: User): Promise< } const claimEmbed = createEmbed({ - title: 'Ticket Claimed', - description: `🎉 ${claimer} has claimed this ticket!`, + title: 'Ticket übernommen', + description: `🎉 ${claimer} hat dieses Ticket übernommen!`, color: '#2ecc71' }); const unclaimRow = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('ticket_unclaim') - .setLabel('Unclaim') + .setLabel('Freigeben') .setStyle(ButtonStyle.Secondary) .setEmoji('🔓') ); const claimStatusMessage = messages.find(m => m.embeds.length > 0 && - (m.embeds[0].title === 'Ticket Claimed' || m.embeds[0].title === 'Ticket Unclaimed') + isClaimStatusTitle(m.embeds[0].title) ); if (claimStatusMessage) { @@ -526,7 +532,7 @@ export async function claimTicket(channel: TextChannel, claimer: User): Promise< return ticketData; } catch (error) { - rethrowTicketError(error, 'claimTicket', 'Failed to claim ticket. Please try again in a moment.', { guildId: channel?.guild?.id, channelId: channel?.id, claimerId: claimer?.id }); + rethrowTicketError(error, 'claimTicket', 'Das Ticket konnte nicht übernommen werden. Bitte versuch es gleich noch mal.', { guildId: channel?.guild?.id, channelId: channel?.id, claimerId: claimer?.id }); } } @@ -540,7 +546,7 @@ export async function reopenTicket( if (ticketData.status !== 'closed') { ticketUserError( 'Ticket not closed', - 'This ticket is not currently closed.', + 'Dieses Ticket ist gerade nicht geschlossen.', ErrorTypes.VALIDATION, { channelId: channel.id, operation: 'reopenTicket' } ); @@ -601,7 +607,7 @@ export async function reopenTicket( const statusField = embed.fields?.find(f => f.name === 'Status'); if (statusField) { - statusField.value = '🟢 Open'; + statusField.value = '🟢 Offen'; } const row = buildTicketControlRow({ claimedBy: ticketData.claimedBy }); @@ -613,14 +619,14 @@ export async function reopenTicket( } const reopenEmbed = createEmbed({ - title: 'Ticket Reopened', - description: `🔓 ${reopener} has reopened this ticket!`, + title: 'Ticket wieder geöffnet', + description: `🔓 ${reopener} hat dieses Ticket wieder geöffnet!`, color: '#2ecc71' }); const closeStatusMessage = messages.find(m => m.embeds.length > 0 && - m.embeds[0].title === 'Ticket Closed' && + isClosedTitle(m.embeds[0].title) && m.components.length > 0 && // Ticket messages only carry classic action rows. (m.components[0] as ActionRow).components.some(c => c.customId === 'ticket_reopen') @@ -635,7 +641,7 @@ export async function reopenTicket( return { ticketData, movedToOpenCategory, openCategoryMoveFailed }; } catch (error) { - rethrowTicketError(error, 'reopenTicket', 'Failed to reopen ticket. Please try again in a moment.', { guildId: channel?.guild?.id, channelId: channel?.id, reopenerId: reopener?.id }); + rethrowTicketError(error, 'reopenTicket', 'Das Ticket konnte nicht wieder geöffnet werden. Bitte versuch es gleich noch mal.', { guildId: channel?.guild?.id, channelId: channel?.id, reopenerId: reopener?.id }); } } @@ -677,17 +683,17 @@ async function generateTranscript(channel: TextChannel): Promise { const ts = new Date(msg.createdTimestamp).toISOString().replace('T', ' ').slice(0, 19); - const author = escape(msg.author?.tag ?? msg.author?.username ?? 'Unknown'); - const content = escape(msg.content || (msg.embeds.length ? '[embed]' : '[attachment]')); + const author = escape(msg.author?.tag ?? msg.author?.username ?? 'Unbekannt'); + const content = escape(msg.content || (msg.embeds.length ? '[Embed]' : '[Anhang]')); return `${ts}${author}${content}`; }).join('\n'); const html = ` - + -Transcript – #${escape(channel.name)} +Transkript – #${escape(channel.name)} -

📜 Transcript – #${escape(channel.name)}

-

${messages.length} message(s) exported on ${new Date().toUTCString()}

+

📜 Transkript – #${escape(channel.name)}

+

${messages.length} Nachricht(en) exportiert am ${new Date().toUTCString()}

- + ${rows} @@ -739,10 +745,10 @@ export async function deleteTicket(channel: TextChannel, deleter: TicketDeleter) const ticketData = requireTicket(await getTicketData(channel.guild.id, channel.id), channel); const deleteEmbed = createEmbed({ - title: 'Ticket Deleted', - description: `🗑️ This ticket will be permanently deleted in ${TICKET_DELETE_DELAY_SECONDS} seconds.`, + title: 'Ticket gelöscht', + description: `🗑️ Dieses Ticket wird in ${TICKET_DELETE_DELAY_SECONDS} Sekunden endgültig gelöscht.`, color: '#e74c3c', - footer: { text: `Ticket ID: ${ticketData.id}` } + footer: { text: `Ticket-ID: ${ticketData.id}` } }); await channel.send({ embeds: [deleteEmbed] }); @@ -816,14 +822,14 @@ export async function deleteTicket(channel: TextChannel, deleter: TicketDeleter) const transcriptEmbed = buildStandardLogEmbed({ color: 0x3498db, - title: 'Ticket Transcript', + title: 'Ticket-Transkript', description: [ formatLogLine('Ticket', `#${ticketData.id}`), - formatLogLine('Channel', `#${channel.name}`), - formatLogLine('Generated', ``), + formatLogLine('Kanal', `#${channel.name}`), + formatLogLine('Erstellt', ``), ].join('\n'), footer: deleter?.username - ? { text: `Deleted by ${deleter.username}`, iconURL: deleter.displayAvatarURL?.() } + ? { text: `Gelöscht von ${deleter.username}`, iconURL: deleter.displayAvatarURL?.() } : undefined, timestamp: true, }); @@ -850,7 +856,7 @@ export async function deleteTicket(channel: TextChannel, deleter: TicketDeleter) } try { - await channel.delete('Ticket deleted permanently'); + await channel.delete('Ticket endgültig gelöscht'); logger.info('✅ Channel deleted', { channelId: channel.id, channelName: channel.name, @@ -881,7 +887,7 @@ export async function deleteTicket(channel: TextChannel, deleter: TicketDeleter) return ticketData; } catch (error) { - rethrowTicketError(error, 'deleteTicket', 'Failed to delete ticket. Please try again in a moment.', { guildId: channel?.guild?.id, channelId: channel?.id, deleterId: deleter?.id }); + rethrowTicketError(error, 'deleteTicket', 'Das Ticket konnte nicht gelöscht werden. Bitte versuch es gleich noch mal.', { guildId: channel?.guild?.id, channelId: channel?.id, deleterId: deleter?.id }); } } @@ -892,7 +898,7 @@ export async function unclaimTicket(channel: TextChannel, unclaimer: GuildMember if (!ticketData.claimedBy) { ticketUserError( 'Ticket not claimed', - 'This ticket is not currently claimed.', + 'Dieses Ticket ist gerade nicht übernommen.', ErrorTypes.VALIDATION, { channelId: channel.id, operation: 'unclaimTicket' } ); @@ -901,7 +907,7 @@ export async function unclaimTicket(channel: TextChannel, unclaimer: GuildMember if (ticketData.claimedBy !== unclaimer.id && !unclaimer.permissions.has(PermissionFlagsBits.ManageChannels)) { ticketUserError( 'Cannot unclaim ticket', - 'You can only unclaim your own tickets or need Manage Channels permission.', + 'Du kannst nur deine eigenen Tickets freigeben oder brauchst die Berechtigung „Kanäle verwalten“.', ErrorTypes.PERMISSION, { channelId: channel.id, operation: 'unclaimTicket' } ); @@ -921,10 +927,10 @@ export async function unclaimTicket(channel: TextChannel, unclaimer: GuildMember if (ticketMessage) { const embed = ticketMessage.embeds[0]; - const claimedField = embed.fields?.find(f => f.name === 'Claimed By'); + const claimedField = embed.fields?.find(f => isClaimedByField(f.name)); if (claimedField) { - claimedField.value = 'Not claimed'; + claimedField.value = 'Nicht übernommen'; } const row = buildTicketControlRow(); @@ -937,13 +943,13 @@ export async function unclaimTicket(channel: TextChannel, unclaimer: GuildMember const claimMessage = messages.find(m => m.embeds.length > 0 && - (m.embeds[0].title === 'Ticket Claimed' || m.embeds[0].title === 'Ticket Unclaimed') + isClaimStatusTitle(m.embeds[0].title) ); if (claimMessage) { const unclaimEmbed = createEmbed({ - title: 'Ticket Unclaimed', - description: `🔓 ${unclaimer} has unclaimed this ticket!`, + title: 'Ticket freigegeben', + description: `🔓 ${unclaimer} hat dieses Ticket freigegeben!`, color: '#f39c12' }); @@ -953,8 +959,8 @@ export async function unclaimTicket(channel: TextChannel, unclaimer: GuildMember }); } else { const unclaimEmbed = createEmbed({ - title: 'Ticket Unclaimed', - description: `🔓 ${unclaimer} has unclaimed this ticket!`, + title: 'Ticket freigegeben', + description: `🔓 ${unclaimer} hat dieses Ticket freigegeben!`, color: '#f39c12' }); @@ -979,7 +985,7 @@ export async function unclaimTicket(channel: TextChannel, unclaimer: GuildMember return ticketData; } catch (error) { - rethrowTicketError(error, 'unclaimTicket', 'Failed to unclaim ticket. Please try again in a moment.', { guildId: channel?.guild?.id, channelId: channel?.id, unclaimerId: unclaimer?.id }); + rethrowTicketError(error, 'unclaimTicket', 'Das Ticket konnte nicht freigegeben werden. Bitte versuch es gleich noch mal.', { guildId: channel?.guild?.id, channelId: channel?.id, unclaimerId: unclaimer?.id }); } } @@ -995,7 +1001,7 @@ export async function updateTicketPriority(channel: TextChannel, priority: strin if (!priorityInfo) { ticketUserError( 'Invalid priority level', - 'Invalid priority level.', + 'Ungültige Priorität.', ErrorTypes.VALIDATION, { channelId: channel.id, priority, operation: 'updateTicketPriority' } ); @@ -1034,7 +1040,7 @@ export async function updateTicketPriority(channel: TextChannel, priority: strin const updatedEmbed = createEmbed({ title: embed.title || 'Ticket', - description: embed.description?.split('\n**Priority:**')[0] + `\n**Priority:** ${priorityInfo.emoji} ${priorityInfo.label}`, + description: embed.description?.split(/\n\*\*(?:Priorität|Priority):\*\*/)[0] + `\n**Priorität:** ${priorityInfo.emoji} ${priorityInfo.label}`, color: priorityInfo.color, fields: embed.fields || [], footer: embed.footer @@ -1044,8 +1050,8 @@ export async function updateTicketPriority(channel: TextChannel, priority: strin } const updateEmbed = createEmbed({ - title: 'Priority Updated', - description: `📊 Ticket priority updated to **${priorityInfo.emoji} ${priorityInfo.label}** by ${updater}`, + title: 'Priorität aktualisiert', + description: `📊 Ticket-Priorität von ${updater} auf **${priorityInfo.emoji} ${priorityInfo.label}** gesetzt`, color: priorityInfo.color }); @@ -1071,6 +1077,6 @@ export async function updateTicketPriority(channel: TextChannel, priority: strin return ticketData; } catch (error) { - 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 }); + rethrowTicketError(error, 'updateTicketPriority', 'Die Ticket-Priorität konnte nicht aktualisiert werden. Bitte versuch es gleich noch mal.', { guildId: channel?.guild?.id, channelId: channel?.id, updaterId: updater?.id, priority }); } } \ No newline at end of file diff --git a/src/services/verificationService.ts b/src/services/verificationService.ts index 7cc3749dc5..e50215c2e8 100644 --- a/src/services/verificationService.ts +++ b/src/services/verificationService.ts @@ -61,7 +61,7 @@ export async function verifyUser(client: NexoClient, guildId: string, userId: st throw createError( `Guild ${guildId} not found`, ErrorTypes.CONFIGURATION, - "Guild not found in bot cache.", + "Der Server wurde im Bot-Cache nicht gefunden.", { guildId } ); } @@ -73,7 +73,7 @@ export async function verifyUser(client: NexoClient, guildId: string, userId: st throw createError( `Member ${userId} not found in guild`, ErrorTypes.USER_INPUT, - "User is not in this server.", + "Der Nutzer ist nicht auf diesem Server.", { userId, guildId } ); } @@ -84,7 +84,7 @@ export async function verifyUser(client: NexoClient, guildId: string, userId: st throw createError( "Verification system disabled", ErrorTypes.CONFIGURATION, - "The verification system is not enabled on this server.", + "Die Verifizierung ist auf diesem Server nicht aktiviert.", { guildId } ); } @@ -98,7 +98,7 @@ export async function verifyUser(client: NexoClient, guildId: string, userId: st throw createError( 'Bot cannot assign verified role', ErrorTypes.PERMISSION, - "I can't assign the verified role. Please check my **Manage Roles** permission and role hierarchy.", + "Ich kann die Verifiziert-Rolle nicht vergeben. Bitte prüfe meine Berechtigung **Rollen verwalten** und die Rollenhierarchie.", { guildId, roleId: verifiedRole.id } ); } @@ -115,7 +115,7 @@ export async function verifyUser(client: NexoClient, guildId: string, userId: st await checkVerificationCooldown(userId, guildId, defaultCooldownMs); await trackVerificationAttempt(userId, guildId, defaultMaxAttempts, defaultAttemptWindowMs); - await member.roles.add(verifiedRole.id, `User verified (${source})`); + await member.roles.add(verifiedRole.id, `Nutzer verifiziert (${source})`); logVerificationAction(client, guildId, userId, 'verified', { source, @@ -145,7 +145,7 @@ export async function verifyUser(client: NexoClient, guildId: string, userId: st operation: 'verifyUser', type: ErrorTypes.UNKNOWN, message: 'Verification operation failed: verifyUser', - userMessage: 'Verification failed. Please try again in a moment.', + userMessage: 'Die Verifizierung ist fehlgeschlagen. Bitte versuch es gleich noch mal.', context: { guildId, userId, source: options.source } }); logger.error('Error verifying user', { @@ -260,7 +260,7 @@ export async function autoVerifyOnJoin(client: NexoClient, guild: Guild, member: }; } - await member.roles.add(verifiedRole.id, 'Auto-verified on join'); + await member.roles.add(verifiedRole.id, 'Beim Beitritt automatisch verifiziert'); logVerificationAction(client, guild.id, member.id, 'auto_verified', { criteria: verificationConfig.autoVerify.criteria, @@ -295,7 +295,7 @@ export async function autoVerifyOnJoin(client: NexoClient, guild: Guild, member: operation: 'autoVerifyOnJoin', type: ErrorTypes.UNKNOWN, message: 'Verification operation failed: autoVerifyOnJoin', - userMessage: 'Automatic verification failed. Please verify manually.', + userMessage: 'Die automatische Verifizierung ist fehlgeschlagen. Bitte verifiziere dich manuell.', context: { guildId: guild.id, userId: member.id } }); logger.error('Error in auto-verification on join', { @@ -323,7 +323,7 @@ export async function removeVerification(client: NexoClient, guildId: string, us throw createError( `Guild ${guildId} not found`, ErrorTypes.CONFIGURATION, - "Guild not found.", + "Server nicht gefunden.", { guildId } ); } @@ -335,7 +335,7 @@ export async function removeVerification(client: NexoClient, guildId: string, us throw createError( `Member ${userId} not found`, ErrorTypes.USER_INPUT, - "User is not in this server.", + "Der Nutzer ist nicht auf diesem Server.", { userId } ); } @@ -346,7 +346,7 @@ export async function removeVerification(client: NexoClient, guildId: string, us throw createError( "Verification system disabled", ErrorTypes.CONFIGURATION, - "The verification system is not enabled.", + "Die Verifizierung ist nicht aktiviert.", { guildId } ); } @@ -356,7 +356,7 @@ export async function removeVerification(client: NexoClient, guildId: string, us throw createError( "Verified role not found", ErrorTypes.CONFIGURATION, - "The verified role no longer exists.", + "Die Verifiziert-Rolle existiert nicht mehr.", { roleId: guildConfig.verification.roleId } ); } @@ -366,7 +366,7 @@ export async function removeVerification(client: NexoClient, guildId: string, us throw createError( 'Bot cannot manage verified role', ErrorTypes.PERMISSION, - "I can't remove the verified role right now. Please check my **Manage Roles** permission and role hierarchy.", + "Ich kann die Verifiziert-Rolle gerade nicht entfernen. Bitte prüfe meine Berechtigung **Rollen verwalten** und die Rollenhierarchie.", { guildId, roleId: verifiedRole.id } ); } @@ -380,7 +380,7 @@ export async function removeVerification(client: NexoClient, guildId: string, us await member.roles.remove( verifiedRole.id, - `Verification removed by ${moderatorId || 'system'}: ${reason}` + `Verifizierung entfernt von ${moderatorId || 'System'}: ${reason}` ); logVerificationAction(client, guildId, userId, 'removed', { @@ -409,7 +409,7 @@ export async function removeVerification(client: NexoClient, guildId: string, us operation: 'removeVerification', type: ErrorTypes.UNKNOWN, message: 'Verification operation failed: removeVerification', - userMessage: 'Failed to remove verification. Please try again in a moment.', + userMessage: 'Die Verifizierung konnte nicht entfernt werden. Bitte versuch es gleich noch mal.', context: { guildId, userId, reason } }); logger.error('Error removing verification', { @@ -428,7 +428,7 @@ export async function validateVerificationSetup(guild: Guild, verificationConfig throw createError( 'Bot member not available in guild cache', ErrorTypes.CONFIGURATION, - "I couldn't verify my server permissions. Please try again.", + "Ich konnte meine Server-Berechtigungen nicht prüfen. Bitte versuch es erneut.", { guildId: guild.id } ); } @@ -438,7 +438,7 @@ export async function validateVerificationSetup(guild: Guild, verificationConfig throw createError( "Verified role not found", ErrorTypes.CONFIGURATION, - "The verified role was deleted. Please run `/verification setup` again.", + "Die Verifiziert-Rolle wurde gelöscht. Bitte führe `/verification setup` erneut aus.", { roleId: verificationConfig.roleId, guildId: guild.id } ); } @@ -449,7 +449,7 @@ export async function validateVerificationSetup(guild: Guild, verificationConfig throw createError( "Verification channel not found", ErrorTypes.CONFIGURATION, - "The verification channel was deleted.", + "Der Verifizierungskanal wurde gelöscht.", { channelId: verificationConfig.channelId, guildId: guild.id } ); } @@ -462,7 +462,7 @@ export async function validateVerificationSetup(guild: Guild, verificationConfig throw createError( "Bot missing permissions in verification channel", ErrorTypes.PERMISSION, - `I'm missing permissions in the verification channel: ${missingPerms.join(', ')}`, + `Mir fehlen Berechtigungen im Verifizierungskanal: ${missingPerms.join(', ')}`, { missingPerms, channelId: channel.id } ); } @@ -546,7 +546,7 @@ export async function checkVerificationCooldown(userId: string, guildId: string, throw createError( "User on verification cooldown", ErrorTypes.RATE_LIMIT, - `Please wait ${Math.ceil(remaining / 1000)} seconds before verifying again.`, + `Bitte warte ${Math.ceil(remaining / 1000)} Sekunden, bevor du dich erneut verifizierst.`, { userId, guildId, cooldownRemaining: remaining } ); } @@ -572,7 +572,7 @@ export async function trackVerificationAttempt( throw createError( "Too many verification attempts", ErrorTypes.RATE_LIMIT, - "You've attempted too many times. Please wait a moment.", + "Du hast es zu oft versucht. Bitte warte einen Moment.", { attempts: recentAttempts.length, maxAttempts } ); } @@ -586,17 +586,17 @@ async function sendAutoVerifyNotification(member: GuildMember, role: Role, guild const { createEmbed } = await import('../utils/embeds.js'); const embed = createEmbed({ - title: "🎉 Welcome to the Server!", - description: `You have been automatically verified in **${guild.name}**!`, + title: "🎉 Willkommen auf dem Server!", + description: `Du wurdest auf **${guild.name}** automatisch verifiziert!`, fields: [ { - name: "✅ Role Assigned", - value: `You now have the ${role} role!`, + name: "✅ Rolle vergeben", + value: `Du hast jetzt die Rolle ${role}!`, inline: false }, { - name: "📖 What's Next?", - value: "You now have access to all server channels and features. Welcome!", + name: "📖 Wie geht es weiter?", + value: "Du hast jetzt Zugriff auf alle Kanäle und Funktionen des Servers. Willkommen!", inline: false } ], @@ -680,7 +680,7 @@ export function validateAutoVerifyCriteria(criteria: string, accountAgeDays: num throw createError( `Invalid auto-verify criteria: ${criteria}`, ErrorTypes.VALIDATION, - "Please select a valid criteria option.", + "Bitte wähle ein gültiges Kriterium.", { criteria, validCriteria } ); } @@ -690,7 +690,7 @@ export function validateAutoVerifyCriteria(criteria: string, accountAgeDays: num throw createError( "Invalid account age days", ErrorTypes.VALIDATION, - `Account age must be between ${minAutoVerifyAccountAgeDays} and ${maxAutoVerifyAccountAgeDays} days.`, + `Das Kontoalter muss zwischen ${minAutoVerifyAccountAgeDays} und ${maxAutoVerifyAccountAgeDays} Tagen liegen.`, { accountAgeDays, minAutoVerifyAccountAgeDays, maxAutoVerifyAccountAgeDays } ); } diff --git a/src/utils/abuseProtection.ts b/src/utils/abuseProtection.ts index ab40777888..195ec804c6 100644 --- a/src/utils/abuseProtection.ts +++ b/src/utils/abuseProtection.ts @@ -133,7 +133,7 @@ function recordBlockedAttempt( export function formatCooldownDuration(ms: number): string { if (!Number.isFinite(ms) || ms <= 0) { - return 'a moment'; + return 'einen Moment'; } const totalSeconds = Math.ceil(ms / 1000); diff --git a/src/utils/commandInputValidation.ts b/src/utils/commandInputValidation.ts index d65f023c53..4bde89e1f9 100644 --- a/src/utils/commandInputValidation.ts +++ b/src/utils/commandInputValidation.ts @@ -56,7 +56,7 @@ export function validateChatInputPayloadOrThrow( throw createError( 'Invalid command input payload', ErrorTypes.VALIDATION, - 'One or more command inputs are invalid. Please review your options and try again.', + 'Mindestens eine Eingabe ist ungültig. Bitte prüfe deine Optionen und versuch es erneut.', { ...context, errorCode: 'VALIDATION_FAILED', diff --git a/src/utils/commandRouter.ts b/src/utils/commandRouter.ts index 55304d7dc0..fb82eda3c5 100644 --- a/src/utils/commandRouter.ts +++ b/src/utils/commandRouter.ts @@ -158,7 +158,7 @@ export function createRouterCommand(options: RouterOptions): Command { throw createError( `No route for /${options.name}`, ErrorTypes.VALIDATION, - 'Unknown subcommand.', + 'Unbekannter Subcommand.', { commandName: options.name }, ); } @@ -167,7 +167,7 @@ export function createRouterCommand(options: RouterOptions): Command { throw createError( `/${options.name} ${route.legacyName} used outside a guild`, ErrorTypes.VALIDATION, - 'This command can only be used in a server.', + 'Dieser Command kann nur auf einem Server genutzt werden.', { commandName: options.name }, ); } diff --git a/src/utils/components.ts b/src/utils/components.ts index b0d63da87b..ad85faeab0 100644 --- a/src/utils/components.ts +++ b/src/utils/components.ts @@ -8,12 +8,12 @@ export function getConfirmationButtons(customIdPrefix: string = 'confirm'): Acti return new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`${customIdPrefix}_yes`) - .setLabel('Confirm') + .setLabel('Bestätigen') .setStyle(ButtonStyle.Success) .setEmoji('✅'), new ButtonBuilder() .setCustomId(`${customIdPrefix}_no`) - .setLabel('Cancel') + .setLabel('Abbrechen') .setStyle(ButtonStyle.Danger) .setEmoji('❌') ); @@ -33,7 +33,7 @@ export function getPaginationRow(customIdPrefix: string = 'page', currentPage: n .setDisabled(currentPage === 1), new ButtonBuilder() .setCustomId(`${customIdPrefix}_page`) - .setLabel(`Page ${currentPage} of ${totalPages}`) + .setLabel(`Seite ${currentPage} von ${totalPages}`) .setStyle(ButtonStyle.Secondary) .setDisabled(true), new ButtonBuilder() diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 18382d6225..23490e63b6 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -71,11 +71,11 @@ export const DEFAULTS = { }; export const ERROR_DEFAULTS = { - INVALID_INPUT: 'Invalid input provided', - DATABASE_ERROR: 'Database operation failed', - NOT_FOUND: 'Not found', - INSUFFICIENT_PERMISSIONS: 'Insufficient permissions', - INVALID_FORMAT: 'Invalid format' + INVALID_INPUT: 'Ungültige Eingabe', + DATABASE_ERROR: 'Datenbankvorgang fehlgeschlagen', + NOT_FOUND: 'Nicht gefunden', + INSUFFICIENT_PERMISSIONS: 'Unzureichende Berechtigungen', + INVALID_FORMAT: 'Ungültiges Format' }; export const TIME = { diff --git a/src/utils/dashboardSession.ts b/src/utils/dashboardSession.ts index dcef83ee5a..6aeb8497b6 100644 --- a/src/utils/dashboardSession.ts +++ b/src/utils/dashboardSession.ts @@ -57,8 +57,8 @@ function wrapHandler( const errorMessage = error instanceof NexoBotError - ? error.userMessage || 'An error occurred while processing your selection.' - : 'An unexpected error occurred while updating the configuration.'; + ? error.userMessage || 'Beim Verarbeiten deiner Auswahl ist ein Fehler aufgetreten.' + : 'Beim Aktualisieren der Konfiguration ist ein unerwarteter Fehler aufgetreten.'; if (!componentInteraction.replied && !componentInteraction.deferred) { await componentInteraction.deferUpdate().catch(() => {}); @@ -136,9 +136,9 @@ export async function startDashboardSession({ } const timeoutEmbed = new EmbedBuilder() - .setTitle('Dashboard Timed Out') + .setTitle('Dashboard abgelaufen') .setDescription( - 'This dashboard has been closed due to inactivity. Please run the command again to continue.', + 'Dieses Dashboard wurde wegen Inaktivität geschlossen. Führe den Command erneut aus, um weiterzumachen.', ) .setColor(getColor('error')); diff --git a/src/utils/database.ts b/src/utils/database.ts index d194651951..378326df5b 100644 --- a/src/utils/database.ts +++ b/src/utils/database.ts @@ -221,11 +221,11 @@ export async function deleteBirthday(client: NexoClient, guildId: string, userId export function getMonthName(monthNum: number): string { const months = [ - 'January', 'February', 'March', 'April', 'May', 'June', - 'July', 'August', 'September', 'October', 'November', 'December' + 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', + 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember' ]; const index = Math.max(0, Math.min(monthNum - 1, 11)); - return monthNum >= 1 && monthNum <= 12 ? months[index] : 'Invalid Month'; + return monthNum >= 1 && monthNum <= 12 ? months[index] : 'Ungültiger Monat'; } function isPostgresSqlReady(dbWrapper: NexoClient['db'] | undefined): boolean { @@ -402,23 +402,23 @@ function normalizeWelcomeConfig(raw: unknown = {}): WelcomeConfig { const channelId = base.channelId ?? null; const goodbyeChannelId = base.goodbyeChannelId ?? null; - const welcomeMessage = base.welcomeMessage ?? "Welcome {user} to {server}!"; - const leaveMessage = base.leaveMessage ?? "{user.tag} has left the server."; + const welcomeMessage = base.welcomeMessage ?? "Willkommen {user} auf {server}!"; + const leaveMessage = base.leaveMessage ?? "{user.tag} hat den Server verlassen."; const welcomeEmbed = base.welcomeEmbed ?? { - title: "🎉 Welcome!", - description: "Welcome {user} to {server}!", + title: "🎉 Willkommen!", + description: "Willkommen {user} auf {server}!", color: getColor("success"), thumbnail: true, - footer: "Welcome to {server}!" + footer: "Willkommen auf {server}!" }; const leaveEmbed = base.leaveEmbed ?? { - title: "👋 Goodbye", - description: "{user.tag} has left the server.", + title: "👋 Auf Wiedersehen", + description: "{user.tag} hat den Server verlassen.", color: getColor("error"), thumbnail: true, - footer: "Goodbye from {server}!" + footer: "Tschüss von {server}!" }; const roleIds = Array.isArray(base.roleIds) ? base.roleIds : []; @@ -1226,7 +1226,7 @@ export async function getJoinToCreateConfig(client: NexoClient, guildId: string) enabled: false, triggerChannels: [], categoryId: null, - channelNameTemplate: "{username}'s Room", + channelNameTemplate: "Raum von {username}", userLimit: 0, bitrate: 64000, temporaryChannels: {} @@ -1242,7 +1242,7 @@ export async function getJoinToCreateConfig(client: NexoClient, guildId: string) enabled: unwrapped.enabled || false, triggerChannels: unwrapped.triggerChannels || [], categoryId: unwrapped.categoryId || null, - channelNameTemplate: unwrapped.channelNameTemplate || "{username}'s Room", + channelNameTemplate: unwrapped.channelNameTemplate || "Raum von {username}", userLimit: unwrapped.userLimit || 0, bitrate: unwrapped.bitrate || 64000, temporaryChannels: unwrapped.temporaryChannels || {}, @@ -1254,7 +1254,7 @@ export async function getJoinToCreateConfig(client: NexoClient, guildId: string) enabled: false, triggerChannels: [], categoryId: null, - channelNameTemplate: "{username}'s Room", + channelNameTemplate: "Raum von {username}", userLimit: 0, bitrate: 64000, temporaryChannels: {} @@ -1415,11 +1415,11 @@ export function formatChannelName(template: string, variables: ChannelNameVariab let formatted = template; const replacements = { - '{username}': variables.username || 'User', - '{user_tag}': variables.userTag || 'User#0000', - '{display_name}': variables.displayName || 'User', + '{username}': variables.username || 'Nutzer', + '{user_tag}': variables.userTag || 'Nutzer#0000', + '{display_name}': variables.displayName || 'Nutzer', '{guild_name}': variables.guildName || 'Server', - '{channel_name}': variables.channelName || 'Voice Channel' + '{channel_name}': variables.channelName || 'Sprachkanal' }; for (const [placeholder, value] of Object.entries(replacements)) { @@ -1429,7 +1429,7 @@ export function formatChannelName(template: string, variables: ChannelNameVariab formatted = formatted.replace(/[^\w\s-]/g, '').trim(); formatted = formatted.substring(0, 100); - return formatted || 'Voice Channel'; + return formatted || 'Sprachkanal'; } function generateCaseId(): string { diff --git a/src/utils/database/guildConfigStorage.ts b/src/utils/database/guildConfigStorage.ts index 3948b143e8..4ee4fc3ddd 100644 --- a/src/utils/database/guildConfigStorage.ts +++ b/src/utils/database/guildConfigStorage.ts @@ -88,7 +88,7 @@ export async function writeGuildConfig( throw createError( 'Database client unavailable for guild config write', ErrorTypes.DATABASE, - 'Failed to save server configuration. The database is unavailable.', + 'Die Server-Konfiguration konnte nicht gespeichert werden. Die Datenbank ist nicht erreichbar.', { guildId, ...context }, ); } @@ -100,7 +100,7 @@ export async function writeGuildConfig( throw createError( 'Guild config write rejected by database layer', ErrorTypes.DATABASE, - 'Failed to save server configuration. Please try again.', + 'Die Server-Konfiguration konnte nicht gespeichert werden. Bitte versuch es erneut.', { guildId, ...context }, ); } diff --git a/src/utils/economy.ts b/src/utils/economy.ts index e1139181e2..15aae87862 100644 --- a/src/utils/economy.ts +++ b/src/utils/economy.ts @@ -112,7 +112,7 @@ export function getMaxBankCapacity(userData: EconomyData | null | undefined): nu } export function formatCurrency(amount: number): string { - const currencyName = ECONOMY_CONFIG.currency?.name || 'coins'; + const currencyName = ECONOMY_CONFIG.currency?.name || 'Münzen'; return `${amount.toLocaleString()} ${currencyName}`; } @@ -198,7 +198,7 @@ export function checkCooldown(userData: EconomyData, action: string): CooldownSt } function formatCooldown(ms: number): string { - if (ms < 1000) return 'now'; + if (ms < 1000) return 'jetzt'; const seconds = Math.floor(ms / 1000); const minutes = Math.floor(seconds / 60); @@ -214,16 +214,16 @@ function formatCooldown(ms: number): string { export function getWorkReward() { const amount = Math.floor(Math.random() * (WORK_MAX - WORK_MIN + 1)) + WORK_MIN; const jobs = [ - 'worked at a fast food restaurant', - 'worked as a programmer', - 'worked as a construction worker', - 'worked as a doctor', - 'worked as a streamer', - 'worked as a YouTuber', - 'worked as a teacher', - 'worked as a cashier', - 'worked as a delivery driver', - 'worked as a freelancer' + 'in einem Fast-Food-Restaurant gearbeitet', + 'als Programmierer gearbeitet', + 'auf dem Bau gearbeitet', + 'als Ärztin bzw. Arzt gearbeitet', + 'als Streamer gearbeitet', + 'als YouTuber gearbeitet', + 'als Lehrkraft gearbeitet', + 'an der Kasse gearbeitet', + 'als Lieferfahrer gearbeitet', + 'als Freelancer gearbeitet' ]; const job = jobs[Math.floor(Math.random() * jobs.length)]; @@ -231,7 +231,7 @@ export function getWorkReward() { return { amount, job, - message: `You ${job} and earned ${formatCurrency(amount)}!` + message: `Du hast ${job} und ${formatCurrency(amount)} verdient!` }; } @@ -240,32 +240,32 @@ export function getCrimeOutcome() { { success: true, amount: Math.floor(Math.random() * 200) + 50, - message: 'You successfully robbed a bank and got away with {amount}!' + message: 'Du hast erfolgreich eine Bank ausgeraubt und bist mit {amount} entkommen!' }, { success: true, amount: Math.floor(Math.random() * 100) + 20, - message: 'You pickpocketed someone and stole {amount}!' + message: 'Du hast jemandem die Taschen geleert und {amount} erbeutet!' }, { success: true, amount: Math.floor(Math.random() * 150) + 30, - message: 'You hacked into a bank account and transferred {amount} to yourself!' + message: 'Du hast ein Bankkonto gehackt und dir {amount} überwiesen!' }, { success: false, fine: Math.floor(Math.random() * 100) + 50, - message: 'You got caught and had to pay a fine of {fine}!' + message: 'Du wurdest erwischt und musstest {fine} Strafe zahlen!' }, { success: false, fine: Math.floor(Math.random() * 150) + 50, - message: 'The police caught you! You paid {fine} to get out of jail.' + message: 'Die Polizei hat dich geschnappt! Du hast {fine} gezahlt, um aus dem Gefängnis zu kommen.' }, { success: false, fine: 0, - message: 'Your attempt failed, but you managed to escape!' + message: 'Dein Versuch ist gescheitert, aber du konntest entkommen!' } ]; @@ -277,7 +277,7 @@ export function getRobOutcome(targetBalance: number) { return { success: false, amount: 0, - message: 'The target has no money to steal!' + message: 'Bei deinem Ziel gibt es kein Geld zu holen!' }; } @@ -292,7 +292,7 @@ Math.floor(Math.random() * (targetBalance * 0.3)) + 1, return { success: true, amount, - message: `You successfully robbed them and got away with {amount}!` + message: `Du hast dein Ziel erfolgreich ausgeraubt und bist mit {amount} entkommen!` }; } else { const fine = Math.floor(Math.random() * 200) + 100; @@ -301,7 +301,7 @@ Math.floor(Math.random() * (targetBalance * 0.3)) + 1, success: false, amount: 0, fine, - message: `You got caught! You had to pay a fine of {fine}.` + message: `Du wurdest erwischt! Du musstest {fine} Strafe zahlen.` }; } } @@ -316,7 +316,7 @@ export const addMoney = wrapServiceBoundary(async function addMoney(client: Nexo throw createError( 'Invalid amount', ErrorTypes.VALIDATION, - 'Amount must be a positive number.', + 'Der Betrag muss eine positive Zahl sein.', { guildId, userId, amount, operation: 'addMoney' } ); } @@ -325,7 +325,7 @@ export const addMoney = wrapServiceBoundary(async function addMoney(client: Nexo throw createError( 'Invalid money type', ErrorTypes.VALIDATION, - 'Type must be "wallet" or "bank".', + 'Der Typ muss "wallet" oder "bank" sein.', { guildId, userId, type, operation: 'addMoney' } ); } @@ -338,7 +338,7 @@ export const addMoney = wrapServiceBoundary(async function addMoney(client: Nexo throw createError( 'Bank capacity exceeded', ErrorTypes.VALIDATION, - `Bank capacity exceeded. Current: ${userData.bank || 0}, Max: ${maxBank}.`, + `Bankkapazität überschritten. Aktuell: ${userData.bank || 0}, Maximum: ${maxBank}.`, { guildId, userId, current: userData.bank || 0, max: maxBank, operation: 'addMoney' } ); } @@ -356,7 +356,7 @@ export const addMoney = wrapServiceBoundary(async function addMoney(client: Nexo }, { service: 'economy', operation: 'addMoney', - userMessage: 'Failed to add money. Please try again.', + userMessage: 'Geld konnte nicht hinzugefügt werden. Bitte versuch es erneut.', }); export const removeMoney = wrapServiceBoundary(async function removeMoney(client: NexoClient, guildId: string, userId: string, amount: number, type = 'wallet') { @@ -365,7 +365,7 @@ export const removeMoney = wrapServiceBoundary(async function removeMoney(client throw createError( 'Invalid amount', ErrorTypes.VALIDATION, - 'Amount must be a positive number.', + 'Der Betrag muss eine positive Zahl sein.', { guildId, userId, amount, operation: 'removeMoney' } ); } @@ -374,7 +374,7 @@ export const removeMoney = wrapServiceBoundary(async function removeMoney(client throw createError( 'Invalid money type', ErrorTypes.VALIDATION, - 'Type must be "wallet" or "bank".', + 'Der Typ muss "wallet" oder "bank" sein.', { guildId, userId, type, operation: 'removeMoney' } ); } @@ -386,7 +386,7 @@ export const removeMoney = wrapServiceBoundary(async function removeMoney(client throw createError( 'Insufficient bank funds', ErrorTypes.VALIDATION, - `Insufficient funds in bank. You have ${userData.bank || 0}, need ${validAmount}.`, + `Nicht genug Geld auf der Bank. Du hast ${userData.bank || 0}, brauchst aber ${validAmount}.`, { guildId, userId, current: userData.bank || 0, required: validAmount, operation: 'removeMoney' } ); } @@ -396,7 +396,7 @@ export const removeMoney = wrapServiceBoundary(async function removeMoney(client throw createError( 'Insufficient wallet funds', ErrorTypes.VALIDATION, - `Insufficient funds in wallet. You have ${userData.wallet || 0}, need ${validAmount}.`, + `Nicht genug Geld in der Geldbörse. Du hast ${userData.wallet || 0}, brauchst aber ${validAmount}.`, { guildId, userId, current: userData.wallet || 0, required: validAmount, operation: 'removeMoney' } ); } @@ -411,25 +411,25 @@ export const removeMoney = wrapServiceBoundary(async function removeMoney(client }, { service: 'economy', operation: 'removeMoney', - userMessage: 'Failed to remove money. Please try again.', + userMessage: 'Geld konnte nicht abgezogen werden. Bitte versuch es erneut.', }); export function getShopInventory(): ShopItem[] { return [ { id: 'fishing_rod', - name: 'Fishing Rod', + name: 'Angel', emoji: '🎣', price: 500, - description: 'Catch fish to sell for profit!', + description: 'Fang Fische und verkaufe sie mit Gewinn!', type: 'tool' }, { id: 'hunting_rifle', - name: 'Hunting Rifle', + name: 'Jagdgewehr', emoji: '🔫', price: 1000, - description: 'Hunt animals for meat and fur!', + description: 'Jage Tiere für Fleisch und Fell!', type: 'tool' }, { @@ -437,26 +437,26 @@ export function getShopInventory(): ShopItem[] { name: 'Laptop', emoji: '💻', price: 2000, - description: 'Work as a programmer for higher pay!', + description: 'Arbeite als Programmierer für mehr Lohn!', type: 'tool', workMultiplier: 1.5 }, { id: 'bank_loan', - name: 'Bank Loan', + name: 'Bankkredit', emoji: '🏦', price: 5000, - description: 'Increases your bank capacity by 50,000!', + description: 'Erhöht deine Bankkapazität um 50.000!', type: 'upgrade', effect: 'bank_capacity', value: 50000 }, { id: 'lottery_ticket', - name: 'Lottery Ticket', + name: 'Lotterielos', emoji: '🎫', price: 100, - description: 'A chance to win big!', + description: 'Deine Chance auf den großen Gewinn!', type: 'consumable', use: 'gamble' } diff --git a/src/utils/embeds.ts b/src/utils/embeds.ts index 128818ce0f..fdbddac1b7 100644 --- a/src/utils/embeds.ts +++ b/src/utils/embeds.ts @@ -98,7 +98,8 @@ function isImportantFooter(footerText: string): boolean { } const normalized = footerText.toLowerCase(); - return /\b(close|closes|closed|expire|expires|available in|page\s+\d+|dashboard closes|ticket id)\b/.test(normalized); + return /\b(close|closes|closed|expire|expires|available in|page\s+\d+|dashboard closes|ticket id)\b/.test(normalized) + || /schließ|geschlossen|läuft\b.*\bab\b|abgelaufen|ablauf|verfügbar in|seite\s+\d+|ticket-id/.test(normalized); } const originalSetDescription = EmbedBuilder.prototype.setDescription; @@ -254,23 +255,23 @@ export function createEmbed({ } const NOTIFICATION_DEFAULT_TITLES: Record = { - success: 'Success', - error: 'Error', + success: 'Erfolg', + error: 'Fehler', info: 'Information', - warning: 'Warning', - primary: 'Notice', + warning: 'Warnung', + primary: 'Hinweis', }; export const USER_ERROR_TITLES: Record = { - validation: 'Invalid Input', - permission: 'Permission Denied', - configuration: 'Configuration Error', - database: 'Database Error', - network: 'Network Error', - discord_api: 'Discord API Error', - user_input: 'Input Error', - rate_limit: 'Too Fast', - unknown: 'Something Went Wrong', + validation: 'Ungültige Eingabe', + permission: 'Keine Berechtigung', + configuration: 'Konfigurationsfehler', + database: 'Datenbankfehler', + network: 'Netzwerkfehler', + discord_api: 'Discord-API-Fehler', + user_input: 'Eingabefehler', + rate_limit: 'Zu schnell', + unknown: 'Etwas ist schiefgelaufen', }; const USER_ERROR_COLORS: Record = { @@ -330,7 +331,7 @@ export function errorEmbed(title?: string | null, detail: unknown = null, option } const description = body ? String(body).trim() : ''; - const titleOverride = title && title !== 'Error' ? title : undefined; + const titleOverride = title && title !== 'Error' && title !== 'Fehler' ? title : undefined; return buildUserErrorEmbed('unknown', description, { titleOverride }); } @@ -338,10 +339,10 @@ export function errorEmbed(title?: string | null, detail: unknown = null, option /** @param {string} titleOrBody - With one arg: body text. With two args: title and body. */ export function successEmbed(title?: string | null, body: string | null = ''): EmbedBuilder { if (arguments.length === 1) { - return buildNotificationEmbed('Success', title, 'success'); + return buildNotificationEmbed('Erfolg', title, 'success'); } - return buildNotificationEmbed(title || 'Success', body, 'success'); + return buildNotificationEmbed(title || 'Erfolg', body, 'success'); } /** @param {string} titleOrBody - With one arg: body text. With two args: title and body. */ @@ -356,10 +357,10 @@ export function infoEmbed(title?: string | null, body: string | null = ''): Embe /** @param {string} titleOrBody - With one arg: body text. With two args: title and body. */ export function warningEmbed(title?: string | null, body: string | null = ''): EmbedBuilder { if (arguments.length === 1) { - return buildNotificationEmbed('Warning', title, 'warning'); + return buildNotificationEmbed('Warnung', title, 'warning'); } - return buildNotificationEmbed(title || 'Warning', body, 'warning'); + return buildNotificationEmbed(title || 'Warnung', body, 'warning'); } export function formatUser(user: User): string { diff --git a/src/utils/errorHandler.ts b/src/utils/errorHandler.ts index dc62a87e2e..ab8fca75ca 100644 --- a/src/utils/errorHandler.ts +++ b/src/utils/errorHandler.ts @@ -128,6 +128,19 @@ const USER_FACING_ERROR_HINTS = [ /^missing\b/i, /^cannot\b/i, /^could not find\b/i, + // German user messages + /^bitte\b/i, + /^du\b/i, + /^dein/i, + /^gib\b/i, + /^wähle\b/i, + /^ungültig/i, + /^unbekannte[rs]?\b/i, + /^fehlende?\b/i, + /^kein/i, + /^diese[rs]?\b/i, + /^das\b/i, + /^nicht unterstützt/i, ]; function isUserFacingPlainError(error: unknown): error is Error { @@ -158,7 +171,15 @@ function isUserFacingPlainError(error: unknown): error is Error { || lowerMessage.includes('must be ') || lowerMessage.includes('at least ') || lowerMessage.includes('no longer than ') - || lowerMessage.includes('at most ')) { + || lowerMessage.includes('at most ') + || lowerMessage.includes('nicht gefunden') + || lowerMessage.includes('nicht konfiguriert') + || lowerMessage.includes('nicht eingerichtet') + || lowerMessage.includes('nicht verfügbar') + || lowerMessage.includes('berechtigung') + || lowerMessage.includes('muss ') + || lowerMessage.includes('mindestens ') + || lowerMessage.includes('höchstens ')) { return true; } @@ -168,15 +189,18 @@ function isUserFacingPlainError(error: unknown): error is Error { function inferErrorTypeFromUserMessage(message: string = ''): ErrorType { const lowerMessage = message.toLowerCase(); - if (lowerMessage.includes('permission') || lowerMessage.includes('not allowed')) { + if (lowerMessage.includes('permission') || lowerMessage.includes('not allowed') + || lowerMessage.includes('berechtigung') || lowerMessage.includes('nicht erlaubt')) { return ErrorTypes.PERMISSION; } - if (lowerMessage.includes('rate limit') || lowerMessage.includes('cooldown') || lowerMessage.includes('too quickly')) { + if (lowerMessage.includes('rate limit') || lowerMessage.includes('cooldown') || lowerMessage.includes('too quickly') + || lowerMessage.includes('zu schnell')) { return ErrorTypes.RATE_LIMIT; } - if (lowerMessage.includes('database') || lowerMessage.includes('postgres') || lowerMessage.includes('sql')) { + if (lowerMessage.includes('database') || lowerMessage.includes('postgres') || lowerMessage.includes('sql') + || lowerMessage.includes('datenbank')) { return ErrorTypes.DATABASE; } @@ -185,11 +209,18 @@ function inferErrorTypeFromUserMessage(message: string = ''): ErrorType { || lowerMessage.includes('not set up') || lowerMessage.includes('disabled') || lowerMessage.includes('unknown command') - || lowerMessage.includes('unknown category')) { + || lowerMessage.includes('unknown category') + || lowerMessage.includes('nicht gefunden') + || lowerMessage.includes('nicht konfiguriert') + || lowerMessage.includes('nicht eingerichtet') + || lowerMessage.includes('deaktiviert') + || lowerMessage.includes('unbekannter command') + || lowerMessage.includes('unbekannte kategorie')) { return ErrorTypes.CONFIGURATION; } - if (lowerMessage.includes('channel') || lowerMessage.includes('user') || lowerMessage.includes('role') || lowerMessage.includes('mention')) { + if (lowerMessage.includes('channel') || lowerMessage.includes('user') || lowerMessage.includes('role') || lowerMessage.includes('mention') + || lowerMessage.includes('kanal') || lowerMessage.includes('nutzer') || lowerMessage.includes('rolle') || lowerMessage.includes('mitglied')) { return ErrorTypes.USER_INPUT; } @@ -274,57 +305,57 @@ export function categorizeError(error: unknown): ErrorType { const UserMessages: Record> = { [ErrorTypes.VALIDATION]: { - default: 'Please check your input and try again.', - missing_required: "You're missing some required information. Check the command options and try again.", - invalid_format: 'The format you provided is incorrect. Check the command usage and try again.' + default: 'Bitte prüfe deine Eingabe und versuch es erneut.', + missing_required: 'Dir fehlen erforderliche Angaben. Prüfe die Command-Optionen und versuch es erneut.', + invalid_format: 'Das Format deiner Eingabe ist falsch. Prüfe die Verwendung des Commands und versuch es erneut.' }, [ErrorTypes.PERMISSION]: { - default: "You don't have permission to do that.", - user_permission: "You don't have permission to use this command.", - bot_permission: "I don't have the permissions needed to do that in this channel." + default: 'Du hast keine Berechtigung dafür.', + user_permission: 'Du hast keine Berechtigung, diesen Command zu nutzen.', + bot_permission: 'Mir fehlen die nötigen Berechtigungen in diesem Kanal.' }, [ErrorTypes.CONFIGURATION]: { - default: 'This feature is not set up yet. Ask a server administrator to configure it.', - missing_config: 'This feature has not been configured yet. Ask a server administrator to set it up.', - invalid_config: 'The server configuration for this feature is invalid. Ask a server administrator to review it.' + default: 'Diese Funktion ist noch nicht eingerichtet. Bitte einen Server-Admin, sie zu konfigurieren.', + missing_config: 'Diese Funktion wurde noch nicht konfiguriert. Bitte einen Server-Admin, sie einzurichten.', + invalid_config: 'Die Server-Konfiguration für diese Funktion ist ungültig. Bitte einen Server-Admin, sie zu prüfen.' }, [ErrorTypes.DATABASE]: { - default: 'Something went wrong while saving data. Please try again in a moment.', - connection_failed: 'I could not reach the database. Please try again later.', - timeout: 'That took too long to complete. Please try again.' + default: 'Beim Speichern ist etwas schiefgelaufen. Bitte versuch es gleich noch einmal.', + connection_failed: 'Ich konnte die Datenbank nicht erreichen. Bitte versuch es später erneut.', + timeout: 'Das hat zu lange gedauert. Bitte versuch es erneut.' }, [ErrorTypes.NETWORK]: { - default: 'I could not reach an external service. Please try again in a moment.', - timeout: 'The request timed out. Please try again.', - unreachable: 'The service is unavailable right now. Please try again later.' + default: 'Ich konnte einen externen Dienst nicht erreichen. Bitte versuch es gleich noch einmal.', + timeout: 'Die Anfrage hat zu lange gedauert. Bitte versuch es erneut.', + unreachable: 'Der Dienst ist gerade nicht erreichbar. Bitte versuch es später erneut.' }, [ErrorTypes.DISCORD_API]: { - default: 'Discord rejected that request. Please try again in a moment.', - rate_limit: "You're doing that too quickly. Wait a moment and try again.", - forbidden: "I'm not allowed to do that here. Check my role permissions.", - interaction_expired: 'That interaction expired. Please run the command again.', - already_acknowledged: 'That action was already handled. Run the command again if you still need help.', - invalid_form_body: 'Discord rejected the request payload. Try again with different input.' + default: 'Discord hat die Anfrage abgelehnt. Bitte versuch es gleich noch einmal.', + rate_limit: 'Du bist zu schnell. Warte kurz und versuch es erneut.', + forbidden: 'Das darf ich hier nicht. Prüfe die Berechtigungen meiner Rolle.', + interaction_expired: 'Diese Interaktion ist abgelaufen. Bitte führe den Command erneut aus.', + already_acknowledged: 'Diese Aktion wurde bereits verarbeitet. Führe den Command erneut aus, falls du noch Hilfe brauchst.', + invalid_form_body: 'Discord hat die Anfrage abgelehnt. Versuch es mit einer anderen Eingabe.' }, [ErrorTypes.USER_INPUT]: { - default: 'There was a problem with your request. Check your input and try again.', - invalid_user: 'I could not find that user. Check the mention or ID and try again.', - invalid_channel: 'I could not find that channel. Check the mention or ID and try again.' + default: 'Bei deiner Anfrage gab es ein Problem. Prüfe deine Eingabe und versuch es erneut.', + invalid_user: 'Ich konnte diesen Nutzer nicht finden. Prüfe die Erwähnung oder ID und versuch es erneut.', + invalid_channel: 'Ich konnte diesen Kanal nicht finden. Prüfe die Erwähnung oder ID und versuch es erneut.' }, [ErrorTypes.RATE_LIMIT]: { - default: "You're doing that too quickly. Wait a moment and try again.", - command_cooldown: 'This command is on cooldown. Wait before using it again.', - global_rate_limit: 'Discord is rate limiting requests. Wait a moment and try again.' + default: 'Du bist zu schnell. Warte kurz und versuch es erneut.', + command_cooldown: 'Dieser Command hat gerade Cooldown. Warte kurz, bevor du ihn erneut nutzt.', + global_rate_limit: 'Discord bremst gerade Anfragen aus. Warte kurz und versuch es erneut.' }, [ErrorTypes.UNKNOWN]: { - default: 'Something went wrong. Please try again in a moment.', - unexpected: 'An unexpected error occurred. Please try again later.', - warn_failed: 'I could not warn that member. Check my permissions and role hierarchy, then try again.', - kick_failed: 'I could not kick that member. Check my permissions and role hierarchy, then try again.', - ban_failed: 'I could not ban that member. Check my permissions and role hierarchy, then try again.', - unban_failed: 'I could not unban that user. Check my permissions and try again.', - timeout_failed: 'I could not timeout that member. Check my permissions and role hierarchy, then try again.', - untimeout_failed: 'I could not remove the timeout. Check my permissions and try again.' + default: 'Etwas ist schiefgelaufen. Bitte versuch es gleich noch einmal.', + unexpected: 'Ein unerwarteter Fehler ist aufgetreten. Bitte versuch es später erneut.', + warn_failed: 'Ich konnte dieses Mitglied nicht verwarnen. Prüfe meine Berechtigungen und die Rollenhierarchie.', + kick_failed: 'Ich konnte dieses Mitglied nicht kicken. Prüfe meine Berechtigungen und die Rollenhierarchie.', + ban_failed: 'Ich konnte dieses Mitglied nicht bannen. Prüfe meine Berechtigungen und die Rollenhierarchie.', + unban_failed: 'Ich konnte diesen Nutzer nicht entbannen. Prüfe meine Berechtigungen und versuch es erneut.', + timeout_failed: 'Ich konnte diesem Mitglied keinen Timeout geben. Prüfe meine Berechtigungen und die Rollenhierarchie.', + untimeout_failed: 'Ich konnte den Timeout nicht aufheben. Prüfe meine Berechtigungen und versuch es erneut.' } }; diff --git a/src/utils/giveaways.ts b/src/utils/giveaways.ts index e5d5686ad3..3aa2ca7957 100644 --- a/src/utils/giveaways.ts +++ b/src/utils/giveaways.ts @@ -77,7 +77,7 @@ export async function saveGiveaway(client: NexoClient, guildId: string, giveaway throw new NexoBotError( 'Invalid giveaway data: missing messageId', ErrorTypes.VALIDATION, - 'Cannot save giveaway without a message ID.', + 'Ein Giveaway kann ohne Nachrichten-ID nicht gespeichert werden.', { giveawayData } ); } @@ -112,7 +112,7 @@ export async function deleteGiveaway(client: NexoClient, guildId: string, messag throw new NexoBotError( 'Missing messageId parameter', ErrorTypes.VALIDATION, - 'Cannot delete giveaway without a message ID.', + 'Ein Giveaway kann ohne Nachrichten-ID nicht gelöscht werden.', { messageId } ); } @@ -188,22 +188,22 @@ export function giveawayButtons(ended = false) { row.addComponents( new ButtonBuilder() .setCustomId('giveaway_reroll') - .setLabel('🎲 Reroll') + .setLabel('🎲 Neu auslosen') .setStyle(ButtonStyle.Secondary), new ButtonBuilder() .setCustomId('giveaway_view') - .setLabel('👁️ View') + .setLabel('👁️ Gewinner anzeigen') .setStyle(ButtonStyle.Primary) ); } else { row.addComponents( new ButtonBuilder() .setCustomId('giveaway_join') - .setLabel('🎉 Join') + .setLabel('🎉 Teilnehmen') .setStyle(ButtonStyle.Primary), new ButtonBuilder() .setCustomId('giveaway_end') - .setLabel('🛑 End') + .setLabel('🛑 Beenden') .setStyle(ButtonStyle.Danger) ); } diff --git a/src/utils/logging/logEmbeds.ts b/src/utils/logging/logEmbeds.ts index b125f724aa..c58ca3394b 100644 --- a/src/utils/logging/logEmbeds.ts +++ b/src/utils/logging/logEmbeds.ts @@ -99,9 +99,9 @@ export function splitComparisonFields(fields: LogField[] = []): ComparisonFields for (const field of fields) { const label = stripFieldLabel(field.name).toLowerCase(); - if (/^old|^before/.test(label)) { + if (/^old|^before|^alt|^vorher/.test(label)) { comparison.before = field.value; - } else if (/^new|^after/.test(label)) { + } else if (/^new|^after|^neu|^nachher/.test(label)) { comparison.after = field.value; } else { comparison.rest.push(field); @@ -168,7 +168,7 @@ export async function resolveUserAuthor(client: Client, userId: string | null | }; } catch { return { - name: `User ${userId}`, + name: `Nutzer ${userId}`, }; } } @@ -230,17 +230,17 @@ export interface RoleAuditOptions { export function buildRoleAuditFields(role: Role, { includeMemberCount = false }: RoleAuditOptions = {}): APIEmbedField[] { const fields: APIEmbedField[] = [ { - name: 'Role Name', + name: 'Rollenname', value: role.name, inline: true }, { - name: 'Color', + name: 'Farbe', value: role.hexColor || '#000000', inline: true }, { - name: 'Role ID', + name: 'Rollen-ID', value: role.id, inline: true } @@ -250,9 +250,9 @@ export function buildRoleAuditFields(role: Role, { includeMemberCount = false }: if (permissions.length > 0) { const displayPerms = permissions.slice(0, MAX_DISPLAYED_ROLE_PERMISSIONS).join(','); fields.push({ - name: 'Permissions', + name: 'Berechtigungen', value: permissions.length > MAX_DISPLAYED_ROLE_PERMISSIONS - ? `${displayPerms}... (+${permissions.length - MAX_DISPLAYED_ROLE_PERMISSIONS} more)` + ? `${displayPerms}... (+${permissions.length - MAX_DISPLAYED_ROLE_PERMISSIONS} weitere)` : displayPerms, inline: false }); @@ -260,13 +260,13 @@ export function buildRoleAuditFields(role: Role, { includeMemberCount = false }: fields.push( { - name: 'Hoisted', - value: role.hoist ? 'Yes' : 'No', + name: 'Separat angezeigt', + value: role.hoist ? 'Ja' : 'Nein', inline: true }, { - name: 'Managed', - value: role.managed ? 'Yes (Bot role)' : 'No', + name: 'Verwaltet', + value: role.managed ? 'Ja (Bot-Rolle)' : 'Nein', inline: true }, { @@ -278,7 +278,7 @@ export function buildRoleAuditFields(role: Role, { includeMemberCount = false }: if (includeMemberCount) { fields.push({ - name: 'Members with Role', + name: 'Mitglieder mit Rolle', value: role.members.size.toString(), inline: true }); diff --git a/src/utils/logging/loggingUi.ts b/src/utils/logging/loggingUi.ts index f4e6faf4db..44b52a63c3 100644 --- a/src/utils/logging/loggingUi.ts +++ b/src/utils/logging/loggingUi.ts @@ -50,21 +50,21 @@ const DASHBOARD_CATEGORY_EMOJIS: Record = { export const DASHBOARD_CATEGORY_LABELS: Record = { moderation: 'Moderation', - message: 'Messages', - role: 'Roles', - member: 'Members', + message: 'Nachrichten', + role: 'Rollen', + member: 'Mitglieder', leveling: 'Leveling', - reactionrole: 'Reaction Roles', + reactionrole: 'Reaktionsrollen', giveaway: 'Giveaways', - counter: 'Counters', - application: 'Applications', - report: 'Reports', + counter: 'Zähler', + application: 'Bewerbungen', + report: 'Meldungen', }; function createBackButton(): ButtonBuilder { return new ButtonBuilder() .setCustomId('log_dash_back') - .setLabel('Back to Dashboard') + .setLabel('Zurück zum Dashboard') .setStyle(ButtonStyle.Secondary); } @@ -96,43 +96,43 @@ export function createLoggingMainMenuSelect(): ActionRowBuilder().addComponents( new StringSelectMenuBuilder() .setCustomId('log_dash_menu') - .setPlaceholder('Choose a setting to configure…') + .setPlaceholder('Wähle eine Einstellung…') .addOptions( new StringSelectMenuOptionBuilder() - .setLabel('Set Audit Log Channel') - .setDescription('Moderation, messages, members, roles, etc.') + .setLabel('Audit-Log-Kanal festlegen') + .setDescription('Moderation, Nachrichten, Mitglieder, Rollen usw.') .setValue('set:audit') .setEmoji('🧾'), new StringSelectMenuOptionBuilder() - .setLabel('Set Applications Channel') - .setDescription('New applications and review updates') + .setLabel('Bewerbungskanal festlegen') + .setDescription('Neue Bewerbungen und Status-Updates') .setValue('set:applications') .setEmoji('📝'), new StringSelectMenuOptionBuilder() - .setLabel('Set Reports Channel') - .setDescription('User reports filed via /report') + .setLabel('Meldungskanal festlegen') + .setDescription('Meldungen von Nutzern über /report') .setValue('set:reports') .setEmoji('🚨'), new StringSelectMenuOptionBuilder() - .setLabel('Clear Audit Channel') + .setLabel('Audit-Kanal entfernen') .setValue('clear:audit') .setEmoji('🗑️'), new StringSelectMenuOptionBuilder() - .setLabel('Clear Applications Channel') + .setLabel('Bewerbungskanal entfernen') .setValue('clear:applications') .setEmoji('🗑️'), new StringSelectMenuOptionBuilder() - .setLabel('Clear Reports Channel') + .setLabel('Meldungskanal entfernen') .setValue('clear:reports') .setEmoji('🗑️'), new StringSelectMenuOptionBuilder() - .setLabel('Event Categories') - .setDescription('Toggle which log types are sent') + .setLabel('Event-Kategorien') + .setDescription('Lege fest, welche Log-Typen gesendet werden') .setValue('view:categories') .setEmoji('📋'), new StringSelectMenuOptionBuilder() - .setLabel('Manage Ignore Filters') - .setDescription('Skip logs from specific users or channels') + .setLabel('Ignorier-Filter verwalten') + .setDescription('Logs von bestimmten Nutzern oder Kanälen überspringen') .setValue('view:filters') .setEmoji('🔇'), ), @@ -143,11 +143,11 @@ export function createLoggingMainActionRow(loggingEnabled: boolean = false): Act return new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('log_dash_toggle:audit_enabled') - .setLabel('Audit Logging') + .setLabel('Audit-Logging') .setStyle(loggingEnabled ? ButtonStyle.Success : ButtonStyle.Danger), new ButtonBuilder() .setCustomId('log_dash_refresh') - .setLabel('Refresh') + .setLabel('Aktualisieren') .setStyle(ButtonStyle.Primary), ); } @@ -166,11 +166,11 @@ export function createLoggingCategoryViewComponents(enabledEvents: EnabledEvents createBackButton(), new ButtonBuilder() .setCustomId('log_dash_toggle:all') - .setLabel('Toggle All Categories') + .setLabel('Alle Kategorien umschalten') .setStyle(ButtonStyle.Secondary), new ButtonBuilder() .setCustomId('log_dash_refresh') - .setLabel('Refresh') + .setLabel('Aktualisieren') .setStyle(ButtonStyle.Primary), ); @@ -183,15 +183,15 @@ export function createLoggingFilterComponents(): ActionRowBuilder createBackButton(), new ButtonBuilder() .setCustomId('log_dash_add_filter:user') - .setLabel('Add User Filter') + .setLabel('Nutzerfilter hinzufügen') .setStyle(ButtonStyle.Primary), new ButtonBuilder() .setCustomId('log_dash_add_filter:channel') - .setLabel('Add Channel Filter') + .setLabel('Kanalfilter hinzufügen') .setStyle(ButtonStyle.Primary), new ButtonBuilder() .setCustomId('log_dash_remove_filter') - .setLabel('Remove Filter') + .setLabel('Filter entfernen') .setStyle(ButtonStyle.Danger), ), ]; diff --git a/src/utils/messageAdapter.ts b/src/utils/messageAdapter.ts index 12d5e56d91..b690f6b0bf 100644 --- a/src/utils/messageAdapter.ts +++ b/src/utils/messageAdapter.ts @@ -161,9 +161,9 @@ export function createMockInteraction( return { id, - username: 'Unknown', + username: 'Unbekannt', bot: false, - tag: 'Unknown#0000', + tag: 'Unbekannt#0000', }; }, getMember: (name: string) => { diff --git a/src/utils/moderation.ts b/src/utils/moderation.ts index 94dc2c40f9..49e3b89cb0 100644 --- a/src/utils/moderation.ts +++ b/src/utils/moderation.ts @@ -71,6 +71,26 @@ const ACTION_TO_EVENT_TYPE: Record = { 'Case Updated': EVENT_TYPES.MODERATION_CONFIG, }; +/** German log titles for the (English) action keys above; the keys stay English because they are matched and stored. */ +const ACTION_LABELS: Record = { + 'Member Banned': 'Mitglied gebannt', + 'Member Kicked': 'Mitglied gekickt', + 'Member Timed Out': 'Mitglied im Timeout', + 'Member Untimeouted': 'Timeout aufgehoben', + 'Member Unbanned': 'Mitglied entbannt', + 'User Warned': 'Nutzer verwarnt', + 'Warnings Viewed': 'Verwarnungen angesehen', + 'Messages Purged': 'Nachrichten gelöscht', + 'Channel Locked': 'Kanal gesperrt', + 'Channel Unlocked': 'Kanal entsperrt', + 'DM Sent': 'DM gesendet', + 'Bot Message Sent': 'Bot-Nachricht gesendet', + 'Log Channel Activated': 'Log-Kanal aktiviert', + 'Log Filter Updated': 'Log-Filter aktualisiert', + 'Case Created': 'Fall erstellt', + 'Case Updated': 'Fall aktualisiert', +}; + function buildModerationLogData(event: ModerationEvent) { const targetIdMatch = event.target?.match(/\((\d+)\)/); const targetId = targetIdMatch?.[1]; @@ -79,19 +99,19 @@ function buildModerationLogData(event: ModerationEvent) { const lines: string[] = []; if (event.target) { - lines.push(formatLogLine('User', event.target)); + lines.push(formatLogLine('Nutzer', event.target)); } if (event.reason) { const reason = event.reason.length > 900 ? `${event.reason.substring(0, 897)}...` : event.reason; - lines.push(formatLogLine('Reason', reason)); + lines.push(formatLogLine('Grund', reason)); } if (event.duration) { - lines.push(formatLogLine('Duration', event.duration)); + lines.push(formatLogLine('Dauer', event.duration)); } if (event.caseId) { - lines.push(formatLogLine('Case', `\`${event.caseId}\``)); + lines.push(formatLogLine('Fall', `\`${event.caseId}\``)); } const meta: [string, string][] = []; @@ -103,7 +123,8 @@ function buildModerationLogData(event: ModerationEvent) { }); } - const title = event.caseId ? `${event.action} · Case #${event.caseId}` : event.action; + const actionLabel = ACTION_LABELS[event.action] ?? event.action; + const title = event.caseId ? `${actionLabel} · Fall #${event.caseId}` : actionLabel; return { title, diff --git a/src/utils/panelStatus.ts b/src/utils/panelStatus.ts index 9638f80013..1ad55580ee 100644 --- a/src/utils/panelStatus.ts +++ b/src/utils/panelStatus.ts @@ -82,29 +82,29 @@ export function messageHasPanelMarker(message: PanelMessage, { buttonCustomId, s export function formatPanelStatusField( panelStatus: PanelStatus | null | undefined, - { repostHint = 'Repost Panel' }: { repostHint?: string } = {}, + { repostHint = 'Panel neu posten' }: { repostHint?: string } = {}, ): string { - if (!panelStatus) return '`Unknown`'; + if (!panelStatus) return '`Unbekannt`'; if (panelStatus.exists) { return panelStatus.message?.url - ? `✅ Active — [view panel](${panelStatus.message.url})` - : '✅ Active'; + ? `✅ Aktiv — [Panel ansehen](${panelStatus.message.url})` + : '✅ Aktiv'; } if (panelStatus.reason === 'channel_missing') { - return '⚠️ Panel channel missing or deleted'; + return '⚠️ Panel-Kanal fehlt oder wurde gelöscht'; } if (panelStatus.reason === 'panel_deleted') { - return `⚠️ Panel message was deleted — use **${repostHint}** below`; + return `⚠️ Panel-Nachricht wurde gelöscht — nutze unten **${repostHint}**`; } if (panelStatus.reason === 'no_channel') { - return '⚠️ No panel channel configured'; + return '⚠️ Kein Panel-Kanal konfiguriert'; } - return '`Unknown`'; + return '`Unbekannt`'; } export async function getBotPanelStatus(client: NexoClient, guild: Guild, { diff --git a/src/utils/permissionGuard.ts b/src/utils/permissionGuard.ts index ce16a008d0..a84d828d97 100644 --- a/src/utils/permissionGuard.ts +++ b/src/utils/permissionGuard.ts @@ -154,7 +154,7 @@ export async function checkModerationPermissions( interaction: ChatInputCommandInteraction, guildConfig: ModeratorRoleConfig, requiredPermissions: PermissionResolvable | null, - errorMessage = 'You do not have permission to use this command.' + errorMessage = 'Du hast keine Berechtigung, diesen Command zu nutzen.' ): Promise { // Guild commands: member is a full GuildMember (as the JS assumed). if (memberHasModerationCommandAccess(interaction.member as GuildMember | null, guildConfig, requiredPermissions)) { @@ -256,7 +256,7 @@ export function botHasPermission(channel: GuildBasedChannel | null | undefined, export async function checkUserPermissions( interaction: ChatInputCommandInteraction, requiredPermissions: PermissionResolvable, - errorMessage = 'You do not have permission to use this command.' + errorMessage = 'Du hast keine Berechtigung, diesen Command zu nutzen.' ): Promise { // Guild commands: member is a full GuildMember (as the JS assumed). const member = interaction.member as GuildMember; @@ -288,7 +288,7 @@ export async function checkBotPermissions( if (!targetChannel || !targetChannel.guild) { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, - message: 'Could not determine channel.', + message: 'Der Kanal konnte nicht ermittelt werden.', context: { source: 'permissionGuard.checkBotPermissions' } }); return false; @@ -298,7 +298,7 @@ export async function checkBotPermissions( if (!botMember) { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, - message: 'Could not find bot member in this guild.', + message: 'Das Bot-Mitglied wurde auf diesem Server nicht gefunden.', context: { source: 'permissionGuard.checkBotPermissions' } }); return false; @@ -317,7 +317,7 @@ export async function checkBotPermissions( if (missingPerms.length > 0) { await replyUserError(interaction, { type: ErrorTypes.PERMISSION, - message: `I need the following permissions in ${targetChannel}: ${missingPerms.join(', ')}`, + message: `Ich brauche folgende Berechtigungen in ${targetChannel}: ${missingPerms.join(', ')}`, context: { source: 'permissionGuard.checkBotPermissions', subtype: 'bot_permission' } }); diff --git a/src/utils/prefixParser.ts b/src/utils/prefixParser.ts index 90979ce3b7..fada3c265d 100644 --- a/src/utils/prefixParser.ts +++ b/src/utils/prefixParser.ts @@ -233,14 +233,14 @@ export function mapArgumentsToOptions(args: string[], commandData: PrefixCommand if (matchedGroup) { missing.push({ name: 'subcommand', - description: `Available: ${matchedGroup.options?.map((s) => s.name).join(', ') || 'none'}`, + description: `Verfügbar: ${matchedGroup.options?.map((s) => s.name).join(', ') || 'keine'}`, type: 1, }); } else { - const available = [...subcommands, ...groups].map((s) => s.name).join(', ') || 'none'; + const available = [...subcommands, ...groups].map((s) => s.name).join(', ') || 'keine'; missing.push({ name: hasSubcommands ? 'subcommand' : 'subcommand group', - description: `Available: ${available}`, + description: `Verfügbar: ${available}`, type: 1, }); } diff --git a/src/utils/responseCoordinator.ts b/src/utils/responseCoordinator.ts index 40eb3ef2e5..d5e92da810 100644 --- a/src/utils/responseCoordinator.ts +++ b/src/utils/responseCoordinator.ts @@ -234,8 +234,8 @@ export class ResponseCoordinator { async respondUsage(usageLine: string): Promise { const embed = buildUserErrorEmbed( 'validation', - `Usage\n\`${usageLine}\``, - { titleOverride: 'Wrong Usage' } + `Verwendung\n\`${usageLine}\``, + { titleOverride: 'Falsche Verwendung' } ); const result = await this.respond({ embeds: [embed] }); diff --git a/src/utils/schemas.ts b/src/utils/schemas.ts index 755da5b794..b3e508f82b 100644 --- a/src/utils/schemas.ts +++ b/src/utils/schemas.ts @@ -250,7 +250,7 @@ export function validateGuildConfigOrThrow(rawConfig: unknown, context: Record = { - open: { color: 0x5865F2, title: 'Ticket Created' }, - close: { color: 0xED4245, title: 'Ticket Closed' }, - delete: { color: 0x8b0000, title: 'Ticket Deleted' }, - claim: { color: 0x5865F2, title: 'Ticket Claimed' }, - unclaim: { color: 0xFAA61A, title: 'Ticket Unclaimed' }, - priority: { color: 0x9b59b6, title: 'Priority Updated' }, - transcript: { color: 0x57F287, title: 'Transcript Generated' }, - feedback: { color: 0x57F287, title: 'Feedback Received' }, + open: { color: 0x5865F2, title: 'Ticket erstellt' }, + close: { color: 0xED4245, title: 'Ticket geschlossen' }, + delete: { color: 0x8b0000, title: 'Ticket gelöscht' }, + claim: { color: 0x5865F2, title: 'Ticket übernommen' }, + unclaim: { color: 0xFAA61A, title: 'Ticket freigegeben' }, + priority: { color: 0x9b59b6, title: 'Priorität aktualisiert' }, + transcript: { color: 0x57F287, title: 'Transkript erstellt' }, + feedback: { color: 0x57F287, title: 'Feedback erhalten' }, }; async function createTicketLogEmbed(guild: Guild, event: TicketEvent) { - const style = TICKET_EVENT_STYLES[event.type] || { color: 0x95a5a6, title: 'Ticket Event' }; + const style = TICKET_EVENT_STYLES[event.type] || { color: 0x95a5a6, title: 'Ticket-Ereignis' }; const ticketNumber = event.ticketNumber || event.ticketId; - const ticketRef = ticketNumber ? `#${ticketNumber}` : 'Unknown'; + const ticketRef = ticketNumber ? `#${ticketNumber}` : 'Unbekannt'; const channelMention = event.ticketId ? `<#${event.ticketId}>` : null; const executorMention = event.executorId ? `<@${event.executorId}>` : null; const userMention = event.userId ? `<@${event.userId}>` : null; @@ -184,13 +184,13 @@ async function createTicketLogEmbed(guild: Guild, event: TicketEvent) { author = await resolveUserAuthor(guild.client, event.userId); inlineFields = [ { name: 'Ticket', value: ticketRef, inline: true }, - { name: 'Creator', value: userMention || 'Unknown', inline: true }, + { name: 'Ersteller', value: userMention || 'Unbekannt', inline: true }, ]; if (channelMention) { - inlineFields.push({ name: 'Channel', value: channelMention, inline: true }); + inlineFields.push({ name: 'Kanal', value: channelMention, inline: true }); } if (event.reason) { - fields.push({ name: 'Reason', value: String(event.reason).slice(0, 1024), inline: false }); + fields.push({ name: 'Grund', value: String(event.reason).slice(0, 1024), inline: false }); } break; @@ -198,13 +198,13 @@ async function createTicketLogEmbed(guild: Guild, event: TicketEvent) { author = await resolveUserAuthor(guild.client, event.executorId); inlineFields = [ { name: 'Ticket', value: ticketRef, inline: true }, - { name: 'Closed by', value: executorMention || 'Unknown', inline: true }, + { name: 'Geschlossen von', value: executorMention || 'Unbekannt', inline: true }, ]; if (channelMention) { - inlineFields.push({ name: 'Channel', value: channelMention, inline: true }); + inlineFields.push({ name: 'Kanal', value: channelMention, inline: true }); } if (event.reason) { - fields.push({ name: 'Reason', value: String(event.reason).slice(0, 1024), inline: false }); + fields.push({ name: 'Grund', value: String(event.reason).slice(0, 1024), inline: false }); } break; @@ -212,7 +212,7 @@ async function createTicketLogEmbed(guild: Guild, event: TicketEvent) { author = await resolveUserAuthor(guild.client, event.executorId); inlineFields = [ { name: 'Ticket', value: ticketRef, inline: true }, - { name: 'Deleted by', value: executorMention || 'Unknown', inline: true }, + { name: 'Gelöscht von', value: executorMention || 'Unbekannt', inline: true }, ]; break; @@ -222,8 +222,8 @@ async function createTicketLogEmbed(guild: Guild, event: TicketEvent) { inlineFields = [ { name: 'Ticket', value: ticketRef, inline: true }, { - name: event.type === 'claim' ? 'Claimed by' : 'Unclaimed by', - value: executorMention || 'Unknown', + name: event.type === 'claim' ? 'Übernommen von' : 'Freigegeben von', + value: executorMention || 'Unbekannt', inline: true, }, ]; @@ -231,14 +231,15 @@ async function createTicketLogEmbed(guild: Guild, event: TicketEvent) { case 'priority': { const priorityEmojis: Record = { none: '⚪', low: '🔵', medium: '🟢', high: '🟡', urgent: '🔴' }; + const priorityNames: Record = { none: 'Keine', low: 'Niedrig', medium: 'Mittel', high: 'Hoch', urgent: 'Dringend' }; const priorityLabel = event.priority - ? `${priorityEmojis[event.priority] || '⚪'} ${event.priority.charAt(0).toUpperCase()}${event.priority.slice(1)}` - : 'Unknown'; + ? `${priorityEmojis[event.priority] || '⚪'} ${priorityNames[event.priority] || event.priority}` + : 'Unbekannt'; author = await resolveUserAuthor(guild.client, event.executorId); inlineFields = [ { name: 'Ticket', value: ticketRef, inline: true }, - { name: 'Priority', value: priorityLabel, inline: true }, - { name: 'Updated by', value: executorMention || 'Unknown', inline: true }, + { name: 'Priorität', value: priorityLabel, inline: true }, + { name: 'Aktualisiert von', value: executorMention || 'Unbekannt', inline: true }, ]; break; } @@ -246,17 +247,17 @@ async function createTicketLogEmbed(guild: Guild, event: TicketEvent) { case 'transcript': inlineFields = [ { name: 'Ticket', value: ticketRef, inline: true }, - { name: 'Creator', value: userMention || 'Unknown', inline: true }, + { name: 'Ersteller', value: userMention || 'Unbekannt', inline: true }, ]; if (event.metadata?.messageCount) { - inlineFields.push({ name: 'Messages', value: String(event.metadata.messageCount), inline: true }); + inlineFields.push({ name: 'Nachrichten', value: String(event.metadata.messageCount), inline: true }); } if (event.metadata?.duration) { - fields.push({ name: 'Duration', value: String(event.metadata.duration), inline: false }); + fields.push({ name: 'Dauer', value: String(event.metadata.duration), inline: false }); } if (event.metadata?.subject || event.reason) { fields.push({ - name: 'Subject', + name: 'Betreff', value: String(event.metadata?.subject || event.reason).slice(0, 1024), inline: false, }); @@ -266,17 +267,17 @@ async function createTicketLogEmbed(guild: Guild, event: TicketEvent) { case 'feedback': { const rating = event.metadata?.rating ?? event.rating; const comment = event.metadata?.comment; - const ratingDisplay = formatRatingStars(rating) || 'No rating'; + const ratingDisplay = formatRatingStars(rating) || 'Keine Bewertung'; author = await resolveUserAuthor(guild.client, event.userId); inlineFields = [ { name: 'Ticket', value: ticketRef, inline: true }, - { name: 'Rating', value: ratingDisplay, inline: true }, + { name: 'Bewertung', value: ratingDisplay, inline: true }, ]; if (comment) { fields.push({ - name: 'Comment', + name: 'Kommentar', value: String(comment).slice(0, 1024), inline: false, }); @@ -320,7 +321,7 @@ export function validateLogChannel( if (!channel || channel.type !== ChannelType.GuildText) { return { valid: false, - error: 'Channel must be a text channel.', + error: 'Der Kanal muss ein Textkanal sein.', }; } @@ -332,7 +333,7 @@ export function validateLogChannel( if (missing.length > 0) { return { valid: false, - error: `Missing permissions: ${missing.join(', ')}`, + error: `Fehlende Berechtigungen: ${missing.join(', ')}`, }; } diff --git a/src/utils/welcome.ts b/src/utils/welcome.ts index 232ab6dbdc..27320335cf 100644 --- a/src/utils/welcome.ts +++ b/src/utils/welcome.ts @@ -3,8 +3,8 @@ import { logger } from './logger.js'; const DEFAULT_TEMPLATES = { - welcome: 'Welcome {user} to {server}!', - goodbye: '{user.tag} has left the server.' + welcome: 'Willkommen {user} auf {server}!', + goodbye: '{user.tag} hat den Server verlassen.' }; /** Fields read from a (possibly partial) discord.js User. */ @@ -61,11 +61,11 @@ export function formatWelcomeMessage(message: unknown, data: WelcomeFormatData | } const tokens: Record = { - '{user}': user?.toString?.() || 'User', - '{user.mention}': user?.toString?.() || 'User', - '{user.tag}': user?.tag || 'Unknown#0000', - '{user.username}': user?.username || 'Unknown', - '{username}': user?.username || 'Unknown', + '{user}': user?.toString?.() || 'Nutzer', + '{user.mention}': user?.toString?.() || 'Nutzer', + '{user.tag}': user?.tag || 'Unbekannt#0000', + '{user.username}': user?.username || 'Unbekannt', + '{username}': user?.username || 'Unbekannt', '{user.discriminator}': user?.discriminator || '0000', '{user.id}': user?.id || 'unknown', '{server}': guild?.name || 'Server', From 51110f1e18dd041c20d3caf6090270daeafcbde5 Mon Sep 17 00:00:00 2001 From: robertwalter Date: Sat, 17 Oct 2026 17:26:08 +0200 Subject: [PATCH 09/20] build(docker): expose local Postgres on 127.0.0.1 for host-side development Co-Authored-By: Claude Opus 5.5 --- docker-compose.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 6f0e30f5dc..f6dcfbb7cd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -60,6 +60,9 @@ services: - POSTGRES_DB=${POSTGRES_DB:-nexobot} volumes: - postgres_data:/var/lib/postgresql/data + ports: + # Local access for `npm run dev` / `npm start` on the host (bound to localhost only). + - "127.0.0.1:${DB_HOST_PORT:-5432}:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] interval: 10s From f684b1eadbba8eeb1b8fe7142de25c8b241be32f Mon Sep 17 00:00:00 2001 From: robertwalter Date: Sat, 17 Oct 2026 17:33:38 +0200 Subject: [PATCH 10/20] docs: document POSTGRES_SSL=false for the local Docker database Co-Authored-By: Claude Opus 5.5 --- .env.example | 1 + README.md | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.env.example b/.env.example index ab7fdd4f1f..c76b370a14 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,7 @@ CORS_ORIGIN=* # Public proxy logs showing "invalid length of startup packet" or "SSL without ALPN" # are usually internet scanners — not your bot. POSTGRES_URL=postgresql://postgres:yourpassword@localhost:5432/nexobot +# Local Docker Postgres has no SSL: set POSTGRES_SSL=false (with NODE_ENV=production SSL is on by default). POSTGRES_SSL= POSTGRES_HOST=localhost POSTGRES_PORT=5432 diff --git a/README.md b/README.md index 3913fe2c08..990ee8979b 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,8 @@ Eine lokale Datenbank lässt sich am einfachsten per Docker starten: docker compose up -d db ``` +Die Compose-Datenbank läuft dann auf `127.0.0.1:5432` (User/Passwort/DB aus `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`). Sie hat kein SSL: Setze `POSTGRES_SSL=false`, sonst schlägt die Verbindung mit `NODE_ENV=production` fehl und der Bot läuft im In-Memory-Modus. + ### Docker ```bash From 5994b72b3b0abf53c88b49278863b2d579209524 Mon Sep 17 00:00:00 2001 From: robertwalter Date: Sat, 17 Oct 2026 17:42:57 +0200 Subject: [PATCH 11/20] fix(economy): stop guild admins from rewriting the global bot config The currency dashboard wrote modal input verbatim into the bot config source. Any ManageGuild member could break the file (a single ") or inject code that runs on the next start, and changed the currency for all guilds. Changing the currency now requires a bot owner, rejects quotes/backslashes/line breaks and writes JSON-quoted literals. Co-Authored-By: Claude Opus 5.5 --- .../Economy/modules/economy_dashboard.ts | 42 +++++++++++++++---- tests/economyDashboard.test.ts | 12 ++++++ 2 files changed, 46 insertions(+), 8 deletions(-) create mode 100644 tests/economyDashboard.test.ts diff --git a/src/commands/Economy/modules/economy_dashboard.ts b/src/commands/Economy/modules/economy_dashboard.ts index 97c70dddd4..2fec384131 100644 --- a/src/commands/Economy/modules/economy_dashboard.ts +++ b/src/commands/Economy/modules/economy_dashboard.ts @@ -17,7 +17,7 @@ import { type StringSelectMenuInteraction, type SelectMenuModalData, } from 'discord.js'; -import { getColor, BotConfig } from '../../../config/bot.js'; +import { getColor, BotConfig, isBotOwner } from '../../../config/bot.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; import { successEmbed } from '../../../utils/embeds.js'; import { logger } from '../../../utils/logger.js'; @@ -116,7 +116,21 @@ async function refreshDashboard(rootInteraction: ChatInputCommandInteraction, gu }).catch(() => {}); } +/** + * Quoted literal for a value written into the bot config source, or null when the value could break + * the file or the regexes below (quotes, backslashes, line breaks). + */ +export function toConfigLiteral(value: string): string | null { + return /["\\\r\n]/.test(value) ? null : JSON.stringify(value); +} + async function updateConfigFile(currencySymbol: string, currencyName: string) { + const symbolLiteral = toConfigLiteral(currencySymbol); + const nameLiteral = toConfigLiteral(currencyName); + if (!symbolLiteral || !nameLiteral) { + return false; + } + try { // tsx dev runs from src/ (config/bot.ts); the compiled build runs from dist/ (config/bot.js). const tsConfigPath = path.join(__dirname, '../../../config/bot.ts'); @@ -125,17 +139,17 @@ async function updateConfigFile(currencySymbol: string, currencyName: string) { configContent = configContent.replace( /symbol:\s*"[^"]*"/, - `symbol: "${currencySymbol}"` + `symbol: ${symbolLiteral}` ); configContent = configContent.replace( /(\/\/\s*Currency display name\.?\s*\n\s*)name:\s*"[^"]*"/, - (_match: string, prefix: string) => `${prefix}name: "${currencyName}"` + (_match: string, prefix: string) => `${prefix}name: ${nameLiteral}` ); configContent = configContent.replace( /(\/\/\s*Plural display name\.?\s*\n\s*)namePlural:\s*"[^"]*"/, - (_match: string, prefix: string) => `${prefix}namePlural: "${currencyName}s"` + (_match: string, prefix: string) => `${prefix}namePlural: ${nameLiteral}` ); await fs.writeFile(configPath, configContent, 'utf-8'); @@ -427,6 +441,12 @@ async function handleRemoveCurrency(selectInteraction: StringSelectMenuInteracti } async function handleChangeCurrency(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, guild: Guild) { + // The currency lives in the global bot config file, so only bot owners may change it (not every guild's admins). + if (!isBotOwner(selectInteraction.user.id)) { + await replyUserError(selectInteraction, { type: ErrorTypes.PERMISSION, message: 'Die Währung gilt für alle Server. Nur Bot-Besitzer können sie ändern.' }); + return; + } + const modal = new ModalBuilder() .setCustomId(`economy_change_currency_${guild.id}`) .setTitle('Währungssymbol ändern'); @@ -456,8 +476,8 @@ async function handleChangeCurrency(selectInteraction: StringSelectMenuInteracti const newSymbol = submitted.fields.getTextInputValue('currency_symbol').trim(); - if (newSymbol.length === 0 || newSymbol.length > 3) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Das Währungssymbol muss 1–3 Zeichen lang sein.' }); + if (newSymbol.length === 0 || newSymbol.length > 3 || !toConfigLiteral(newSymbol)) { + await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Das Währungssymbol muss 1–3 Zeichen lang sein und darf keine Anführungszeichen oder Backslashes enthalten.' }); return; } @@ -481,6 +501,12 @@ async function handleChangeCurrency(selectInteraction: StringSelectMenuInteracti } async function handleChangeName(selectInteraction: StringSelectMenuInteraction, rootInteraction: ChatInputCommandInteraction, guild: Guild) { + // The currency lives in the global bot config file, so only bot owners may change it (not every guild's admins). + if (!isBotOwner(selectInteraction.user.id)) { + await replyUserError(selectInteraction, { type: ErrorTypes.PERMISSION, message: 'Die Währung gilt für alle Server. Nur Bot-Besitzer können sie ändern.' }); + return; + } + const modal = new ModalBuilder() .setCustomId(`economy_change_name_${guild.id}`) .setTitle('Währungsname ändern'); @@ -510,8 +536,8 @@ async function handleChangeName(selectInteraction: StringSelectMenuInteraction, const newName = submitted.fields.getTextInputValue('currency_name').trim(); - if (newName.length === 0 || newName.length > 20) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Der Währungsname muss 1–20 Zeichen lang sein.' }); + if (newName.length === 0 || newName.length > 20 || !toConfigLiteral(newName)) { + await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Der Währungsname muss 1–20 Zeichen lang sein und darf keine Anführungszeichen oder Backslashes enthalten.' }); return; } diff --git a/tests/economyDashboard.test.ts b/tests/economyDashboard.test.ts new file mode 100644 index 0000000000..424d4938b5 --- /dev/null +++ b/tests/economyDashboard.test.ts @@ -0,0 +1,12 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { toConfigLiteral } from '../src/commands/Economy/modules/economy_dashboard.js'; + +test('currency values that could break the config source are rejected', () => { + assert.equal(toConfigLiteral('Münzen'), '"Münzen"'); + assert.equal(toConfigLiteral('€'), '"€"'); + assert.equal(toConfigLiteral('"'), null); + assert.equal(toConfigLiteral('"+process.exit()+"'), null); + assert.equal(toConfigLiteral('a\\b'), null); + assert.equal(toConfigLiteral('a\nb'), null); +}); From 33a8555cd597034d005dc02629992b58b810c545 Mon Sep 17 00:00:00 2001 From: robertwalter Date: Sat, 17 Oct 2026 17:43:37 +0200 Subject: [PATCH 12/20] fix(commands): make legacy group-mapped disables manageable Old keys like usernotes/autoverify/shop-config translate to group paths (msg notes, ...) that were not in the command registry: /commands could neither disable them by old name nor re-enable them, so such commands stayed disabled forever. The registry now lists subcommand groups and /commands disable|enable accept current and pre-consolidation names. Co-Authored-By: Claude Opus 5.5 --- src/services/commandAccessService.ts | 37 ++++++++++++++++++++++------ tests/commandConsolidation.test.ts | 20 ++++++++++++++- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/src/services/commandAccessService.ts b/src/services/commandAccessService.ts index b974c7ccb2..edebd55f7f 100644 --- a/src/services/commandAccessService.ts +++ b/src/services/commandAccessService.ts @@ -120,6 +120,15 @@ export function buildCommandRegistry(client: NexoClient): Map { const fun = mapArgumentsToOptions(['roll'], { name: 'fun', options: [{ name: 'roll', type: 1 }] }); assert.equal(fun.getSubcommand(), 'roll'); }); + +test('/commands resolves old names, including ones mapped to a group', () => { + const notes = fakeCommand(new SlashCommandBuilder().setName('usernotes').setDescription('Notes') + .addSubcommand((s) => s.setName('add').setDescription('Add')), []); + const balance = fakeCommand(new SlashCommandBuilder().setName('balance').setDescription('Balance'), []); + const msg = { ...createRouterCommand({ name: 'msg', description: 'Messages', category: 'Moderation', routes: [{ legacyName: 'usernotes', module: notes }] }), category: 'Moderation' }; + const eco = { ...createRouterCommand({ name: 'eco', description: 'Economy', category: 'Economy', routes: [{ legacyName: 'balance', module: balance }] }), category: 'Economy' }; + const client = { commands: new Collection([['msg', msg], ['eco', eco]]) } as unknown as NexoClient; // only .commands is read + + const names = [...buildCommandRegistry(client).values()].flatMap((category) => category.commands.map((command) => command.name)); + assert.ok(names.includes('msg notes'), 'group node is toggleable'); + assert.equal(resolveAccessTarget(client, 'usernotes')?.name, 'msg notes'); + assert.equal(resolveAccessTarget(client, 'balance')?.name, 'eco balance'); + assert.equal(resolveAccessTarget(client, 'msg notes add')?.name, 'msg notes add'); + assert.equal(resolveAccessTarget(client, 'nope'), null); +}); From 17f1492fe2dcdf28e909e820a0fe92a1322c21fd Mon Sep 17 00:00:00 2001 From: robertwalter Date: Sat, 17 Oct 2026 17:44:20 +0200 Subject: [PATCH 13/20] fix(commands): old ban/kick/rank/welcome disables keep their old scope A stored bare key for a name that exists before and after the consolidation now means the mapped subcommand (ban -> ban user, welcome -> welcome message) instead of the whole new command, so servers do not silently lose /ban remove, /welcome autorole and similar. Disabling a whole new command with such a name is stored as " *". Co-Authored-By: Claude Opus 5.5 --- docs/COMMAND_MIGRATION.md | 2 +- src/config/commands/legacyCommandMap.ts | 23 +++++++++++++++-------- src/services/commandAccessService.ts | 11 ++++++----- tests/commandConsolidation.test.ts | 16 ++++++++++++++-- 4 files changed, 36 insertions(+), 16 deletions(-) diff --git a/docs/COMMAND_MIGRATION.md b/docs/COMMAND_MIGRATION.md index 9f1565ccd1..75197ad3ea 100644 --- a/docs/COMMAND_MIGRATION.md +++ b/docs/COMMAND_MIGRATION.md @@ -9,7 +9,7 @@ NexoBot fasst die früheren Einzel-Commands thematisch zusammen. Aus 99 Top-Leve - **Cooldowns:** Cooldowns und Missbrauchsschutz zählen weiter pro altem Command. - **`/commands disable balance`** akzeptiert alte Namen und sperrt den neuen Subcommand. -Hinweis: Bei Namen, die es alt und neu gibt (`/ban`, `/kick`, `/rank`, `/welcome`), sperrt ein gespeicherter Eintrag ohne Subcommand den ganzen neuen Command. Beispiel: Ein gesperrtes `/ban` sperrt auch `/ban remove` und `/ban mass`. +Bei Namen, die es alt und neu gibt (`/ban`, `/kick`, `/rank`, `/welcome`), behält ein alter Eintrag seine alte Bedeutung: Ein gesperrtes `/ban` sperrt nur `/ban user`, ein gesperrtes `/welcome` nur `/welcome message`. Wer heute den ganzen neuen Command sperrt, sperrt alle Subcommands. ## Unverändert diff --git a/src/config/commands/legacyCommandMap.ts b/src/config/commands/legacyCommandMap.ts index e2dea5c582..dc76071948 100644 --- a/src/config/commands/legacyCommandMap.ts +++ b/src/config/commands/legacyCommandMap.ts @@ -158,27 +158,34 @@ function isNewFirstLevelName(command: string, firstArg: string | undefined): boo return Boolean(firstArg && NEW_FIRST_LEVEL_NAMES.get(command)?.has(firstArg.toLowerCase())); } +/** Suffix marking a stored "whole command" disable for names that existed before and after consolidation. */ +export const WHOLE_COMMAND_MARKER = '*'; + /** * Translate a stored access key (old or new naming) to the new naming. * `balance` -> `eco balance`, `usernotes add` -> `msg notes add`, `welcome setup` -> `welcome message setup`. - * For old names that are also new top-level names, a bare key keeps the new meaning (whole command). + * A bare old name keeps its old meaning (`ban` -> `ban user`); disabling a whole new command with such a + * name is stored as `ban *`. */ export function translateLegacyAccessKey(key: string): string { const tokens = key.toLowerCase().trim().split(/\s+/); - const target = LEGACY_COMMAND_MAP[tokens[0]]; - if (!target) { - return tokens.join(' '); + if (tokens[1] === WHOLE_COMMAND_MARKER) { + return tokens[0]; } - if (target.command === tokens[0]) { - if (tokens.length === 1 || isNewFirstLevelName(target.command, tokens[1])) { - return tokens.join(' '); - } + const target = LEGACY_COMMAND_MAP[tokens[0]]; + if (!target || (target.command === tokens[0] && isNewFirstLevelName(target.command, tokens[1]))) { + return tokens.join(' '); } return [...getLegacyPath(target), ...tokens.slice(1)].join(' '); } +/** Key to store when disabling `name` (new naming), so translateLegacyAccessKey reads it back unchanged. */ +export function toStoredAccessKey(name: string): string { + return LEGACY_COMMAND_MAP[name]?.command === name ? `${name} ${WHOLE_COMMAND_MARKER}` : name; +} + /** * Rewrite a prefix invocation that uses an old command name. * Returns null when the name is not a legacy name or the args already address the new structure. diff --git a/src/services/commandAccessService.ts b/src/services/commandAccessService.ts index edebd55f7f..0537cd8df0 100644 --- a/src/services/commandAccessService.ts +++ b/src/services/commandAccessService.ts @@ -7,7 +7,7 @@ import { getCategoryIcon, PROTECTED_COMMANDS, } from '../config/commands/commandCategories.js'; -import { resolveLegacyName, LEGACY_COMMAND_MAP, translateLegacyAccessKey } from '../config/commands/legacyCommandMap.js'; +import { resolveLegacyName, LEGACY_COMMAND_MAP, toStoredAccessKey, translateLegacyAccessKey } from '../config/commands/legacyCommandMap.js'; import type { RESTPostAPIChatInputApplicationCommandsJSONBody } from 'discord.js'; import type { GuildConfigContext } from '../utils/database/guildConfigStorage.js'; import type { CommandData, NexoClient } from '../types/index.js'; @@ -69,7 +69,7 @@ function translateDisabledCommands(record: Record): Record, normalizedName: string): void { for (const key of Object.keys(record)) { - if (key === normalizedName || translateLegacyAccessKey(key) === normalizedName) { + if (translateLegacyAccessKey(key) === normalizedName) { delete record[key]; } } @@ -169,8 +169,9 @@ export function isProtectedCommand(commandName: string | null | undefined): bool } export function isCommandEnabledInConfig(config: CommandAccessConfig | null | undefined, commandName: string, category: string | null | undefined): boolean { - const normalizedName = translateLegacyAccessKey(String(commandName || '')); - const tokens = normalizedName.split(' '); + // Queried names are always current names (access keys, registry entries); only stored keys are translated. + const normalizedName = String(commandName || '').toLowerCase().trim(); + const tokens = normalizedName.split(/\s+/); const baseCommand = tokens[0]; const isProtected = isProtectedCommand(baseCommand); @@ -315,7 +316,7 @@ export async function disableCommand(client: NexoClient, guildId: string, comman const config = await getGuildConfig(client, guildId, context); const disabledCommands = normalizeToggleRecord(config?.disabledCommands); - disabledCommands[normalizedName] = true; + disabledCommands[toStoredAccessKey(normalizedName)] = true; await persistAccessConfig(client, guildId, { disabledCommands }, context); return { commandName: normalizedName, enabled: false }; diff --git a/tests/commandConsolidation.test.ts b/tests/commandConsolidation.test.ts index e24e0c4f4d..100e1456e9 100644 --- a/tests/commandConsolidation.test.ts +++ b/tests/commandConsolidation.test.ts @@ -5,6 +5,7 @@ import type { ChatInputCommandInteraction } from 'discord.js'; import { resolveLegacyName, routeLegacyPrefixCommand, + toStoredAccessKey, translateLegacyAccessKey, } from '../src/config/commands/legacyCommandMap.js'; import { Collection } from 'discord.js'; @@ -19,8 +20,13 @@ test('stored legacy access keys translate to the new paths', () => { assert.equal(translateLegacyAccessKey('usernotes add'), 'msg notes add'); assert.equal(translateLegacyAccessKey('shop-config setrole'), 'economy shop setrole'); assert.equal(translateLegacyAccessKey('welcome setup'), 'welcome message setup'); - // Old names that are also new top-level names keep the new meaning. - assert.equal(translateLegacyAccessKey('ban'), 'ban'); + // A bare old name keeps its old meaning; whole new commands are stored as ` *`. + assert.equal(translateLegacyAccessKey('ban'), 'ban user'); + assert.equal(translateLegacyAccessKey('welcome'), 'welcome message'); + assert.equal(translateLegacyAccessKey('ban *'), 'ban'); + assert.equal(toStoredAccessKey('ban'), 'ban *'); + assert.equal(toStoredAccessKey('ban remove'), 'ban remove'); + assert.equal(toStoredAccessKey('eco'), 'eco'); assert.equal(translateLegacyAccessKey('welcome autorole add'), 'welcome autorole add'); assert.equal(translateLegacyAccessKey('music pause'), 'music pause'); }); @@ -34,6 +40,12 @@ test('disabled legacy commands and categories block the new subcommands', () => assert.equal(isCommandEnabledInConfig(config, 'tools weather', 'Tools'), false); assert.equal(isCommandEnabledInConfig(config, 'tools hexcolor', 'Tools'), true); assert.equal(isCommandEnabledInConfig({ disabledCommands: ['commands'] }, 'commands disable', 'Core'), true); + + // An old `/ban` disable only blocks `/ban user`; a new whole-command disable blocks everything. + assert.equal(isCommandEnabledInConfig({ disabledCommands: { ban: true } }, 'ban user', 'Moderation'), false); + assert.equal(isCommandEnabledInConfig({ disabledCommands: { ban: true } }, 'ban remove', 'Moderation'), true); + assert.equal(isCommandEnabledInConfig({ disabledCommands: { ban: true } }, 'ban', 'Moderation'), true); + assert.equal(isCommandEnabledInConfig({ disabledCommands: { 'ban *': true } }, 'ban remove', 'Moderation'), false); }); test('prefix invocations with old names are routed', () => { From 0d134b28fe1cff47b8d8d2a371a8410a25552ea7 Mon Sep 17 00:00:00 2001 From: robertwalter Date: Sat, 17 Oct 2026 17:44:36 +0200 Subject: [PATCH 14/20] fix(jointocreate): keep the stored channel name template selectable Templates saved before the German template list (e.g. "{username}'s Room") had no matching option, so the dashboard showed nothing preselected and saving forced a different template. Co-Authored-By: Claude Opus 5.5 --- src/commands/JoinToCreate/jointocreate.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/commands/JoinToCreate/jointocreate.ts b/src/commands/JoinToCreate/jointocreate.ts index f3565db706..edcc872e9e 100644 --- a/src/commands/JoinToCreate/jointocreate.ts +++ b/src/commands/JoinToCreate/jointocreate.ts @@ -391,11 +391,16 @@ async function handleNameTemplateModal(interaction: ButtonInteraction, triggerCh || currentConfig.channelNameTemplate || "Raum von {username}"; + // Keep a stored template that is not in the list (e.g. older English ones) selectable and preselected. + const templateOptions = TEMPLATE_OPTIONS.some(o => o.value === currentTemplate) + ? TEMPLATE_OPTIONS + : [{ label: currentTemplate.slice(0, 100), value: currentTemplate.slice(0, 100) }, ...TEMPLATE_OPTIONS]; + const templateSelect = new StringSelectMenuBuilder() .setCustomId('template') .setPlaceholder('Wähle eine Namensvorlage...') .setOptions( - TEMPLATE_OPTIONS.map(o => ({ + templateOptions.map(o => ({ label: o.label, value: o.value, default: o.value === currentTemplate, From 784e26eb2e585ad4e9ad17b870a6eec151353ccf Mon Sep 17 00:00:00 2001 From: robertwalter Date: Sat, 17 Oct 2026 17:44:58 +0200 Subject: [PATCH 15/20] refactor(prefix): derive slash-only router subcommands from the routed module Replaces the hand-maintained block list (eco shop, music play/queue, msg embed) with the old command's own slashOnly flag via resolveRoute. Co-Authored-By: Claude Opus 5.5 --- src/config/commands/prefixRestrictions.ts | 17 ++++++++++++----- tests/commandConsolidation.test.ts | 13 +++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/config/commands/prefixRestrictions.ts b/src/config/commands/prefixRestrictions.ts index 045f4c35cd..e83f26e207 100644 --- a/src/config/commands/prefixRestrictions.ts +++ b/src/config/commands/prefixRestrictions.ts @@ -15,7 +15,7 @@ export interface PrefixRestriction { reason?: string; } -type PrefixCheckedCommand = Pick; +type PrefixCheckedCommand = Pick; function isSubcommandGroupOption(opt: APIApplicationCommandOption): opt is APIApplicationCommandSubcommandGroupOption { return opt.type === 2; @@ -54,11 +54,7 @@ export const COMMAND_BLOCKED_SUBCOMMANDS: Record | undefined 'move', 'clear', '247', - 'play', - 'queue', ]), - eco: new Set(['shop']), - msg: new Set(['embed']), birthday: new Set(['setchannel']), report: new Set(['setchannel']), }; @@ -135,6 +131,17 @@ export function getPrefixRestriction( }; } + // Routed subcommands keep the slash-only flag of the old command they come from (e.g. /eco shop). + const route = matchedGroup + ? command.resolveRoute?.(matchedGroup.name, null) + : firstArg && (command.resolveRoute?.(null, firstArg) ?? command.resolveRoute?.(null, resolvedFirstArg)); + if (route && route.module.slashOnly) { + return { + blocked: true, + reason: 'Dieser Subcommand ist nur als Slash-Command verfügbar.', + }; + } + if (firstArg && (isSubcommandBlocked(commandName, firstArg) || isSubcommandBlocked(commandName, resolvedFirstArg))) { return { blocked: true, diff --git a/tests/commandConsolidation.test.ts b/tests/commandConsolidation.test.ts index 100e1456e9..9b23ecc978 100644 --- a/tests/commandConsolidation.test.ts +++ b/tests/commandConsolidation.test.ts @@ -12,6 +12,7 @@ import { Collection } from 'discord.js'; import { buildCommandRegistry, isCommandEnabledInConfig, resolveAccessTarget } from '../src/services/commandAccessService.js'; import type { NexoClient } from '../src/types/index.js'; import { mapArgumentsToOptions } from '../src/utils/prefixParser.js'; +import { getPrefixRestriction } from '../src/config/commands/prefixRestrictions.js'; import { createRouterCommand } from '../src/utils/commandRouter.js'; import type { Command } from '../src/types/index.js'; @@ -139,3 +140,15 @@ test('/commands resolves old names, including ones mapped to a group', () => { assert.equal(resolveAccessTarget(client, 'msg notes add')?.name, 'msg notes add'); assert.equal(resolveAccessTarget(client, 'nope'), null); }); + +test('prefix blocks routed subcommands whose old command was slash-only', () => { + const shop = { ...fakeCommand(new SlashCommandBuilder().setName('shop').setDescription('Shop'), []), slashOnly: true }; + const work = fakeCommand(new SlashCommandBuilder().setName('work').setDescription('Work'), []); + const eco = createRouterCommand({ + name: 'eco', description: 'Economy', category: 'Economy', + routes: [{ legacyName: 'shop', module: shop }, { legacyName: 'work', module: work }], + }); + const keep = (name: string) => name; + assert.equal(getPrefixRestriction(eco, ['shop'], keep).blocked, true); + assert.equal(getPrefixRestriction(eco, ['work'], keep).blocked, false); +}); From 66996d8eb0ddad57a4afcc38341c097c9b705d60 Mon Sep 17 00:00:00 2001 From: robertwalter Date: Sat, 17 Oct 2026 17:45:39 +0200 Subject: [PATCH 16/20] refactor: drop unused legacy-map helpers and reloadCommand Removes formatLegacyTarget, getLegacyNamesFor and the one-caller getLegacyTarget, the duplicate normalized category label map, and the never-called reloadCommand loader export. Co-Authored-By: Claude Opus 5.5 --- src/config/commands/commandCategories.ts | 6 +----- src/config/commands/legacyCommandMap.ts | 16 +------------- src/handlers/loaders/commandLoader.ts | 27 +----------------------- 3 files changed, 3 insertions(+), 46 deletions(-) diff --git a/src/config/commands/commandCategories.ts b/src/config/commands/commandCategories.ts index 4522eb7f89..990046f26d 100644 --- a/src/config/commands/commandCategories.ts +++ b/src/config/commands/commandCategories.ts @@ -47,10 +47,6 @@ export const CATEGORY_LABELS: Record = { Welcome: 'Willkommen', }; -const CATEGORY_LABELS_BY_KEY = new Map( - Object.entries(CATEGORY_LABELS).map(([folder, label]) => [normalizeCategoryKey(folder), label]), -); - /** Commands that always stay available so admins can recover access. */ export const PROTECTED_COMMANDS = new Set(['commands', 'configwizard']); @@ -71,7 +67,7 @@ function formatFolderName(rawCategory: string | null | undefined): string { /** German display label for a category folder; falls back to the title-cased folder name. */ export function formatCategoryName(rawCategory: string | null | undefined): string { - return CATEGORY_LABELS_BY_KEY.get(normalizeCategoryKey(rawCategory)) ?? formatFolderName(rawCategory); + return CATEGORY_LABELS[String(rawCategory || '')] ?? formatFolderName(rawCategory); } export function getCategoryIcon(category: string): string { diff --git a/src/config/commands/legacyCommandMap.ts b/src/config/commands/legacyCommandMap.ts index dc76071948..ee3dfbf4be 100644 --- a/src/config/commands/legacyCommandMap.ts +++ b/src/config/commands/legacyCommandMap.ts @@ -135,20 +135,12 @@ for (const target of Object.values(LEGACY_COMMAND_MAP)) { NEW_FIRST_LEVEL_NAMES.set(target.command, names); } -export function getLegacyTarget(name: string): LegacyCommandTarget | null { - return LEGACY_COMMAND_MAP[name.toLowerCase()] ?? null; -} - /** New path tokens for a legacy target, e.g. ['eco', 'balance'] or ['msg', 'notes']. */ export function getLegacyPath(target: LegacyCommandTarget): string[] { return [target.command, firstLevelName(target)]; } /** `/eco balance` style display string for a legacy command name. */ -export function formatLegacyTarget(name: string): string | null { - const target = getLegacyTarget(name); - return target ? `/${getLegacyPath(target).join(' ')}` : null; -} /** * Whether `firstArg` is already a valid first-level name of the new command. @@ -194,7 +186,7 @@ export function routeLegacyPrefixCommand( commandName: string, args: string[], ): { commandName: string; args: string[] } | null { - const target = getLegacyTarget(commandName); + const target = LEGACY_COMMAND_MAP[commandName.toLowerCase()]; if (!target) { return null; } @@ -225,9 +217,3 @@ export function resolveLegacyName(command: string, group: string | null, subcomm return null; } -/** All legacy names that now live under `command` (for help hints and docs). */ -export function getLegacyNamesFor(command: string): string[] { - return Object.entries(LEGACY_COMMAND_MAP) - .filter(([, target]) => target.command === command) - .map(([legacyName]) => legacyName); -} diff --git a/src/handlers/loaders/commandLoader.ts b/src/handlers/loaders/commandLoader.ts index 9d78e93688..f147115501 100644 --- a/src/handlers/loaders/commandLoader.ts +++ b/src/handlers/loaders/commandLoader.ts @@ -1,7 +1,7 @@ import fs from 'fs/promises'; import { isLoadableModuleFile } from '../../utils/helpers.js'; import path from 'path'; -import { fileURLToPath, pathToFileURL } from 'url'; +import { fileURLToPath } from 'url'; import { ApplicationCommandOptionType, Collection } from 'discord.js'; import type { RESTPostAPIChatInputApplicationCommandsJSONBody } from 'discord.js'; import { logger } from '../../utils/logger.js'; @@ -308,28 +308,3 @@ export async function registerCommands(client: NexoClient, options: { clientId?: throw error; } } - -export async function reloadCommand(client: NexoClient, commandName: string): Promise<{ success: boolean; message: string }> { - const command = client.commands.get(commandName); - - if (!command) { - return { success: false, message: `Command "${commandName}" not found` }; - } - - try { - const commandPath = path.resolve(command.filePath!); - const moduleUrl = pathToFileURL(commandPath); - moduleUrl.searchParams.set('t', Date.now().toString()); - - // The reloaded file is the same command module that was validated on initial load. - const { default: newCommand }: { default: Command } = await import(moduleUrl.href); - - client.commands.set(commandName, newCommand); - - logger.info(`Reloaded command: ${commandName}`); - return { success: true, message: `Successfully reloaded command "${commandName}"` }; - } catch (error) { - logger.error(`Error reloading command "${commandName}":`, error); - return { success: false, message: `Error reloading command: ${error instanceof Error ? error.message : String(error)}` }; - } -} \ No newline at end of file From f3a9b7de61e8db98024022350fec9013f58962d2 Mon Sep 17 00:00:00 2001 From: robertwalter Date: Sat, 17 Oct 2026 22:13:18 +0200 Subject: [PATCH 17/20] docs: drop CONTRIBUTING and SECURITY, credit TitanBot in the README This is a personal bot, not a project taking contributions or security reports. The README now states up front that NexoBot is based on and reworks TitanBot by codebymitch (MIT). Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 108 ------------------------------------------------ README.md | 3 +- SECURITY.md | 76 ---------------------------------- roadmap.txt | 1 - 4 files changed, 2 insertions(+), 186 deletions(-) delete mode 100644 CONTRIBUTING.md delete mode 100644 SECURITY.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 7443ace2d4..0000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,108 +0,0 @@ -# Contributing to NexoBot - -Thank you for your interest in contributing to NexoBot! This guide covers local setup, project conventions, and what we look for in pull requests. - -## Ways to Contribute - -- Bug fixes and reliability improvements -- New commands or enhancements to existing features -- Documentation updates - -Before starting large features, open an issue on [GitHub](https://github.com/Robertwlt03/NexoBot/issues) so we can align on scope and avoid duplicate work. - -## Getting Started - -### Prerequisites - -- **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) - -### Local Setup - -1. Fork and clone the repository. -2. Install dependencies: - ```bash - npm install - ``` -3. Copy the environment file and configure it: - ```bash - cp .env.example .env - ``` - At minimum, set `DISCORD_TOKEN`, `CLIENT_ID`, and `GUILD_ID` for single-server development. See [README.md](README.md#umgebungsvariablen) for full configuration options. -4. Build and verify your database setup (when using PostgreSQL): - ```bash - npm run build - npm run migrate:check - ``` -5. Start the bot in watch mode (runs the TypeScript sources via tsx): - ```bash - npm run dev - ``` - Production-style start: `npm run build && npm start`. - -Before opening a pull request, run `npm run typecheck`, `npm test` and `npm run build`. - -For Docker-based setup, see [README.md](README.md#docker). - -## Development Workflow - -1. **Fork the repository** and create a branch from `main`. -2. **Make focused changes** — one logical change per pull request when possible. -3. **Open a pull request** with a clear description of what changed and why. - -Use descriptive branch names, for example: - -- `fix/ticket-panel-refresh` -- `feat/economy-shop-filter` -- `docs/contributing-guide` - -## Database & Migrations - -NexoBot 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. - -Useful commands: - -| Command | Purpose | -| --- | --- | -| `npm run migrate` | Apply migrations | -| `npm run migrate:check` | Verify schema version matches expectations | -| `npm run migrate:status` | Show current migration status | - -If your change modifies the database schema, update the expected schema version in `.env.example` (`SCHEMA_VERSION`, `SCHEMA_VERSION_LABEL`) and ensure `npm run migrate:check` passes locally. - -Test features that read or write guild data with **both** PostgreSQL and the memory fallback when feasible. - -## Code Guidelines - -- **Match existing style** — strict TypeScript (no `any`), ES modules with `.js` import specifiers, async/await, and the conventions used in neighboring files. -- **Commands** — top-level commands live in `src/commands//`, subcommand implementations in `modules/`. Consolidated commands are built with `createRouterCommand`; moved command names go into `src/config/commands/legacyCommandMap.ts`. -- **Language** — user-facing texts are German (informal "du"); code, comments and logs are English. -- **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 (`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`. - -There is no ESLint config in this repo today; consistency with surrounding code is the main bar. - -## Pull Request Checklist - -In your PR description, include: - -- **What** changed -- **Why** the change is needed -- **How to test** it manually (commands, config, or env vars to set) - -## Reporting Issues - -When reporting a bug, include: - -- Steps to reproduce -- Expected vs. actual behavior -- Relevant logs (`LOG_LEVEL=debug` can help locally) -- Whether you use PostgreSQL or memory fallback -- Bot version / commit hash if known - -## License - -By contributing, you agree that your contributions will be licensed under the same [MIT License](LICENSE) that covers this project. diff --git a/README.md b/README.md index 990ee8979b..59aa602b9d 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ **NexoBot** (Anzeigename „Nexo“) ist ein modularer Discord-Community-Bot mit Moderation, Economy, Leveling, Tickets, Musik, Giveaways, Verifizierung und vielen Werkzeugen. Geschrieben in TypeScript mit discord.js v14, PostgreSQL als Datenspeicher. +> **Herkunft:** NexoBot basiert auf [TitanBot](https://github.com/codebymitch/TitanBot) von codebymitch (MIT-Lizenz) und wurde für diesen Bot überarbeitet: Rebranding, Umstellung auf TypeScript, zusammengefasste Commands und deutsche Texte. + [![Discord.js](https://img.shields.io/npm/v/discord.js?style=flat-square&labelColor=%23202225&color=%23202225&logo=npm&logoColor=white&logoWidth=20)](https://www.npmjs.com/package/discord.js) ![TypeScript](https://img.shields.io/badge/-TypeScript-%233178C6?logo=typescript&logoColor=white&style=flat-square) ![PostgreSQL](https://img.shields.io/badge/-PostgreSQL-%23336791?logo=postgresql&logoColor=white&style=flat-square&logoWidth=20) @@ -16,7 +18,6 @@ - [Railway](#railway) - [Umgebungsvariablen](#umgebungsvariablen) - [Intents und Berechtigungen](#intents-und-berechtigungen) -- [Mitwirken](CONTRIBUTING.md) - [Lizenz und Credits](#lizenz-und-credits) ## Funktionen diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 2312162b6a..0000000000 --- a/SECURITY.md +++ /dev/null @@ -1,76 +0,0 @@ -# Security Policy — NexoBot - -We take security seriously. If you discover a vulnerability, please follow the policy below so we can triage and address it safely. - -## Summary / Self-hosting clarity -NexoBot is distributed as open-source software for self-hosting. We (the maintainers) do not operate self-hosted instances and do not have access to user data or configurations on third-party deployments. Self-hosters are responsible for securing and operating their instances. This policy describes how to report vulnerabilities affecting the project code and guidance for self-hosters who discover security issues in their deployment. - -## Reporting a Vulnerability (preferred) -- Preferred channel: Open a private GitHub Security Advisory for this repository (recommended). -- If you cannot use GitHub Advisories, open an issue without exploit details at https://github.com/Robertwlt03/NexoBot/issues and ask for a private contact. -- Do NOT open a public issue with exploit details. - -If you found a problem in your self-hosted instance that appears to be due to misconfiguration, please contact the instance operator/host first. If you believe the issue is caused by a vulnerability in NexoBot code, follow the reporting steps above and indicate whether the report comes from a self-hosted deployment. - -## Response timelines (what to expect) -- Acknowledgement: within 72 hours. -- Triage & severity estimate: within 7 days. -- Fix/migration plan: - - Critical: aim to ship fix or mitigation within 7–14 days. - - High: aim to ship within 30 days. - - Medium/Low: addressed in a future release; communicated within 90 days. -- Public disclosure: we will coordinate with the reporter and normally publish an advisory after a patch is released, or within 90 days if unresolved (unless the reporter requests otherwise). - -## Safe testing rules (researcher rules of engagement) -- Only test services you own or have explicit permission to test. -- Do not exfiltrate, destroy, or modify user data. -- Do not attempt to escalate to or access Discord user tokens, DMs, or other private user content. -- Provide minimal, safe PoC that reproduces the issue. Redact sensitive data (tokens, PII). -- If the vulnerability requires intrusive testing, contact us first to agree on a plan. - -## What to include in your report -- Component affected (e.g., database migration, command parser, OAuth). -- Clear, minimal steps to reproduce. -- Environment: commit SHA, release tag, Docker image tag, or version. -- Whether the issue was observed on a self-hosted deployment (and the deployment configuration). -- PoC (script, HTTP request, logs, screenshots) — sanitize secrets before sharing. -- Impact (data exposure, privilege escalation, RCE, etc.). -- Suggested mitigation (if any). -- Contact info for follow-up. - -## Incident reporting from self-hosted deployments -If you operate a self-hosted NexoBot instance and suffer a security incident: -- Immediately rotate any exposed secrets (bot token, DB credentials, API keys). -- Take a snapshot of logs/configuration for investigation (avoid sharing secrets). -- If you need upstream help, file a private security advisory and include sanitized reproduction steps and the NexoBot version/commit. -- The maintainers can only fix vulnerabilities in upstream code; we cannot rotate tokens, restore data, or remediate other hosts' deployments. - -## Scope -- In-scope: this repository's code, authentication flows, webhooks provided by this project, and the intended deployment artifacts (Dockerfiles, scripts). -- Out-of-scope: third-party services (Discord itself, hosted DB providers), and instances you do not own without owner consent. - -## Maintenance & Hardening advice for self-hosters (recommended defaults) -- Use TLS for all exposed endpoints and webhooks (Let’s Encrypt is fine). -- Do not expose Postgres to the public internet. Use an internal network, VPC, or SSH tunnels. -- Run Postgres with authentication, strong passwords, and network restrictions. -- Keep secrets out of the repository (use env vars, secret managers, or GitHub Secrets for CI). -- Restrict the bot's Discord intents and permissions to the minimum required. -- Enable automatic dependency updates (Dependabot) and security alerts. -- Enable secret-scanning for your repo(s) and enable push protection if available. -- Rotate bot tokens and API keys when suspected of compromise. -- Regular backups for Postgres and test restores regularly. -- Monitor logs and alert on suspicious activity (unexpected config changes, mass deletions). - -## Disclosure & Credits -- We will credit researchers in release notes/advisories unless you request anonymity. -- We may assign a CVE or coordinate with CERT/other bodies for high-severity issues. - -## Privacy & Telemetry -- NexoBot does not phone home or collect usage data by default. (If you plan to add telemetry, it must be opt-in, documented, and transparent.) -- Maintainers do not receive data from self-hosted instances. If you choose to enable any telemetry, document what is collected and how to opt out. - -## Contact -- Preferred: GitHub Security Advisory for this repository -- Issues (no exploit details): https://github.com/Robertwlt03/NexoBot/issues - -Thank you for helping keep NexoBot safe. diff --git a/roadmap.txt b/roadmap.txt index 9b3b346617..d09516f002 100644 --- a/roadmap.txt +++ b/roadmap.txt @@ -9,7 +9,6 @@ Root - tsconfig.json -> TypeScript config (strict, NodeNext, src -> dist) - Dockerfile -> multi-stage build (tsc) + slim runtime (node dist/app.js) - README.md -> setup and usage docs (German) -- SECURITY.md -> security policy - docs/COMMAND_MIGRATION.md -> old -> new command mapping (99 -> 36) - tests/ -> node:test checks (npm test, run via tsx) - lavalink/ -> Lavalink node list and local Lavalink config From 20992c51ce8c4e5273bdeacb6093f1cb43309b82 Mon Sep 17 00:00:00 2001 From: robertwalter Date: Sat, 17 Oct 2026 22:13:18 +0200 Subject: [PATCH 18/20] docs: add Nexo header banners and a license footer Header: transparent banner in the profile picture style (white Nexo robot with orbit ring next to a slanted plate), with a context badge on the command migration doc. Footer: a slim divider with the small robot and the license and TitanBot credit as text, replacing the README's license section; clickable links sit below it. Co-Authored-By: Claude Opus 5.5 --- README.md | 26 +++++++------ assets/banner-commands.svg | 77 ++++++++++++++++++++++++++++++++++++++ assets/banner-footer.svg | 65 ++++++++++++++++++++++++++++++++ assets/banner.svg | 73 ++++++++++++++++++++++++++++++++++++ docs/COMMAND_MIGRATION.md | 8 +++- 5 files changed, 237 insertions(+), 12 deletions(-) create mode 100644 assets/banner-commands.svg create mode 100644 assets/banner-footer.svg create mode 100644 assets/banner.svg diff --git a/README.md b/README.md index 59aa602b9d..c4222fb2a4 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,18 @@ -# Nexo – Ein Bot. Alle Funktionen. +

+ Nexo – Ein Bot. Alle Funktionen. +

+ +

+ discord.js + TypeScript + PostgreSQL + Lizenz: MIT +

**NexoBot** (Anzeigename „Nexo“) ist ein modularer Discord-Community-Bot mit Moderation, Economy, Leveling, Tickets, Musik, Giveaways, Verifizierung und vielen Werkzeugen. Geschrieben in TypeScript mit discord.js v14, PostgreSQL als Datenspeicher. > **Herkunft:** NexoBot basiert auf [TitanBot](https://github.com/codebymitch/TitanBot) von codebymitch (MIT-Lizenz) und wurde für diesen Bot überarbeitet: Rebranding, Umstellung auf TypeScript, zusammengefasste Commands und deutsche Texte. -[![Discord.js](https://img.shields.io/npm/v/discord.js?style=flat-square&labelColor=%23202225&color=%23202225&logo=npm&logoColor=white&logoWidth=20)](https://www.npmjs.com/package/discord.js) -![TypeScript](https://img.shields.io/badge/-TypeScript-%233178C6?logo=typescript&logoColor=white&style=flat-square) -![PostgreSQL](https://img.shields.io/badge/-PostgreSQL-%23336791?logo=postgresql&logoColor=white&style=flat-square&logoWidth=20) - ## Inhalt - [Funktionen](#funktionen) @@ -18,7 +23,6 @@ - [Railway](#railway) - [Umgebungsvariablen](#umgebungsvariablen) - [Intents und Berechtigungen](#intents-und-berechtigungen) -- [Lizenz und Credits](#lizenz-und-credits) ## Funktionen @@ -136,8 +140,8 @@ Einladung mit den Scopes `bot` und `applications.commands` und diesen Berechtigu - Mitglieder kicken, Mitglieder bannen, Mitglieder moderieren (Timeout) - Verbinden und Sprechen (Musik) -## Lizenz und Credits - -NexoBot steht unter der MIT-Lizenz, siehe [LICENSE](LICENSE). - -Based on TitanBot by codebymitch, licensed under MIT. Basiert auf [TitanBot](https://github.com/codebymitch/TitanBot) von codebymitch. +

+ NexoBot – © 2026 Robertwlt03, MIT-Lizenz. Basiert auf TitanBot von codebymitch (MIT). +
+ Lizenz · TitanBot · Command-Migration +

diff --git a/assets/banner-commands.svg b/assets/banner-commands.svg new file mode 100644 index 0000000000..4446eb6b50 --- /dev/null +++ b/assets/banner-commands.svg @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + COMMANDS + 99 → 36 · ALTE NAMEN BLEIBEN + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/banner-footer.svg b/assets/banner-footer.svg new file mode 100644 index 0000000000..cea77b99d1 --- /dev/null +++ b/assets/banner-footer.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NEXO · EIN BOT. ALLE FUNKTIONEN. + © 2026 Robertwlt03 · Veröffentlicht unter der MIT-Lizenz + Basiert auf TitanBot von codebymitch (MIT) und für NexoBot überarbeitet + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/banner.svg b/assets/banner.svg new file mode 100644 index 0000000000..55f0f08029 --- /dev/null +++ b/assets/banner.svg @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NEXO + EIN BOT. ALLE FUNKTIONEN. + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/COMMAND_MIGRATION.md b/docs/COMMAND_MIGRATION.md index 75197ad3ea..4aa6dc6045 100644 --- a/docs/COMMAND_MIGRATION.md +++ b/docs/COMMAND_MIGRATION.md @@ -1,4 +1,6 @@ -# Command-Migration: 99 → 36 Commands +

+ Command-Migration: 99 → 36 Commands +

NexoBot fasst die früheren Einzel-Commands thematisch zusammen. Aus 99 Top-Level-Commands werden 36. Command-Namen innerhalb der Gruppen, Optionen und Buttons bleiben gleich. @@ -100,3 +102,7 @@ Bestehende Subcommands bleiben außerdem direkt erhalten bei `/economy` (dashboa | `/goodbye …` | `/welcome goodbye …` | Welcome | | `/greet …` | `/welcome greet …` | Welcome | | `/welcome …` | `/welcome message …` | Welcome | + +

+ NexoBot – © 2026 Robertwlt03, MIT-Lizenz. Basiert auf TitanBot von codebymitch (MIT). +

From 2e6833d37518288b9c854a3f716eedd6f18ee574 Mon Sep 17 00:00:00 2001 From: robertwalter Date: Sat, 17 Oct 2026 22:24:41 +0200 Subject: [PATCH 19/20] docs: add section icons to the README and command migration doc A small icon set in the banner style (galaxy tile, purple-cyan edge, white line icon) in front of each heading; heading text and anchors stay unchanged. Co-Authored-By: Claude Opus 5.5 --- README.md | 20 ++++++++++---------- assets/icons/commands.svg | 14 ++++++++++++++ assets/icons/contents.svg | 14 ++++++++++++++ assets/icons/database.svg | 14 ++++++++++++++ assets/icons/docker.svg | 14 ++++++++++++++ assets/icons/env.svg | 14 ++++++++++++++ assets/icons/features.svg | 14 ++++++++++++++ assets/icons/local.svg | 14 ++++++++++++++ assets/icons/mapping.svg | 14 ++++++++++++++ assets/icons/permissions.svg | 14 ++++++++++++++ assets/icons/railway.svg | 14 ++++++++++++++ assets/icons/setup.svg | 14 ++++++++++++++ assets/icons/unchanged-check.svg | 14 ++++++++++++++ assets/icons/unchanged-lock.svg | 14 ++++++++++++++ docs/COMMAND_MIGRATION.md | 6 +++--- 15 files changed, 195 insertions(+), 13 deletions(-) create mode 100644 assets/icons/commands.svg create mode 100644 assets/icons/contents.svg create mode 100644 assets/icons/database.svg create mode 100644 assets/icons/docker.svg create mode 100644 assets/icons/env.svg create mode 100644 assets/icons/features.svg create mode 100644 assets/icons/local.svg create mode 100644 assets/icons/mapping.svg create mode 100644 assets/icons/permissions.svg create mode 100644 assets/icons/railway.svg create mode 100644 assets/icons/setup.svg create mode 100644 assets/icons/unchanged-check.svg create mode 100644 assets/icons/unchanged-lock.svg diff --git a/README.md b/README.md index c4222fb2a4..97ace71bb6 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ > **Herkunft:** NexoBot basiert auf [TitanBot](https://github.com/codebymitch/TitanBot) von codebymitch (MIT-Lizenz) und wurde für diesen Bot überarbeitet: Rebranding, Umstellung auf TypeScript, zusammengefasste Commands und deutsche Texte. -## Inhalt +## Inhalt - [Funktionen](#funktionen) - [Commands](#commands) @@ -24,7 +24,7 @@ - [Umgebungsvariablen](#umgebungsvariablen) - [Intents und Berechtigungen](#intents-und-berechtigungen) -## Funktionen +## Funktionen - **Moderation** – Ban, Kick, Timeout, Verwarnungen, Fälle, Massenaktionen, Kanal-Sperren, Nutzernotizen - **Economy** – Kontostand, Arbeit, Shop, Inventar, Glücksspiel, Überweisungen, Bestenliste @@ -38,15 +38,15 @@ Alle Funktionen lassen sich pro Server über `/configwizard` und `/commands` konfigurieren und einzeln sperren. -## Commands +## Commands NexoBot bündelt die Funktionen in thematische Commands mit Subcommands, zum Beispiel `/eco balance`, `/mod warn` oder `/music play`. Die vollständige Zuordnung der früheren Einzel-Commands steht in [docs/COMMAND_MIGRATION.md](docs/COMMAND_MIGRATION.md). `/help` zeigt alle Commands im Bot an. -## Einrichtung +## Einrichtung Voraussetzungen: ein Bot im [Discord Developer Portal](https://discord.com/developers/applications) mit Token und Client-ID sowie den unten genannten Intents. -### Lokal +### Lokal Benötigt Node.js 20.10 oder neuer und PostgreSQL. @@ -69,7 +69,7 @@ docker compose up -d db Die Compose-Datenbank läuft dann auf `127.0.0.1:5432` (User/Passwort/DB aus `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`). Sie hat kein SSL: Setze `POSTGRES_SSL=false`, sonst schlägt die Verbindung mit `NODE_ENV=production` fehl und der Bot läuft im In-Memory-Modus. -### Docker +### Docker ```bash cp .env.example .env # mindestens DISCORD_TOKEN, CLIENT_ID, GUILD_ID setzen @@ -84,7 +84,7 @@ docker compose --profile local-lavalink up -d Fertige Images: `ghcr.io/robertwlt03/nexobot`. -### Railway +### Railway 1. Repository in Railway verbinden und einen PostgreSQL-Dienst hinzufügen. 2. Im Bot-Dienst `DISCORD_TOKEN`, `CLIENT_ID` und `GUILD_ID` setzen und `POSTGRES_URL` bzw. `DATABASE_URL` auf die private Railway-Datenbank-URL verweisen lassen. @@ -92,7 +92,7 @@ Fertige Images: `ghcr.io/robertwlt03/nexobot`. Healthchecks: `GET /health` (Prozess lebt) und `GET /ready` (Bot verbunden und Datenbank bereit). -## Umgebungsvariablen +## Umgebungsvariablen Alle Variablen mit Beispielwerten stehen in [.env.example](.env.example). Die wichtigsten: @@ -113,7 +113,7 @@ Alle Variablen mit Beispielwerten stehen in [.env.example](.env.example). Die wi Ohne erreichbare Datenbank läuft der Bot in einem eingeschränkten In-Memory-Modus. Für den Produktivbetrieb ist PostgreSQL Pflicht. -### Datenbank-Werkzeuge +### Datenbank-Werkzeuge ```bash npm run migrate # Schema-Migrationen anwenden @@ -124,7 +124,7 @@ npm run restore:db # Backup einspielen Die Skripte laufen aus `dist/scripts/`, also vorher `npm run build` ausführen. -## Intents und Berechtigungen +## Intents und Berechtigungen Im Developer Portal aktivieren (Privileged Gateway Intents): diff --git a/assets/icons/commands.svg b/assets/icons/commands.svg new file mode 100644 index 0000000000..2338d10729 --- /dev/null +++ b/assets/icons/commands.svg @@ -0,0 +1,14 @@ + diff --git a/assets/icons/contents.svg b/assets/icons/contents.svg new file mode 100644 index 0000000000..ee476ad1ff --- /dev/null +++ b/assets/icons/contents.svg @@ -0,0 +1,14 @@ + diff --git a/assets/icons/database.svg b/assets/icons/database.svg new file mode 100644 index 0000000000..eefcc536a0 --- /dev/null +++ b/assets/icons/database.svg @@ -0,0 +1,14 @@ + diff --git a/assets/icons/docker.svg b/assets/icons/docker.svg new file mode 100644 index 0000000000..c8a879a433 --- /dev/null +++ b/assets/icons/docker.svg @@ -0,0 +1,14 @@ + diff --git a/assets/icons/env.svg b/assets/icons/env.svg new file mode 100644 index 0000000000..8c46444bdc --- /dev/null +++ b/assets/icons/env.svg @@ -0,0 +1,14 @@ + diff --git a/assets/icons/features.svg b/assets/icons/features.svg new file mode 100644 index 0000000000..07ae836a91 --- /dev/null +++ b/assets/icons/features.svg @@ -0,0 +1,14 @@ + diff --git a/assets/icons/local.svg b/assets/icons/local.svg new file mode 100644 index 0000000000..6bfeb55615 --- /dev/null +++ b/assets/icons/local.svg @@ -0,0 +1,14 @@ + diff --git a/assets/icons/mapping.svg b/assets/icons/mapping.svg new file mode 100644 index 0000000000..52dbe652cd --- /dev/null +++ b/assets/icons/mapping.svg @@ -0,0 +1,14 @@ + diff --git a/assets/icons/permissions.svg b/assets/icons/permissions.svg new file mode 100644 index 0000000000..e538f7f3ef --- /dev/null +++ b/assets/icons/permissions.svg @@ -0,0 +1,14 @@ + diff --git a/assets/icons/railway.svg b/assets/icons/railway.svg new file mode 100644 index 0000000000..cbfadb447b --- /dev/null +++ b/assets/icons/railway.svg @@ -0,0 +1,14 @@ + diff --git a/assets/icons/setup.svg b/assets/icons/setup.svg new file mode 100644 index 0000000000..66c6a5af4d --- /dev/null +++ b/assets/icons/setup.svg @@ -0,0 +1,14 @@ + diff --git a/assets/icons/unchanged-check.svg b/assets/icons/unchanged-check.svg new file mode 100644 index 0000000000..b04041308b --- /dev/null +++ b/assets/icons/unchanged-check.svg @@ -0,0 +1,14 @@ + diff --git a/assets/icons/unchanged-lock.svg b/assets/icons/unchanged-lock.svg new file mode 100644 index 0000000000..8e609d571c --- /dev/null +++ b/assets/icons/unchanged-lock.svg @@ -0,0 +1,14 @@ + diff --git a/docs/COMMAND_MIGRATION.md b/docs/COMMAND_MIGRATION.md index 4aa6dc6045..cd4674581b 100644 --- a/docs/COMMAND_MIGRATION.md +++ b/docs/COMMAND_MIGRATION.md @@ -4,7 +4,7 @@ NexoBot fasst die früheren Einzel-Commands thematisch zusammen. Aus 99 Top-Level-Commands werden 36. Command-Namen innerhalb der Gruppen, Optionen und Buttons bleiben gleich. -## Was gleich bleibt +## Was gleich bleibt - **Gesperrte Commands:** Sperren aus `/commands` mit alten Namen gelten weiter. Ein gesperrtes `/balance` sperrt `/eco balance`, ein gesperrtes `/usernotes` sperrt `/msg notes`. Gesperrte Kategorien gelten auch für Commands, die in eine andere Kategorie gewandert sind (z. B. `/weather` aus Utility). - **Prefix-Commands:** Alte Namen und Aliase funktionieren weiter: `!bal` → `/eco balance`, `!unban` → `/ban remove`, `!usernotes view @User` → `/msg notes view @User`. @@ -13,13 +13,13 @@ NexoBot fasst die früheren Einzel-Commands thematisch zusammen. Aus 99 Top-Leve Bei Namen, die es alt und neu gibt (`/ban`, `/kick`, `/rank`, `/welcome`), behält ein alter Eintrag seine alte Bedeutung: Ein gesperrtes `/ban` sperrt nur `/ban user`, ein gesperrtes `/welcome` nur `/welcome message`. Wer heute den ganzen neuen Command sperrt, sperrt alle Subcommands. -## Unverändert +## Unverändert `/help`, `/commands`, `/configwizard`, `/todo`, `/report`, `/wipedata`, `/apply`, `/app-admin`, `/count`, `/ticket`, `/cases`, `/verify`, `/birthday`, `/jointocreate`, `/logging`, `/reactroles`, `/search`, `/serverstats` Bestehende Subcommands bleiben außerdem direkt erhalten bei `/economy` (dashboard), `/music` (pause, resume, skip, …), `/level` (setup, dashboard) und `/verification` (setup, remove, dashboard). -## Zuordnung +## Zuordnung `…` bedeutet: Die alten Subcommands bleiben unter der neuen Gruppe erhalten, z. B. `/usernotes add` → `/msg notes add`. From 36e66c6e231bb92108b0e58fa11fdacebb2fe9c6 Mon Sep 17 00:00:00 2001 From: robertwalter Date: Sun, 18 Oct 2026 14:36:01 +0200 Subject: [PATCH 20/20] docs: make banners sharp on mobile Drop SVG drop-shadow/blur filters (iOS Safari rasterizes filtered SVG content at low resolution), enlarge the banner subtitle, and move the footer text out of the image into real Markdown text so it stays legible and crisp on small screens. Co-Authored-By: Claude Opus 5.5 --- README.md | 10 +++++++--- assets/banner-commands.svg | 16 +++++----------- assets/banner-footer.svg | 21 +++++---------------- assets/banner.svg | 14 ++++---------- docs/COMMAND_MIGRATION.md | 8 +++++++- 5 files changed, 28 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 97ace71bb6..5c8a5e3bf4 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,11 @@ Einladung mit den Scopes `bot` und `applications.commands` und diesen Berechtigu - Verbinden und Sprechen (Musik)

- NexoBot – © 2026 Robertwlt03, MIT-Lizenz. Basiert auf TitanBot von codebymitch (MIT). -
- Lizenz · TitanBot · Command-Migration + +

+ +

+ Nexo · Ein Bot. Alle Funktionen.
+ © 2026 Robertwlt03 · Veröffentlicht unter der MIT-Lizenz
+ Basiert auf TitanBot von codebymitch (MIT) und für NexoBot überarbeitet

diff --git a/assets/banner-commands.svg b/assets/banner-commands.svg index 4446eb6b50..bca4eecf5b 100644 --- a/assets/banner-commands.svg +++ b/assets/banner-commands.svg @@ -22,14 +22,8 @@ - - - - - - - + @@ -45,9 +39,9 @@ COMMANDS - 99 → 36 · ALTE NAMEN BLEIBEN + 99 → 36 · ALTE NAMEN BLEIBEN - + @@ -65,12 +59,12 @@ - + - + diff --git a/assets/banner-footer.svg b/assets/banner-footer.svg index cea77b99d1..72d7c00f45 100644 --- a/assets/banner-footer.svg +++ b/assets/banner-footer.svg @@ -1,4 +1,4 @@ - + diff --git a/assets/banner.svg b/assets/banner.svg index 55f0f08029..cdc7b64679 100644 --- a/assets/banner.svg +++ b/assets/banner.svg @@ -22,14 +22,8 @@ - - - - - - - + @@ -45,9 +39,9 @@ NEXO - EIN BOT. ALLE FUNKTIONEN. + EIN BOT. ALLE FUNKTIONEN. - + @@ -65,7 +59,7 @@ - + diff --git a/docs/COMMAND_MIGRATION.md b/docs/COMMAND_MIGRATION.md index cd4674581b..f04ea377bd 100644 --- a/docs/COMMAND_MIGRATION.md +++ b/docs/COMMAND_MIGRATION.md @@ -104,5 +104,11 @@ Bestehende Subcommands bleiben außerdem direkt erhalten bei `/economy` (dashboa | `/welcome …` | `/welcome message …` | Welcome |

- NexoBot – © 2026 Robertwlt03, MIT-Lizenz. Basiert auf TitanBot von codebymitch (MIT). + +

+ +

+ Nexo · Ein Bot. Alle Funktionen.
+ © 2026 Robertwlt03 · Veröffentlicht unter der MIT-Lizenz
+ Basiert auf TitanBot von codebymitch (MIT) und für NexoBot überarbeitet

Timestamp (UTC)AuthorMessage
Zeitpunkt (UTC)AutorNachricht