diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000000..9569ca95d9 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "PowerShell(docker compose down)", + "PowerShell(docker compose up -d --build)", + "PowerShell(docker compose logs bot --tail=50)" + ] + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000000..a4c5838ae8 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,36 @@ +{ + "permissions": { + "allow": [ + "Bash(Get-ChildItem -Path \"c:\\\\code\\\\zen-bot\" -Force)", + "Bash(Select-Object Name, Mode)", + "Bash(Format-Table)", + "PowerShell(Get-ChildItem -Path \"c:\\\\code\\\\zen-bot\\\\src\" -Recurse -Force | Where-Object { $_.PSIsContainer } | Select-Object FullName | ForEach-Object { $_.FullName.Replace\\('c:\\\\code\\\\zen-bot\\\\', ''\\) } | Sort-Object)", + "PowerShell(Get-ChildItem -Path \"c:\\\\code\\\\zen-bot\" -Name -Force | Where-Object { $_ -match 'CLAUDE|cursor' })", + "PowerShell(Get-ChildItem -Path \"c:\\\\code\\\\zen-bot\\\\src\\\\commands\" | Where-Object { $_.PSIsContainer } | ForEach-Object { $_.Name })", + "Bash(docker compose *)", + "Bash(git rm *)", + "Bash(git add *)", + "Bash(git commit -m ' *)", + "Bash(git push *)", + "Bash(Get-ChildItem -Path \"c:\\\\code\\\\zen-bot\" -Recurse -Directory)", + "Bash(Select-Object -ExpandProperty FullName)", + "PowerShell(Get-ChildItem -Path \"c:\\\\code\\\\zen-bot\\\\src\\\\commands\\\\Economy\" -Recurse | Select-Object -ExpandProperty FullName | ForEach-Object { $_.Replace\\(\"c:\\\\code\\\\zen-bot\\\\\", \"\"\\) })", + "Bash(Get-ChildItem -Path 'c:\\\\code\\\\zen-bot\\\\src\\\\commands' -Recurse -File)", + "Bash(Select-Object FullName)", + "Bash(Sort-Object FullName)", + "Bash(Get-ChildItem -Path \"c:\\\\code\\\\zen-bot\\\\src\\\\commands\" -Recurse -Filter \"*.js\")", + "Bash(git -C \"/c/code/zen-bot\" log --oneline -5)", + "Bash(Get-ChildItem -Path \"c:\\\\code\\\\zen-bot\\\\src\\\\commands\" -Directory)", + "Bash(Select-Object Name)", + "PowerShell(docker run --rm -v \"c:/code/zen-bot:/app\" -w /app node:20-alpine npm install --save @discordjs/voice play-dl opusscript libsodium-wrappers 2>&1)", + "PowerShell(docker compose up -d --build 2>&1)", + "Bash(git revert *)", + "PowerShell(Get-Command cloudflared -ErrorAction SilentlyContinue; Get-ChildItem \"C:\\\\Program Files\\\\cloudflared\\\\\" -ErrorAction SilentlyContinue; Get-ChildItem \"$env:LOCALAPPDATA\\\\cloudflared\\\\\" -ErrorAction SilentlyContinue; Get-ChildItem \"$env:ProgramFiles\\\\Cloudflare\\\\\" -ErrorAction SilentlyContinue)", + "PowerShell(& \"C:\\\\Program Files \\(x86\\)\\\\cloudflared\\\\cloudflared.exe\" --version)", + "PowerShell(Start-Process -FilePath \"C:\\\\Program Files \\(x86\\)\\\\cloudflared\\\\cloudflared.exe\" -ArgumentList \"tunnel --url http://localhost:3000\" -NoNewWindow -RedirectStandardOutput \"C:\\\\code\\\\zen-bot\\\\cloudflared.log\" -RedirectStandardError \"C:\\\\code\\\\zen-bot\\\\cloudflared_err.log\")", + "Bash(docker exec *)", + "WebFetch(domain:maven.lavalink.dev)", + "PowerShell(docker exec titanbot sed -n '2785,2840p' /usr/src/app/node_modules/lavalink-client/dist/index.js 2>&1)" + ] + } +} diff --git a/.env.editil.example b/.env.editil.example new file mode 100644 index 0000000000..4f593d74bf --- /dev/null +++ b/.env.editil.example @@ -0,0 +1,14 @@ +DISCORD_TOKEN=put_your_bot_token_here +GUILD_ID=0 +WELCOME_CHANNEL_ID=0 +RULES_CHANNEL_ID=0 +LOG_CHANNEL_ID=0 +TICKET_CATEGORY_ID=0 +TICKET_STAFF_ROLE_ID=0 +NEW_MEMBER_ROLE_ID=0 +MEMBER_ROLE_ID=0 +BOOSTER_ROLE_ID=0 +BOOSTER_CHANNEL_ID=0 +WINNERS_CHANNEL_ID=0 +LINKS_ALLOWED_CHANNEL_IDS= +BAD_WORDS= diff --git a/.env.example b/.env.example index 4f08ac030e..6ee2147ea5 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ DISCORD_TOKEN=your_discord_bot_token_here CLIENT_ID=your_discord_client_id_here GUILD_ID=your_discord_guild_id_here OWNER_IDS=your_discord_id_here (optional) +ADMIN_IDS=comma_separated_admin_ids_here (optional) # Bot Runtime Configuration NODE_ENV=production @@ -48,3 +49,9 @@ POSTGRES_RESTORE_URL= # Optional Feature/API Keys TMDB_API_KEY= + +# Anti-NSFW image scanning (Sightengine — https://sightengine.com) +# Sign up for a free account to get these values. +# Without these, image/video scanning is skipped; text/domain filtering still works. +SIGHTENGINE_API_USER= +SIGHTENGINE_API_SECRET= diff --git a/.gitignore b/.gitignore index 23b71db818..7a2a0f8c21 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,10 @@ build/Release node_modules/ jspm_packages/ +# Python cache +__pycache__/ +*.pyc + # Snowpack dependency directory (https: web_modules/ @@ -144,3 +148,11 @@ dist # Database backup artifacts backups/ *.dump + +# Lavalink downloaded plugins (rebuilt on container start) +lavalink/plugins/ + +# Cloudflare tunnel logs +cloudflared.log +cloudflared_err.log +Modelfile diff --git a/.vscode/settings.json b/.vscode/settings.json index b684618899..2f0815f7de 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,7 @@ { "files.encoding": "utf8", - "files.autoGuessEncoding": false + "files.autoGuessEncoding": false, + + } + \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..414d603478 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,86 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## Commands + +```bash +npm start # Run the bot +npm test # Run all tests (Node built-in test runner) +node --test tests/path/to/file.test.js # Run a single test file +npm run migrate # Apply pending migrations +npm run migrate:check # Validate migration state +npm run migrate:status # Show migration history +``` + +Docker (preferred for full-stack): +```bash +docker compose up -d --build # Build and start bot + PostgreSQL +docker compose down # Stop everything +docker compose logs -f bot # Stream bot logs +``` + +## Architecture + +### Entry Point & Bot Class +`src/app.js` defines `TitanBot extends Client`. On startup it: +1. Initializes the database (PostgreSQL with in-memory fallback) +2. Loads commands, events, and interactions via handlers +3. Starts an Express web server (`GET /health`, `GET /ready`) +4. Schedules cron jobs (birthdays at 6 AM, giveaways every minute, counters every 15 min) + +### Command Loading +`src/handlers/commandLoader.js` recursively scans `src/commands/**/*.js` (skipping `modules/` subdirs) and registers guild-level slash commands. Each command exports: +```js +export default { + data: new SlashCommandBuilder()..., + async execute(interaction, guildConfig, client) { ... } +} +``` +Commands are organized into category subdirectories under `src/commands/`. + +### Interaction Routing +`src/events/interactionCreate.js` routes all Discord interactions: +- Slash commands → `client.commands` +- Button presses → `client.buttons` +- Select menus → `client.selectMenus` +- Modals → `client.modals` + +Each category has its handlers in `src/interactions/buttons/`, `src/interactions/selectMenus/`, and `src/interactions/modals/`. These are loaded by `src/handlers/interactions.js`. + +### Database Layer +`src/utils/database.js` exports a `DatabaseWrapper` singleton (`db`) that wraps either: +- **PostgreSQL** (`src/utils/postgresDatabase.js`) — when `POSTGRES_URL`/`POSTGRES_HOST` is reachable +- **MemoryStorage** (`src/utils/memoryStorage.js`) — degraded fallback when Postgres is unavailable + +`db.isAvailable()` returns `true` only when PostgreSQL is connected. Functions that require SQL (e.g. `getEndedGiveaways`) check this and return empty results in degraded mode. The bot attaches the wrapper to `client.db` at startup. + +In Docker Compose the bot service must set `POSTGRES_HOST=db` and `POSTGRES_URL=postgresql://...@db:5432/...` — the `.env` file defaults to `localhost` which only works for local dev outside Docker. + +### Services +`src/services/` contains domain-specific business logic (giveawayService, ticketService, leveling, economy, etc.). Commands call services; services interact with the database directly via `pgDb` or the wrapper. + +### Configuration +`src/config/postgres.js` reads individual `POSTGRES_*` env vars (not `DATABASE_URL`). `src/config/bot.js` has feature flags — 18 features are toggleable; music is disabled. `src/config/schemaVersion.js` holds `EXPECTED_SCHEMA_VERSION` which must match the applied migration. + +### Migrations +`scripts/migrate.js` manages schema versions tracked in the `schema_migrations` table. The bot will refuse to start if the schema version mismatches (`SCHEMA_VERSION_MISMATCH` error code). `AUTO_MIGRATE=true` in `.env` applies pending migrations automatically on startup. + +### Error & Interaction Helpers +- `src/utils/interactionHelper.js` — use `InteractionHelper.safeReply/safeDeferReply/safeEdit` instead of raw Discord.js methods to handle already-acknowledged interaction errors gracefully. +- `src/utils/abuseProtection.js` — rate limiting applied per command before `execute()` is called. +- `src/utils/embeds.js` — `createEmbed({ title, description, color })` for consistent embed styling. + +## Completion Workflow + +After every completed coding task, unless the user explicitly says otherwise: + +1. Update the bot version and add accurate Hebrew release notes for the completed work. +2. Run the full test suite and any focused validation appropriate to the change. +3. Commit only the task's intended files and push the current branch. +4. Rebuild and restart the bot with Docker Compose. +5. If the pushed commit contains a user-facing bot update or bug fix, publish exactly one Hebrew bot-update announcement through the persistent update system. Do not announce documentation-only, tests-only, formatting, refactoring-without-behavior-change, or routine maintenance commits. +6. Verify PostgreSQL recorded the announced version and message ID, inspect the Discord message for correct Hebrew/UTF-8 content, and check the readiness endpoint. +7. Report the commit, branch, test results, deployment health, announced version, and Discord message ID. + +Never publish an automatic update while PostgreSQL is unavailable or the bot is using in-memory degraded storage. Never start a second local bot process when the Docker bot service is already running. Do not stage, commit, or overwrite unrelated user changes. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..535b62a990 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,72 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +npm start # Run the bot +npm test # Run all tests (Node built-in test runner) +node --test tests/path/to/file.test.js # Run a single test file +npm run migrate # Apply pending migrations +npm run migrate:check # Validate migration state +npm run migrate:status # Show migration history +``` + +Docker (preferred for full-stack): +```bash +docker compose up -d --build # Build and start bot + PostgreSQL +docker compose down # Stop everything +docker compose logs -f bot # Stream bot logs +``` + +## Architecture + +### Entry Point & Bot Class +`src/app.js` defines `TitanBot extends Client`. On startup it: +1. Initializes the database (PostgreSQL with in-memory fallback) +2. Loads commands, events, and interactions via handlers +3. Starts an Express web server (`GET /health`, `GET /ready`) +4. Schedules cron jobs (birthdays at 6 AM, giveaways every minute, counters every 15 min) + +### Command Loading +`src/handlers/commandLoader.js` recursively scans `src/commands/**/*.js` (skipping `modules/` subdirs) and registers guild-level slash commands. Each command exports: +```js +export default { + data: new SlashCommandBuilder()..., + async execute(interaction, guildConfig, client) { ... } +} +``` +Commands are organized into category subdirectories under `src/commands/`. + +### Interaction Routing +`src/events/interactionCreate.js` routes all Discord interactions: +- Slash commands → `client.commands` +- Button presses → `client.buttons` +- Select menus → `client.selectMenus` +- Modals → `client.modals` + +Each category has its handlers in `src/interactions/buttons/`, `src/interactions/selectMenus/`, and `src/interactions/modals/`. These are loaded by `src/handlers/interactions.js`. + +### Database Layer +`src/utils/database.js` exports a `DatabaseWrapper` singleton (`db`) that wraps either: +- **PostgreSQL** (`src/utils/postgresDatabase.js`) — when `POSTGRES_URL`/`POSTGRES_HOST` is reachable +- **MemoryStorage** (`src/utils/memoryStorage.js`) — degraded fallback when Postgres is unavailable + +`db.isAvailable()` returns `true` only when PostgreSQL is connected. Functions that require SQL (e.g. `getEndedGiveaways`) check this and return empty results in degraded mode. The bot attaches the wrapper to `client.db` at startup. + +In Docker Compose the bot service must set `POSTGRES_HOST=db` and `POSTGRES_URL=postgresql://...@db:5432/...` — the `.env` file defaults to `localhost` which only works for local dev outside Docker. + +### Services +`src/services/` contains domain-specific business logic (giveawayService, ticketService, leveling, economy, etc.). Commands call services; services interact with the database directly via `pgDb` or the wrapper. + +### Configuration +`src/config/postgres.js` reads individual `POSTGRES_*` env vars (not `DATABASE_URL`). `src/config/bot.js` has feature flags — 18 features are toggleable; music is disabled. `src/config/schemaVersion.js` holds `EXPECTED_SCHEMA_VERSION` which must match the applied migration. + +### Migrations +`scripts/migrate.js` manages schema versions tracked in the `schema_migrations` table. The bot will refuse to start if the schema version mismatches (`SCHEMA_VERSION_MISMATCH` error code). `AUTO_MIGRATE=true` in `.env` applies pending migrations automatically on startup. + +### Error & Interaction Helpers +- `src/utils/interactionHelper.js` — use `InteractionHelper.safeReply/safeDeferReply/safeEdit` instead of raw Discord.js methods to handle already-acknowledged interaction errors gracefully. +- `src/utils/abuseProtection.js` — rate limiting applied per command before `execute()` is called. +- `src/utils/embeds.js` — `createEmbed({ title, description, color })` for consistent embed styling. diff --git a/Dockerfile b/Dockerfile index 576813ed8f..4f0a67045d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,12 @@ WORKDIR /usr/src/app COPY package*.json ./ # Install only production dependencies +RUN apk add --no-cache ffmpeg python3 py3-pip && \ + pip3 install yt-dlp --break-system-packages --quiet && \ + mkdir -p /root/.config/yt-dlp && \ + echo '--js-runtimes node' > /root/.config/yt-dlp/config && \ + yt-dlp --js-runtimes node --remote-components ejs:github --simulate "https://www.youtube.com/watch?v=jNQXAC9IVRw" 2>/dev/null || true + RUN npm ci --omit=dev # Bundle app source diff --git a/Dockerfile.editil b/Dockerfile.editil new file mode 100644 index 0000000000..3ed07da6f2 --- /dev/null +++ b/Dockerfile.editil @@ -0,0 +1,8 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY requirements-editil.txt ./ +RUN pip install --no-cache-dir -r requirements-editil.txt +COPY editil_bot ./editil_bot + +CMD ["python", "-m", "editil_bot.main"] diff --git a/README-EDITIL.md b/README-EDITIL.md new file mode 100644 index 0000000000..d145cccbe5 --- /dev/null +++ b/README-EDITIL.md @@ -0,0 +1,26 @@ +# עורכי ישראל | EditIL + +בוט Discord מקצועי לקהילת עורכי וידאו ישראלית, בנוי עם Python, `discord.py`, כפתורי UI ו־SQLite. + +## הפעלה + +1. התקינו Python 3.11 ומעלה. +2. צרו `.env.editil` על בסיס [`.env.editil.example`](.env.editil.example), והזינו את מזהי התפקידים והערוצים. +3. התקינו תלויות: `py -m pip install -r requirements-editil.txt` +4. הפעילו: `py -m editil_bot.main` + +להפעלה עם Docker: `docker compose -f docker-compose.editil.yml up -d --build`. + +הפעילו ב־Discord Developer Portal את **Server Members Intent** ואת **Message Content Intent**. + +## ניהול + +- `/panel roles` — פאנל בחירת תוכנות וסוגי עורכים. +- `/panel verify` — פאנל אימות חברי קהילה. +- `/tickets` — פאנל כרטיסי עזרה, דיווחים, שיתופי פעולה ובאגים. +- `/warn`, `/timeout`, `/kick`, `/ban`, `/clear` — כלי ניהול. +- `/showcase` — פרסום יצירה. +- `/profile` — פרופיל עורך. +- `/contest create`, `/contest submit`, `/contest vote` — תחרויות עריכה. + +למערכת התפקידים, צרו בשרת תפקידים בעלי השמות המדויקים שמופיעים בפאנל. הגדירו את תפקידי `🆕 חדש`, `❤️ חבר קהילה` ו־`💎 Booster` באמצעות מזהי התפקידים בקובץ ההגדרות. diff --git a/docker-compose.editil.yml b/docker-compose.editil.yml new file mode 100644 index 0000000000..7004b708ac --- /dev/null +++ b/docker-compose.editil.yml @@ -0,0 +1,13 @@ +services: + editil-bot: + build: + context: . + dockerfile: Dockerfile.editil + env_file: + - .env.editil + volumes: + - editil-data:/app/data + restart: unless-stopped + +volumes: + editil-data: diff --git a/docker-compose.linux.yml b/docker-compose.linux.yml new file mode 100644 index 0000000000..a3d59edc6d --- /dev/null +++ b/docker-compose.linux.yml @@ -0,0 +1,52 @@ +# Linux VPS deployment — uses network_mode: host so Discord voice UDP works. +# Usage: docker compose -f docker-compose.linux.yml up -d --build + +services: + bot: + build: . + container_name: titanbot + restart: unless-stopped + network_mode: host + environment: + - NODE_ENV=production + - DISCORD_TOKEN=${DISCORD_TOKEN} + - CLIENT_ID=${CLIENT_ID} + - GUILD_ID=${GUILD_ID} + - OWNER_IDS=${OWNER_IDS} + - LOG_LEVEL=${LOG_LEVEL:-warn} + - PORT=${PORT:-3000} + - WEB_HOST=0.0.0.0 + - AUTO_MIGRATE=true + - POSTGRES_HOST=localhost + - POSTGRES_PORT=5432 + - POSTGRES_USER=${POSTGRES_USER:-titanbot} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-titanbotpassword} + - POSTGRES_DB=${POSTGRES_DB:-titanbot} + - POSTGRES_URL=postgresql://${POSTGRES_USER:-titanbot}:${POSTGRES_PASSWORD:-titanbotpassword}@localhost:5432/${POSTGRES_DB:-titanbot} + - DATABASE_URL=postgres://${POSTGRES_USER:-titanbot}:${POSTGRES_PASSWORD:-titanbotpassword}@localhost:5432/${POSTGRES_DB:-titanbot} + - SIGHTENGINE_API_USER=${SIGHTENGINE_API_USER} + - SIGHTENGINE_API_SECRET=${SIGHTENGINE_API_SECRET} + depends_on: + db: + condition: service_healthy + + db: + image: postgres:15-alpine + container_name: titanbot-db + restart: unless-stopped + ports: + - "127.0.0.1:5432:5432" + environment: + - POSTGRES_USER=${POSTGRES_USER:-titanbot} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-titanbotpassword} + - POSTGRES_DB=${POSTGRES_DB:-titanbot} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + postgres_data: diff --git a/docker-compose.yml b/docker-compose.yml index b2fb7b982f..3afbe5373e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,16 +3,32 @@ services: build: . container_name: titanbot restart: unless-stopped + ports: + - "3000:3000" environment: - NODE_ENV=production - DISCORD_TOKEN=${DISCORD_TOKEN} - CLIENT_ID=${CLIENT_ID} - GUILD_ID=${GUILD_ID} - DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + - POSTGRES_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + - POSTGRES_HOST=db - PORT=3000 + - OWNER_IDS=${OWNER_IDS} + - ADMIN_IDS=${ADMIN_IDS} + - DASHBOARD_PASSWORD=${DASHBOARD_PASSWORD:-admin} + - DASHBOARD_SECRET=${DASHBOARD_SECRET} + - DASHBOARD_EMAIL=${DASHBOARD_EMAIL} + - CLIENT_SECRET=${CLIENT_SECRET} + - REDIRECT_URI=${REDIRECT_URI:-http://localhost:3000/auth/discord/callback} + - LAVALINK_HOST=lavalink + - LAVALINK_PORT=2333 + - LAVALINK_PASSWORD=${LAVALINK_PASSWORD:-youshallnotpass} depends_on: db: condition: service_healthy + lavalink: + condition: service_started networks: - titan-network @@ -22,7 +38,7 @@ services: restart: unless-stopped environment: - POSTGRES_USER=${POSTGRES_USER:-titanbot} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-password} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-titanbotpassword} - POSTGRES_DB=${POSTGRES_DB:-titanbot} volumes: - postgres_data:/var/lib/postgresql/data @@ -34,6 +50,23 @@ services: networks: - titan-network + lavalink: + image: ghcr.io/lavalink-devs/lavalink:4 + container_name: lavalink + restart: unless-stopped + environment: + - _JAVA_OPTIONS=-Xmx512m + - LAVALINK_SERVER_PASSWORD=${LAVALINK_PASSWORD:-youshallnotpass} + - SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID} + - SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET} + - YOUTUBE_REFRESH_TOKEN=${YOUTUBE_REFRESH_TOKEN} + volumes: + - ./lavalink/application.yml:/opt/Lavalink/application.yml + networks: + - titan-network + healthcheck: + disable: true + networks: titan-network: driver: bridge diff --git a/editil_bot/__init__.py b/editil_bot/__init__.py new file mode 100644 index 0000000000..4af61a0777 --- /dev/null +++ b/editil_bot/__init__.py @@ -0,0 +1 @@ +"""EditIL Discord bot package.""" diff --git a/editil_bot/cogs/__init__.py b/editil_bot/cogs/__init__.py new file mode 100644 index 0000000000..0ce1ac05cf --- /dev/null +++ b/editil_bot/cogs/__init__.py @@ -0,0 +1 @@ +"""EditIL feature cogs.""" diff --git a/editil_bot/cogs/community.py b/editil_bot/cogs/community.py new file mode 100644 index 0000000000..ab1d6d31d6 --- /dev/null +++ b/editil_bot/cogs/community.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import discord +from discord import app_commands +from discord.ext import commands + +from ..embeds import BLUE, PURPLE, embed, error, success +from ..logging import log + +SOFTWARE = [("🎬", "After Effects"), ("✂️", "Premiere Pro"), ("📱", "CapCut"), ("🎨", "Photoshop"), ("🧊", "Blender"), ("🎥", "DaVinci Resolve")] +EDITOR_TYPES = [("🎬", "Video Editor"), ("📱", "TikTok Editor"), ("🎮", "Gaming Editor"), ("🎨", "Designer"), ("🎵", "Music Creator"), ("🌱", "Beginner Editor")] + + +class VerificationView(discord.ui.View): + def __init__(self, cog: "Community"): + super().__init__(timeout=None) + self.cog = cog + + @discord.ui.button(label="אימות", emoji="✅", style=discord.ButtonStyle.success, custom_id="editil:verify") + async def verify(self, interaction: discord.Interaction, _: discord.ui.Button) -> None: + if not interaction.guild or not isinstance(interaction.user, discord.Member): + return + settings = self.cog.bot.settings + new_role = interaction.guild.get_role(settings.new_member_role_id) + member_role = interaction.guild.get_role(settings.member_role_id) + if member_role is None: + await interaction.response.send_message(embed=error("תפקיד חבר קהילה לא הוגדר עדיין."), ephemeral=True) + return + if new_role: + await interaction.user.remove_roles(new_role, reason="אימות משתמש") + await interaction.user.add_roles(member_role, reason="אימות משתמש") + await interaction.response.send_message(embed=success("האימות הושלם. ברוכים הבאים לקהילה!"), ephemeral=True) + await log(interaction.guild, settings.log_channel_id, "✅ אימות", f"{interaction.user.mention} אימת/ה את חשבונו/ה.") + + +class RolePanel(discord.ui.View): + def __init__(self, cog: "Community"): + super().__init__(timeout=None) + self.cog = cog + self.add_item(RoleSelect("software", "תוכנת עריכה", SOFTWARE)) + self.add_item(RoleSelect("type", "סוג עורך", EDITOR_TYPES)) + + +class RoleSelect(discord.ui.Select): + def __init__(self, group: str, placeholder: str, roles: list[tuple[str, str]]): + options = [discord.SelectOption(label=name, emoji=emoji, value=name) for emoji, name in roles] + super().__init__(placeholder=placeholder, min_values=0, max_values=len(options), options=options, custom_id=f"editil:roles:{group}") + self.group = group + self.names = [name for _, name in roles] + + async def callback(self, interaction: discord.Interaction) -> None: + assert interaction.guild and isinstance(interaction.user, discord.Member) + managed = [role for role in interaction.guild.roles if role.name in self.names] + selected = [role for role in interaction.guild.roles if role.name in self.values] + if managed: + await interaction.user.remove_roles(*managed, reason="עדכון תפקידי עריכה") + if selected: + await interaction.user.add_roles(*selected, reason="עדכון תפקידי עריכה") + await interaction.response.send_message(embed=success("התפקידים שלך עודכנו."), ephemeral=True) + + +class WelcomeView(discord.ui.View): + def __init__(self, cog: "Community"): + super().__init__(timeout=None) + self.cog = cog + + @discord.ui.button(label="חוקים", emoji="📜", style=discord.ButtonStyle.secondary, custom_id="editil:rules") + async def rules(self, interaction: discord.Interaction, _: discord.ui.Button) -> None: + channel = interaction.guild and interaction.guild.get_channel(self.cog.bot.settings.rules_channel_id) + text = channel.mention if isinstance(channel, discord.TextChannel) else "ערוץ החוקים עדיין לא הוגדר." + await interaction.response.send_message(f"📜 החוקים נמצאים כאן: {text}", ephemeral=True) + + @discord.ui.button(label="תפקידים", emoji="🎭", style=discord.ButtonStyle.primary, custom_id="editil:welcome-roles") + async def roles(self, interaction: discord.Interaction, _: discord.ui.Button) -> None: + await interaction.response.send_message("🎭 בחרו את התפקידים המתאימים לכם:", view=RolePanel(self.cog), ephemeral=True) + + @discord.ui.button(label="התחילו לערוך", emoji="🎬", style=discord.ButtonStyle.success, custom_id="editil:start") + async def start(self, interaction: discord.Interaction, _: discord.ui.Button) -> None: + await interaction.response.send_message("🎬 התחילו בהיכרות, בחרו תפקידים ושתפו את העריכה הראשונה שלכם!", ephemeral=True) + + +class Community(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + bot.add_view(VerificationView(self)) + bot.add_view(RolePanel(self)) + bot.add_view(WelcomeView(self)) + + @commands.Cog.listener() + async def on_member_join(self, member: discord.Member) -> None: + s = self.bot.settings + new_role = member.guild.get_role(s.new_member_role_id) + if new_role: + await member.add_roles(new_role, reason="חבר חדש בקהילה") + channel = member.guild.get_channel(s.welcome_channel_id) + if isinstance(channel, discord.TextChannel): + message = (f"🎬 ברוכים הבאים ל־**EditIL 🇮🇱**\n\nשלום {member.mention}!\n" + "אתם עכשיו חלק מקהילת העורכים הישראלית.\n\n" + "כדי להתחיל:\n📜 קראו חוקים\n🎭 בחרו תפקידים\n🎬 שתפו את היצירות שלכם") + await channel.send(embed=embed("ברוכים הבאים", message, PURPLE), view=WelcomeView(self)) + await log(member.guild, s.log_channel_id, "📥 הצטרפות", f"{member.mention} הצטרף/ה לשרת.") + + @commands.Cog.listener() + async def on_member_update(self, before: discord.Member, after: discord.Member) -> None: + if before.premium_since is None and after.premium_since is not None: + role = after.guild.get_role(self.bot.settings.booster_role_id) + if role: + await after.add_roles(role, reason="Discord Nitro Boost") + channel = after.guild.get_channel(self.bot.settings.welcome_channel_id) + if isinstance(channel, discord.TextChannel): + await channel.send(embed=embed("💎 תודה על התמיכה ב־EditIL!", f"{after.mention}, קיבלתם את תפקיד ה־Booster והטבות בלעדיות.", PURPLE)) + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Community(bot)) diff --git a/editil_bot/cogs/community_commands.py b/editil_bot/cogs/community_commands.py new file mode 100644 index 0000000000..728670d450 --- /dev/null +++ b/editil_bot/cogs/community_commands.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import discord +from discord import app_commands +from discord.ext import commands + +from ..embeds import PURPLE, embed, error, success +from .community import RolePanel + + +class CommunityCommands(commands.Cog): + def __init__(self, bot: commands.Bot): self.bot = bot + + async def _send_to_configured(self, interaction: discord.Interaction, key: str, title: str, content: str) -> None: + channel_id = int(await self.bot.db.get_guild_setting(interaction.guild_id, key, 0) or 0) + channel = interaction.guild.get_channel(channel_id) if interaction.guild else None + if not isinstance(channel, discord.TextChannel): + await interaction.response.send_message(embed=error("המערכת עדיין לא הוגדרה. בעל השרת יכול להשתמש ב־`/setup`."), ephemeral=True); return + await channel.send(embed=embed(title, f"**מאת:** {interaction.user.mention}\n{content}", PURPLE)) + await interaction.response.send_message(embed=success("הפנייה נשלחה בהצלחה."), ephemeral=True) + + @app_commands.command(name="suggest", description="שליחת הצעה לקהילה") + async def suggest(self, interaction: discord.Interaction, suggestion: str) -> None: + await self._send_to_configured(interaction, "suggestions_channel_id", "💡 הצעה חדשה", suggestion) + + @app_commands.command(name="report", description="דיווח פרטי לצוות") + async def report(self, interaction: discord.Interaction, member: discord.Member, reason: str) -> None: + await self._send_to_configured(interaction, "reports_channel_id", "🚨 דיווח חדש", f"**משתמש:** {member.mention}\n**סיבה:** {reason}") + + @app_commands.command(name="feedback", description="שליחת משוב") + async def feedback(self, interaction: discord.Interaction, message: str) -> None: + key = "suggestions_channel_id" + await self._send_to_configured(interaction, key, "📝 משוב חדש", message) + + @app_commands.command(name="poll", description="יצירת סקר") + async def poll(self, interaction: discord.Interaction, question: str, option_one: str, option_two: str) -> None: + card = embed("📊 " + question, f"1️⃣ {option_one}\n2️⃣ {option_two}\n\nנוצר על ידי {interaction.user.mention}", PURPLE) + await interaction.response.send_message(embed=card) + message = await interaction.original_response() + await message.add_reaction("1️⃣"); await message.add_reaction("2️⃣") + + @app_commands.command(name="roles", description="בחירת תפקידי עריכה") + async def roles(self, interaction: discord.Interaction) -> None: + community = self.bot.get_cog("Community") + await interaction.response.send_message("בחרו את התפקידים המתאימים לכם:", view=RolePanel(community), ephemeral=True) + + @app_commands.command(name="rolepanel", description="פרסום לוח בחירת תפקידים") + @app_commands.checks.has_permissions(manage_roles=True) + async def rolepanel(self, interaction: discord.Interaction) -> None: + community = self.bot.get_cog("Community") + await interaction.response.send_message(embed=embed("🎭 בחירת תפקידים", "בחרו מהרשימות את תחומי העריכה שלכם.", PURPLE), view=RolePanel(community)) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(CommunityCommands(bot)) diff --git a/editil_bot/cogs/contests.py b/editil_bot/cogs/contests.py new file mode 100644 index 0000000000..bf0eb82070 --- /dev/null +++ b/editil_bot/cogs/contests.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import time +from urllib.parse import urlparse + +import discord +from discord import app_commands +from discord.ext import commands + +from ..embeds import PURPLE, error, success, embed +from ..logging import log + + +class Contests(commands.Cog): + contest = app_commands.Group(name="contest", description="תחרויות עריכה של EditIL") + + def __init__(self, bot: commands.Bot): self.bot = bot + + @contest.command(name="create", description="יצירת תחרות עריכה") + @app_commands.checks.has_permissions(manage_guild=True) + async def create(self, interaction: discord.Interaction, title: str, description: str, days: app_commands.Range[int, 1, 30] = 7) -> None: + ends = int(time.time()) + days * 86400 + cursor = await self.bot.db.execute("INSERT INTO contests (guild_id, title, description, ends_at) VALUES (?, ?, ?, ?)", (interaction.guild_id, title, description, ends)) + await interaction.response.send_message(embed=embed("🏆 תחרות עריכה חדשה", f"**{title}**\n{description}\n\nהגשות פתוחות עד .\nמזהה תחרות: `{cursor.lastrowid}`", PURPLE)) + await log(interaction.guild, self.bot.settings.log_channel_id, "🏆 תחרות נוצרה", f"{interaction.user.mention} יצר/ה את {title}.") + + @contest.command(name="submit", description="הגשת עריכה לתחרות") + async def submit(self, interaction: discord.Interaction, contest_id: int, link: str) -> None: + parsed = urlparse(link) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + await interaction.response.send_message(embed=error("יש לשלוח קישור תקין להגשה."), ephemeral=True) + return + contest = await self.bot.db.fetchone("SELECT title, ends_at, active FROM contests WHERE id = ? AND guild_id = ?", (contest_id, interaction.guild_id)) + if not contest or not contest[2] or contest[1] < time.time(): + await interaction.response.send_message(embed=error("לא נמצאה תחרות פעילה עם מזהה זה."), ephemeral=True) + return + try: + await self.bot.db.execute("INSERT INTO submissions (contest_id, user_id, url) VALUES (?, ?, ?)", (contest_id, interaction.user.id, link)) + except Exception: + await interaction.response.send_message(embed=error("כבר שלחתם הגשה לתחרות זו."), ephemeral=True) + return + await interaction.response.send_message(embed=success(f"ההגשה לתחרות **{contest[0]}** נקלטה."), ephemeral=True) + + @contest.command(name="vote", description="הצבעה להגשה בתחרות") + async def vote(self, interaction: discord.Interaction, contest_id: int, submission_id: int) -> None: + submitted = await self.bot.db.fetchone("SELECT user_id FROM submissions WHERE id = ? AND contest_id = ?", (submission_id, contest_id)) + if not submitted or submitted[0] == interaction.user.id: + await interaction.response.send_message(embed=error("הגשה לא נמצאה או שלא ניתן להצביע לעצמכם."), ephemeral=True) + return + try: + await self.bot.db.execute("INSERT INTO votes (contest_id, voter_id, submission_id) VALUES (?, ?, ?)", (contest_id, interaction.user.id, submission_id)) + except Exception: + await interaction.response.send_message(embed=error("כבר הצבעתם בתחרות הזו."), ephemeral=True) + return + await interaction.response.send_message(embed=success("ההצבעה שלך נשמרה!"), ephemeral=True) + + @contest.command(name="end", description="סיום תחרות והצגת הזוכה") + @app_commands.checks.has_permissions(manage_guild=True) + async def end(self, interaction: discord.Interaction, contest_id: int) -> None: + contest = await self.bot.db.fetchone("SELECT title, active FROM contests WHERE id = ? AND guild_id = ?", (contest_id, interaction.guild_id)) + if not contest or not contest[1]: + await interaction.response.send_message(embed=error("לא נמצאה תחרות פעילה עם מזהה זה."), ephemeral=True); return + winner = await self.bot.db.fetchone("SELECT s.user_id, COUNT(v.voter_id) votes FROM submissions s LEFT JOIN votes v ON v.submission_id = s.id WHERE s.contest_id = ? GROUP BY s.id ORDER BY votes DESC, s.id ASC LIMIT 1", (contest_id,)) + await self.bot.db.execute("UPDATE contests SET active = 0 WHERE id = ?", (contest_id,)) + if winner: + await self.bot.db.execute("INSERT OR IGNORE INTO profiles (user_id) VALUES (?)", (winner[0],)) + await self.bot.db.execute("UPDATE profiles SET wins = wins + 1 WHERE user_id = ?", (winner[0],)) + result = f"הזוכה: <@{winner[0]}> עם {winner[1]} הצבעות." if winner else "התחרות הסתיימה ללא הגשות." + await interaction.response.send_message(embed=embed(f"🏆 {contest[0]}", result, PURPLE)) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Contests(bot)) diff --git a/editil_bot/cogs/core.py b/editil_bot/cogs/core.py new file mode 100644 index 0000000000..0b16e90cbf --- /dev/null +++ b/editil_bot/cogs/core.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import logging +import secrets + +import discord +from discord import app_commands +from discord.ext import commands + +from ..embeds import error +from ..logging import log + +logger = logging.getLogger(__name__) + + +class Core(commands.Cog): + """Cross-cutting Hebrew errors and private audit logging.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @commands.Cog.listener() + async def on_app_command_completion(self, interaction: discord.Interaction, command: app_commands.Command) -> None: + if interaction.guild: + await log(interaction.guild, self.bot.settings.log_channel_id, "⌨️ פקודה", f"{interaction.user.mention} הפעיל/ה `/{command.qualified_name}`.") + + @commands.Cog.listener() + async def on_member_remove(self, member: discord.Member) -> None: + await log(member.guild, self.bot.settings.log_channel_id, "📤 עזיבה", f"{member.mention} עזב/ה את השרת.") + + @commands.Cog.listener() + async def on_member_update(self, before: discord.Member, after: discord.Member) -> None: + before_roles = {role.id for role in before.roles} + after_roles = {role.id for role in after.roles} + if before_roles != after_roles: + added = [role.mention for role in after.roles if role.id not in before_roles] + removed = [role.mention for role in before.roles if role.id not in after_roles] + changes = [] + if added: + changes.append("נוסף: " + ", ".join(added)) + if removed: + changes.append("הוסר: " + ", ".join(removed)) + await log(after.guild, self.bot.settings.log_channel_id, "🎭 שינוי תפקידים", f"{after.mention}\n" + "\n".join(changes)) + + @commands.Cog.listener() + async def on_app_command_error(self, interaction: discord.Interaction, exception: app_commands.AppCommandError) -> None: + if isinstance(exception, app_commands.MissingPermissions): + message = error("אין לך את ההרשאה הדרושה לפעולה זו.") + elif isinstance(exception, app_commands.CommandOnCooldown): + message = error("יש להמתין מעט לפני ניסיון נוסף.") + else: + reference = secrets.token_hex(4).upper() + self.bot.last_error_reference = reference + original = getattr(exception, "original", exception) + logger.error("Unhandled application command error [%s]", reference, exc_info=(type(original), original, original.__traceback__)) + message = error(f"אירעה שגיאה בעת ביצוע הפקודה. השגיאה נרשמה לבדיקה. מזהה: `{reference}`") + if interaction.guild: + await log(interaction.guild, self.bot.settings.log_channel_id, "⚠️ שגיאת פקודה", f"פקודה: `/{interaction.command.qualified_name if interaction.command else 'לא ידוע'}`\nמשתמש: {interaction.user.mention}\nמזהה: `{reference}`") + if interaction.response.is_done(): + await interaction.followup.send(embed=message, ephemeral=True) + else: + await interaction.response.send_message(embed=message, ephemeral=True) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Core(bot)) diff --git a/editil_bot/cogs/general.py b/editil_bot/cogs/general.py new file mode 100644 index 0000000000..a6dc5c2d9a --- /dev/null +++ b/editil_bot/cogs/general.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import discord +from discord import app_commands +from discord.ext import commands + +from ..embeds import BLUE, embed + + +CATEGORIES = { + "כללי": {"help", "ping", "botinfo", "serverinfo", "userinfo", "avatar"}, + "קהילה": {"suggest", "report", "feedback", "poll", "roles", "rolepanel", "showcase", "contest"}, + "רמות": {"rank", "profile", "leaderboard"}, + "כרטיסים": {"ticket", "close", "transcript", "add", "remove"}, + "ניהול": {"warn", "warnings", "clearwarnings", "timeout", "kick", "ban", "unban", "clear", "lock", "unlock", "slowmode", "nick", "embed", "announce"}, + "הגדרות": {"setup", "settings", "reload", "sync", "debug"}, +} + + +class General(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + + @app_commands.command(name="help", description="הצגת הפקודות הזמינות") + async def help(self, interaction: discord.Interaction) -> None: + owner = bool(interaction.guild and interaction.user.id == interaction.guild.owner_id) + roots = {command.name for command in self.bot.tree.get_commands()} + lines = [] + for category, names in CATEGORIES.items(): + available = sorted(roots & names) + if not owner: + available = [name for name in available if name not in CATEGORIES["הגדרות"]] + if available: + lines.append(f"**{category}**\n" + " ".join(f"`/{name}`" for name in available)) + await interaction.response.send_message(embed=embed("📚 עזרה", "\n\n".join(lines), BLUE), ephemeral=True) + + @app_commands.command(name="ping", description="בדיקת זמן התגובה של הבוט") + async def ping(self, interaction: discord.Interaction) -> None: + await interaction.response.send_message(f"🏓 זמן תגובה: `{round(self.bot.latency * 1000)}ms`", ephemeral=True) + + @app_commands.command(name="botinfo", description="מידע על הבוט") + async def botinfo(self, interaction: discord.Interaction) -> None: + description = f"**שם:** {self.bot.user or 'EditIL Assistant'}\n**ספרייה:** discord.py `{discord.__version__}`\n**שרתים:** {len(self.bot.guilds)}\n**פקודות רשומות:** {self.bot.registered_command_count or len(self.bot.tree.get_commands())}" + await interaction.response.send_message(embed=embed("🤖 מידע על הבוט", description)) + + @app_commands.command(name="serverinfo", description="מידע על השרת") + @app_commands.guild_only() + async def serverinfo(self, interaction: discord.Interaction) -> None: + guild = interaction.guild + assert guild + description = f"**שם:** {guild.name}\n**בעלים:** <@{guild.owner_id}>\n**חברים:** {guild.member_count}\n**ערוצים:** {len(guild.channels)}\n**נוצר:** " + await interaction.response.send_message(embed=embed("ℹ️ מידע על השרת", description)) + + @app_commands.command(name="userinfo", description="מידע על משתמש") + @app_commands.guild_only() + async def userinfo(self, interaction: discord.Interaction, member: discord.Member | None = None) -> None: + member = member or interaction.user + assert isinstance(member, discord.Member) + description = f"**משתמש:** {member.mention}\n**מזהה:** `{member.id}`\n**הצטרף:** \n**נוצר:** " + await interaction.response.send_message(embed=embed("👤 מידע על משתמש", description)) + + @app_commands.command(name="avatar", description="הצגת תמונת פרופיל") + async def avatar(self, interaction: discord.Interaction, user: discord.User | None = None) -> None: + user = user or interaction.user + card = embed(f"🖼️ תמונת הפרופיל של {user}") + card.set_image(url=user.display_avatar.url) + await interaction.response.send_message(embed=card) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(General(bot)) diff --git a/editil_bot/cogs/levels.py b/editil_bot/cogs/levels.py new file mode 100644 index 0000000000..d6579bd48c --- /dev/null +++ b/editil_bot/cogs/levels.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from collections import defaultdict + +import discord +from discord import app_commands +from discord.ext import commands + +from ..embeds import PURPLE, embed, error, success + +REWARDS = {5: "🌱 עורך מתחיל", 15: "🎬 עורך", 30: "⭐ עורך מקצועי", 50: "💎 עורך אגדי"} + + +class Levels(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.cooldowns: dict[int, float] = defaultdict(float) + + @commands.Cog.listener() + async def on_message(self, message: discord.Message) -> None: + if not message.guild or message.author.bot: + return + now = discord.utils.utcnow().timestamp() + if now - self.cooldowns[message.author.id] < 45: + return + self.cooldowns[message.author.id] = now + # Replies are treated as helpful community participation and earn a + # small bonus. The cooldown keeps this from being farmable. + amount = 8 if message.reference else 5 + xp, level = await self.bot.db.add_xp(message.author.id, amount) + if level in REWARDS and xp % 100 < 5: + role = discord.utils.get(message.guild.roles, name=REWARDS[level][2:]) + if role and isinstance(message.author, discord.Member): + await message.author.add_roles(role, reason=f"רמת EditIL {level}") + await message.channel.send(f"🎉 {message.author.mention} הגיע/ה לרמה {level} וקיבל/ה **{REWARDS[level]}**!") + + + @app_commands.command(name="profile", description="הצגת פרופיל העורך") + async def profile(self, interaction: discord.Interaction, member: discord.Member | None = None) -> None: + member = member or interaction.user + row = await self.bot.db.fetchone("SELECT xp, edits, wins, software FROM profiles WHERE user_id = ?", (member.id,)) + xp, edits, wins, software = row or (0, 0, 0, "לא נבחר") + level = xp // 100 + description = (f"**שם משתמש:** {member.mention}\n**רמה:** {level} ({xp} XP)\n" + f"**תוכנה:** {software}\n**עריכות שפורסמו:** {edits}\n" + f"**ניצחונות בתחרויות:** {wins}\n**תאריך הצטרפות:** ") + await interaction.response.send_message(embed=embed("🎬 פרופיל עורך", description, PURPLE)) + + @app_commands.command(name="rank", description="הצגת הרמה וה־XP") + async def rank(self, interaction: discord.Interaction, member: discord.Member | None = None) -> None: + member = member or interaction.user + row = await self.bot.db.fetchone("SELECT xp FROM profiles WHERE user_id = ?", (member.id,)) + xp = row[0] if row else 0 + await interaction.response.send_message(embed=embed("📈 דירוג", f"{member.mention}\n**רמה:** {xp // 100}\n**XP:** {xp}\n**לרמה הבאה:** {100 - xp % 100}", PURPLE)) + + @app_commands.command(name="leaderboard", description="טבלת מובילי XP") + async def leaderboard(self, interaction: discord.Interaction) -> None: + rows = await self.bot.db.fetchall("SELECT user_id, xp FROM profiles ORDER BY xp DESC LIMIT 10") + lines = [f"**{index}.** <@{user_id}> — {xp} XP" for index, (user_id, xp) in enumerate(rows, 1)] + await interaction.response.send_message(embed=embed("🏅 טבלת מובילים", "\n".join(lines) or "עדיין אין נתוני XP.", PURPLE)) + + @app_commands.command(name="setxp", description="קביעת XP למשתמש") + @app_commands.checks.has_permissions(manage_guild=True) + async def setxp(self, interaction: discord.Interaction, member: discord.Member, amount: app_commands.Range[int, 0, 10_000_000]) -> None: + await self.bot.db.execute("INSERT OR IGNORE INTO profiles (user_id) VALUES (?)", (member.id,)) + await self.bot.db.execute("UPDATE profiles SET xp = ? WHERE user_id = ?", (amount, member.id)) + await interaction.response.send_message(embed=success(f"ה־XP של {member.mention} נקבע ל־{amount}."), ephemeral=True) + + @app_commands.command(name="resetxp", description="איפוס XP למשתמש") + @app_commands.checks.has_permissions(manage_guild=True) + async def resetxp(self, interaction: discord.Interaction, member: discord.Member) -> None: + await self.bot.db.execute("INSERT OR IGNORE INTO profiles (user_id) VALUES (?)", (member.id,)) + await self.bot.db.execute("UPDATE profiles SET xp = 0 WHERE user_id = ?", (member.id,)) + await interaction.response.send_message(embed=success(f"ה־XP של {member.mention} אופס."), ephemeral=True) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Levels(bot)) diff --git a/editil_bot/cogs/moderation.py b/editil_bot/cogs/moderation.py new file mode 100644 index 0000000000..9617373354 --- /dev/null +++ b/editil_bot/cogs/moderation.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import re +from collections import defaultdict, deque +from datetime import timedelta + +import discord +from discord import app_commands +from discord.ext import commands + +from ..embeds import error, success, embed, PURPLE +from ..logging import log +from ..permissions import target_is_manageable + +LINK = re.compile(r"https?://|discord(?:app)?\.com/invite/|discord\.gg/", re.I) + + +class Moderation(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.recent_messages: dict[int, deque[float]] = defaultdict(deque) + + async def _log(self, interaction: discord.Interaction, action: str, target: discord.Member | discord.User, reason: str) -> None: + assert interaction.guild + await log(interaction.guild, self.bot.settings.log_channel_id, f"🛡️ {action}", f"משתמש: {target.mention}\nמנהל: {interaction.user.mention}\nסיבה: {reason}") + + async def _manageable(self, interaction: discord.Interaction, member: discord.Member) -> bool: + assert interaction.guild and isinstance(interaction.user, discord.Member) + if target_is_manageable(interaction.user, member, interaction.guild.me): + return True + await interaction.response.send_message(embed=error("לא ניתן לבצע פעולה על בעל השרת, מנהל מערכת, משתמש בדרגה שווה או גבוהה, או משתמש שמעל תפקיד הבוט."), ephemeral=True) + return False + + @app_commands.command(name="warn", description="מתן אזהרה למשתמש") + @app_commands.checks.has_permissions(moderate_members=True) + async def warn(self, interaction: discord.Interaction, member: discord.Member, reason: str) -> None: + if not await self._manageable(interaction, member): return + await self.bot.db.execute("INSERT INTO warnings (guild_id, user_id, moderator_id, reason) VALUES (?, ?, ?, ?)", (interaction.guild_id, member.id, interaction.user.id, reason)) + await interaction.response.send_message(embed=success(f"{member.mention} קיבל/ה אזהרה.\nסיבה: {reason}"), ephemeral=True) + await self._log(interaction, "אזהרה", member, reason) + + @app_commands.command(name="timeout", description="השתקת משתמש לזמן מוגבל") + @app_commands.checks.has_permissions(moderate_members=True) + async def timeout(self, interaction: discord.Interaction, member: discord.Member, minutes: app_commands.Range[int, 1, 40320], reason: str = "לא צוינה") -> None: + if not await self._manageable(interaction, member): return + await member.timeout(timedelta(minutes=minutes), reason=reason) + await interaction.response.send_message(embed=success(f"{member.mention} הושתק/ה ל־{minutes} דקות."), ephemeral=True) + await self._log(interaction, "Timeout", member, reason) + + @app_commands.command(name="kick", description="הסרת משתמש מהשרת") + @app_commands.checks.has_permissions(kick_members=True) + async def kick(self, interaction: discord.Interaction, member: discord.Member, reason: str = "לא צוינה") -> None: + if not await self._manageable(interaction, member): return + await member.kick(reason=reason) + await interaction.response.send_message(embed=success(f"{member} הוסר/ה מהשרת."), ephemeral=True) + await self._log(interaction, "Kick", member, reason) + + @app_commands.command(name="ban", description="חסימת משתמש מהשרת") + @app_commands.checks.has_permissions(ban_members=True) + async def ban(self, interaction: discord.Interaction, member: discord.Member, reason: str = "לא צוינה") -> None: + if not await self._manageable(interaction, member): return + await member.ban(reason=reason) + await interaction.response.send_message(embed=success(f"{member} נחסם/ה מהשרת."), ephemeral=True) + await self._log(interaction, "Ban", member, reason) + + @app_commands.command(name="clear", description="מחיקת הודעות מערוץ") + @app_commands.checks.has_permissions(manage_messages=True) + async def clear(self, interaction: discord.Interaction, amount: app_commands.Range[int, 1, 100]) -> None: + assert isinstance(interaction.channel, discord.TextChannel) + await interaction.response.defer(ephemeral=True) + deleted = await interaction.channel.purge(limit=amount) + await interaction.followup.send(embed=success(f"נמחקו {len(deleted)} הודעות."), ephemeral=True) + await log(interaction.guild, self.bot.settings.log_channel_id, "🧹 ניקוי הודעות", f"{interaction.user.mention} מחק/ה {len(deleted)} הודעות ב־{interaction.channel.mention}.") + + @app_commands.command(name="warnings", description="הצגת אזהרות למשתמש") + @app_commands.checks.has_permissions(moderate_members=True) + async def warnings(self, interaction: discord.Interaction, member: discord.Member) -> None: + rows = await self.bot.db.fetchall("SELECT id, moderator_id, reason, created_at FROM warnings WHERE guild_id = ? AND user_id = ? ORDER BY id DESC LIMIT 20", (interaction.guild_id, member.id)) + lines = [f"`#{row[0]}` — {row[2]} (<@{row[1]}>, {row[3]})" for row in rows] + await interaction.response.send_message(embed=embed(f"⚠️ אזהרות של {member}", "\n".join(lines) or "אין אזהרות.", PURPLE), ephemeral=True) + + @app_commands.command(name="clearwarnings", description="מחיקת אזהרות של משתמש") + @app_commands.checks.has_permissions(manage_guild=True) + async def clearwarnings(self, interaction: discord.Interaction, member: discord.Member) -> None: + cursor = await self.bot.db.execute("DELETE FROM warnings WHERE guild_id = ? AND user_id = ?", (interaction.guild_id, member.id)) + await interaction.response.send_message(embed=success(f"נמחקו {cursor.rowcount} אזהרות של {member.mention}."), ephemeral=True) + + @app_commands.command(name="unban", description="הסרת חסימה לפי מזהה משתמש") + @app_commands.checks.has_permissions(ban_members=True) + async def unban(self, interaction: discord.Interaction, user_id: str, reason: str = "לא צוינה") -> None: + if not user_id.isdigit(): + await interaction.response.send_message(embed=error("מזהה המשתמש אינו תקין."), ephemeral=True); return + user = await self.bot.fetch_user(int(user_id)) + await interaction.guild.unban(user, reason=reason) + await interaction.response.send_message(embed=success(f"החסימה של {user} הוסרה."), ephemeral=True) + + @app_commands.command(name="lock", description="נעילת הערוץ") + @app_commands.checks.has_permissions(manage_channels=True) + async def lock(self, interaction: discord.Interaction) -> None: + await interaction.response.defer(ephemeral=True) + await interaction.channel.set_permissions(interaction.guild.default_role, send_messages=False, reason=f"Locked by {interaction.user}") + await interaction.followup.send(embed=success("הערוץ ננעל."), ephemeral=True) + + @app_commands.command(name="unlock", description="פתיחת הערוץ") + @app_commands.checks.has_permissions(manage_channels=True) + async def unlock(self, interaction: discord.Interaction) -> None: + await interaction.response.defer(ephemeral=True) + await interaction.channel.set_permissions(interaction.guild.default_role, send_messages=None, reason=f"Unlocked by {interaction.user}") + await interaction.followup.send(embed=success("הערוץ נפתח."), ephemeral=True) + + @app_commands.command(name="slowmode", description="הגדרת מצב איטי בערוץ") + @app_commands.checks.has_permissions(manage_channels=True) + async def slowmode(self, interaction: discord.Interaction, seconds: app_commands.Range[int, 0, 21600]) -> None: + await interaction.response.defer(ephemeral=True) + await interaction.channel.edit(slowmode_delay=seconds, reason=f"Changed by {interaction.user}") + await interaction.followup.send(embed=success(f"מצב איטי נקבע ל־{seconds} שניות."), ephemeral=True) + + @app_commands.command(name="nick", description="שינוי כינוי למשתמש") + @app_commands.checks.has_permissions(manage_nicknames=True) + async def nick(self, interaction: discord.Interaction, member: discord.Member, nickname: app_commands.Range[str, 1, 32] | None = None) -> None: + if not await self._manageable(interaction, member): return + await member.edit(nick=nickname, reason=f"Changed by {interaction.user}") + await interaction.response.send_message(embed=success("הכינוי עודכן."), ephemeral=True) + + @commands.Cog.listener() + async def on_message(self, message: discord.Message) -> None: + if not message.guild or message.author.bot or not isinstance(message.author, discord.Member): + return + now = discord.utils.utcnow().timestamp() + queue = self.recent_messages[message.author.id] + queue.append(now) + while queue and now - queue[0] > 8: + queue.popleft() + if len(queue) >= 6: + await message.author.timeout(timedelta(minutes=5), reason="אנטי-ספאם") + queue.clear() + await log(message.guild, self.bot.settings.log_channel_id, "🚫 אנטי-ספאם", f"{message.author.mention} הושתק/ה אוטומטית ל־5 דקות.") + return + content = message.content.casefold() + if any(word in content for word in self.bot.settings.bad_words) or (LINK.search(message.content) and message.channel.id not in self.bot.settings.allowed_link_channels and not message.author.guild_permissions.manage_messages): + await message.delete() + await message.channel.send(f"{message.author.mention} ההודעה הוסרה לפי כללי הקהילה.", delete_after=5) + await log(message.guild, self.bot.settings.log_channel_id, "🛡️ סינון אוטומטי", f"הודעה של {message.author.mention} הוסרה ב־{message.channel.mention}.") + + @warn.error + @timeout.error + @kick.error + @ban.error + @clear.error + async def permissions_error(self, interaction: discord.Interaction, _: app_commands.AppCommandError) -> None: + if not interaction.response.is_done(): + await interaction.response.send_message(embed=error("אין לך את ההרשאה הדרושה לפעולה זו."), ephemeral=True) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Moderation(bot)) diff --git a/editil_bot/cogs/owner.py b/editil_bot/cogs/owner.py new file mode 100644 index 0000000000..bf81794825 --- /dev/null +++ b/editil_bot/cogs/owner.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import logging +from typing import Literal + +import discord +from discord import app_commands +from discord.ext import commands + +from ..embeds import PURPLE, embed, error, success +from ..permissions import server_owner_only + +logger = logging.getLogger(__name__) + +CHANNEL_KEYS = { + "welcome": "welcome_channel_id", "rules": "rules_channel_id", "verification": "verification_channel_id", + "logs": "log_channel_id", "suggestions": "suggestions_channel_id", "reports": "reports_channel_id", + "levels": "level_channel_id", "commands": "commands_channel_id", +} +ROLE_KEYS = { + "verified": "verified_role_id", "helper": "helper_role_id", "moderator": "moderator_role_id", + "administrator": "administrator_role_id", "ticket_staff": "ticket_staff_role_id", + "booster": "booster_role_id", "level_reward": "level_reward_role_id", +} +MODULES = {"moderation", "leveling", "welcome", "verification", "tickets", "suggestions", "reports", "contests", "role_panels", "automod"} + + +class ResetView(discord.ui.View): + def __init__(self, bot: commands.Bot, owner_id: int, guild_id: int): + super().__init__(timeout=120) + self.bot, self.owner_id, self.guild_id = bot, owner_id, guild_id + + @discord.ui.button(label="אישור איפוס הגדרות", style=discord.ButtonStyle.danger) + async def confirm(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: + if interaction.user.id != self.owner_id: + await interaction.response.send_message(embed=error("רק בעל השרת יכול לאשר פעולה זו."), ephemeral=True) + return + await self.bot.db.reset_guild_settings(self.guild_id) + button.disabled = True + await interaction.response.edit_message(embed=success("ההגדרות אופסו. נתוני אזהרות, XP, תחרויות וכרטיסים נשמרו."), view=self) + + +class Owner(commands.Cog): + settings_group = app_commands.Group(name="settings", description="ניהול הגדרות EditIL") + + def __init__(self, bot: commands.Bot): self.bot = bot + + @app_commands.command(name="setup", description="התחלת הגדרת הבוט") + @server_owner_only() + async def setup_command(self, interaction: discord.Interaction) -> None: + await interaction.response.send_message(embed=embed("⚙️ הגדרה ראשונית", "השתמשו ב־`/settings channel` וב־`/settings role` עם בוחרי Discord, לאחר מכן בדקו את התוצאה ב־`/settings view`. ההגדרות נשמרות רק לאחר כל בחירה מפורשת ואינן יוצרות ערוצים או תפקידים כפולים.", PURPLE), ephemeral=True) + + @settings_group.command(name="view", description="הצגת ההגדרות הנוכחיות") + @server_owner_only() + async def settings_view(self, interaction: discord.Interaction) -> None: + values = await self.bot.db.get_guild_settings(interaction.guild_id) + items = [] + for label, key in {**CHANNEL_KEYS, **ROLE_KEYS}.items(): + value = int(values.get(key, 0) or 0) + mention = f"<#{value}>" if key.endswith("channel_id") else f"<@&{value}>" + items.append(f"**{label}:** {mention if value else 'לא הוגדר'}") + modules = values.get("modules", {}) + items.append("**מודולים:** " + (", ".join(f"{k}={'פעיל' if v else 'כבוי'}" for k, v in modules.items()) or "ברירת מחדל")) + items.append(f"**XP:** {values.get('xp_min', 5)}–{values.get('xp_max', 8)}, השהיה {values.get('xp_cooldown', 45)} שניות") + await interaction.response.send_message(embed=embed("⚙️ הגדרות השרת", "\n".join(items), PURPLE), ephemeral=True) + + @settings_group.command(name="channel", description="הגדרת ערוץ") + @server_owner_only() + async def settings_channel(self, interaction: discord.Interaction, setting: Literal["welcome", "rules", "verification", "logs", "suggestions", "reports", "levels", "commands"], channel: discord.TextChannel) -> None: + await self.bot.db.set_guild_setting(interaction.guild_id, CHANNEL_KEYS[setting], channel.id) + await interaction.response.send_message(embed=success(f"הערוץ `{setting}` הוגדר ל־{channel.mention}."), ephemeral=True) + + @settings_group.command(name="role", description="הגדרת תפקיד") + @server_owner_only() + async def settings_role(self, interaction: discord.Interaction, setting: Literal["verified", "helper", "moderator", "administrator", "ticket_staff", "booster", "level_reward"], role: discord.Role) -> None: + bot_member = interaction.guild.me + if role >= bot_member.top_role: + await interaction.response.send_message(embed=error("תפקיד הבוט נמוך מדי בהיררכיית התפקידים."), ephemeral=True) + return + await self.bot.db.set_guild_setting(interaction.guild_id, ROLE_KEYS[setting], role.id) + await interaction.response.send_message(embed=success(f"התפקיד `{setting}` הוגדר ל־{role.mention}."), ephemeral=True) + + @settings_group.command(name="module", description="הפעלה או השבתה של מודול") + @server_owner_only() + async def settings_module(self, interaction: discord.Interaction, module: str, enabled: bool) -> None: + module = module.casefold() + if module not in MODULES: + await interaction.response.send_message(embed=error("שם המודול אינו תקין."), ephemeral=True); return + modules = await self.bot.db.get_guild_setting(interaction.guild_id, "modules", {}) + modules[module] = enabled + await self.bot.db.set_guild_setting(interaction.guild_id, "modules", modules) + await interaction.response.send_message(embed=success(f"המודול `{module}` {'הופעל' if enabled else 'הושבת'}. הנתונים שלו לא נמחקו."), ephemeral=True) + + @settings_group.command(name="levels", description="הגדרת צבירת XP") + @server_owner_only() + async def settings_levels(self, interaction: discord.Interaction, minimum: app_commands.Range[int, 0, 100], maximum: app_commands.Range[int, 1, 100], cooldown: app_commands.Range[int, 5, 3600]) -> None: + if minimum > maximum: + await interaction.response.send_message(embed=error("ערך ה־XP המינימלי לא יכול להיות גדול מהמקסימלי."), ephemeral=True); return + for key, value in (("xp_min", minimum), ("xp_max", maximum), ("xp_cooldown", cooldown)): + await self.bot.db.set_guild_setting(interaction.guild_id, key, value) + await interaction.response.send_message(embed=success("הגדרות הרמות עודכנו."), ephemeral=True) + + @settings_group.command(name="command", description="הגדרת פקודה בודדת") + @server_owner_only() + async def settings_command(self, interaction: discord.Interaction, command: str, enabled: bool, cooldown: app_commands.Range[int, 0, 3600] = 0) -> None: + command = command.lstrip("/").casefold() + if command in {"settings", "help", "debug"} and not enabled: + await interaction.response.send_message(embed=error("לא ניתן להשבית פקודת שחזור חיונית."), ephemeral=True); return + config = await self.bot.db.get_guild_setting(interaction.guild_id, "commands", {}) + config[command] = {"enabled": enabled, "cooldown": cooldown} + await self.bot.db.set_guild_setting(interaction.guild_id, "commands", config) + await interaction.response.send_message(embed=success(f"הגדרת `/{command}` נשמרה."), ephemeral=True) + + @settings_group.command(name="reset", description="איפוס הגדרות השרת") + @server_owner_only() + async def settings_reset(self, interaction: discord.Interaction) -> None: + await interaction.response.send_message(embed=error("האיפוס מוחק רק הגדרות. נתוני אזהרות, XP, תחרויות, תמלילים וכרטיסים יישמרו. אשרו כדי להמשיך."), view=ResetView(self.bot, interaction.user.id, interaction.guild_id), ephemeral=True) + + @app_commands.command(name="sync", description="סנכרון פקודות לשרת הנוכחי") + @server_owner_only() + async def sync(self, interaction: discord.Interaction) -> None: + await interaction.response.defer(ephemeral=True) + self.bot.validate_command_tree() + guild = discord.Object(interaction.guild_id) + self.bot.tree.copy_global_to(guild=guild) + synced = await self.bot.tree.sync(guild=guild) + self.bot.registered_command_count = len(synced) + await interaction.followup.send(embed=success(f"נרשמו {len(synced)} פקודות לשרת. סנכרון לשרת מופיע בדרך כלל מהר יותר מסנכרון גלובלי."), ephemeral=True) + + @app_commands.command(name="reload", description="טעינה מחדש של מודול") + @server_owner_only() + async def reload(self, interaction: discord.Interaction, module: str) -> None: + extension = f"editil_bot.cogs.{module.removesuffix('.py')}" + if extension == __name__: + await interaction.response.send_message(embed=error("לא ניתן לטעון מחדש את מודול הבעלים מתוך הפקודה עצמה."), ephemeral=True); return + await interaction.response.defer(ephemeral=True) + try: + await self.bot.reload_extension(extension) + except Exception: + logger.exception("Failed to reload extension %s", extension) + await interaction.followup.send(embed=error("טעינת המודול נכשלה. הפרטים המלאים נרשמו במסוף."), ephemeral=True); return + await interaction.followup.send(embed=success(f"המודול `{module}` נטען מחדש."), ephemeral=True) + + @app_commands.command(name="debug", description="בדיקת תקינות הבוט") + @server_owner_only() + async def debug(self, interaction: discord.Interaction) -> None: + guild = interaction.guild + values = await self.bot.db.get_guild_settings(interaction.guild_id) + missing_channels = [key for key in CHANNEL_KEYS.values() if not values.get(key)] + missing_roles = [key for key in ROLE_KEYS.values() if not values.get(key)] + me = guild.me + needed = [name for name in ("send_messages", "embed_links", "read_message_history") if not getattr(me.guild_permissions, name)] + description = f"**Latency:** {round(self.bot.latency * 1000)}ms\n**Database:** connected\n**Loaded modules:** {len(self.bot.loaded_modules)}\n**Failed modules:** {len(self.bot.failed_modules)}\n**Registered commands:** {self.bot.registered_command_count or len(self.bot.tree.get_commands())}\n**Missing channels:** {', '.join(missing_channels) or 'none'}\n**Missing roles:** {', '.join(missing_roles) or 'none'}\n**Missing permissions:** {', '.join(needed) or 'none'}\n**Last error:** {self.bot.last_error_reference or 'none'}" + await interaction.response.send_message(embed=embed("🧪 Debug", description, PURPLE), ephemeral=True) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Owner(bot)) diff --git a/editil_bot/cogs/showcase.py b/editil_bot/cogs/showcase.py new file mode 100644 index 0000000000..e682689d4e --- /dev/null +++ b/editil_bot/cogs/showcase.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from urllib.parse import urlparse + +import discord +from discord import app_commands +from discord.ext import commands + +from ..embeds import PURPLE, embed + + +class Showcase(commands.Cog): + def __init__(self, bot: commands.Bot): self.bot = bot + + @app_commands.command(name="showcase", description="פרסום עריכה חדשה ב־Showcase") + @app_commands.describe(software="תוכנת העריכה", category="קטגוריית היצירה", link="קישור לעריכה") + async def showcase(self, interaction: discord.Interaction, software: str, category: str, link: str) -> None: + parsed = urlparse(link) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + await interaction.response.send_message("⚠️ יש לשלוח קישור תקין שמתחיל ב־https:// או http://.", ephemeral=True) + return + description = f"**יוצר/ת:** {interaction.user.mention}\n**תוכנה:** {software}\n**קטגוריה:** {category}\n**צפייה:** [לחצו כאן]({link})\n\n**לייקים:** ❤️ 0" + await interaction.response.send_message(embed=embed("🎬 עריכה חדשה", description, PURPLE)) + message = await interaction.original_response() + await message.add_reaction("❤️") + await self.bot.db.execute("INSERT OR IGNORE INTO profiles (user_id) VALUES (?)", (interaction.user.id,)) + await self.bot.db.execute("UPDATE profiles SET edits = edits + 1, software = ? WHERE user_id = ?", (software, interaction.user.id)) + await self.bot.db.add_xp(interaction.user.id, 15) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Showcase(bot)) diff --git a/editil_bot/cogs/tickets.py b/editil_bot/cogs/tickets.py new file mode 100644 index 0000000000..8bc4ceb26c --- /dev/null +++ b/editil_bot/cogs/tickets.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import io + +import discord +from discord import app_commands +from discord.ext import commands + +from ..embeds import PURPLE, embed, error, success +from ..logging import log + + +class TicketView(discord.ui.View): + def __init__(self, cog: "Tickets"): + super().__init__(timeout=None) + self.cog = cog + + async def open_ticket(self, interaction: discord.Interaction, kind: str) -> None: + if not interaction.guild or not isinstance(interaction.user, discord.Member): + return + s = self.cog.bot.settings + category = interaction.guild.get_channel(s.ticket_category_id) + if not isinstance(category, discord.CategoryChannel): + await interaction.response.send_message(embed=error("קטגוריית הכרטיסים לא הוגדרה."), ephemeral=True) + return + existing = await self.cog.bot.db.fetchone("SELECT channel_id FROM tickets WHERE guild_id = ? AND opener_id = ? AND status = 'open'", (interaction.guild.id, interaction.user.id)) + if existing: + await interaction.response.send_message("כבר פתוח עבורך כרטיס פעיל.", ephemeral=True) + return + overwrites = {interaction.guild.default_role: discord.PermissionOverwrite(view_channel=False), interaction.user: discord.PermissionOverwrite(view_channel=True, send_messages=True, read_message_history=True)} + staff = interaction.guild.get_role(s.ticket_staff_role_id) + if staff: + overwrites[staff] = discord.PermissionOverwrite(view_channel=True, send_messages=True, read_message_history=True) + channel = await interaction.guild.create_text_channel(f"{kind.lower()}-{interaction.user.name}"[:90], category=category, overwrites=overwrites, topic=f"Ticket owner: {interaction.user.id}") + await self.cog.bot.db.execute("INSERT INTO tickets (channel_id, guild_id, opener_id, type) VALUES (?, ?, ?, ?)", (channel.id, interaction.guild.id, interaction.user.id, kind)) + await channel.send(f"{interaction.user.mention} | <@&{s.ticket_staff_role_id}>" if s.ticket_staff_role_id else interaction.user.mention, embed=embed(f"{kind} | EditIL", "צוות הקהילה יענה בהקדם. פרטו את הבקשה בצורה ברורה.", PURPLE), view=CloseTicketView(self.cog)) + await interaction.response.send_message(embed=success(f"הכרטיס נפתח: {channel.mention}"), ephemeral=True) + await log(interaction.guild, s.log_channel_id, "🎫 כרטיס חדש", f"{interaction.user.mention} פתח/ה כרטיס מסוג {kind}: {channel.mention}") + + @discord.ui.button(label="עזרה", emoji="🎫", style=discord.ButtonStyle.primary, custom_id="editil:ticket:help") + async def help(self, interaction: discord.Interaction, _: discord.ui.Button) -> None: await self.open_ticket(interaction, "Help") + @discord.ui.button(label="דיווח", emoji="🚨", style=discord.ButtonStyle.danger, custom_id="editil:ticket:report") + async def report(self, interaction: discord.Interaction, _: discord.ui.Button) -> None: await self.open_ticket(interaction, "Report") + @discord.ui.button(label="שיתוף פעולה", emoji="💼", style=discord.ButtonStyle.secondary, custom_id="editil:ticket:partnership") + async def partnership(self, interaction: discord.Interaction, _: discord.ui.Button) -> None: await self.open_ticket(interaction, "Partnership") + @discord.ui.button(label="דיווח באג", emoji="🛠️", style=discord.ButtonStyle.secondary, custom_id="editil:ticket:bug") + async def bug(self, interaction: discord.Interaction, _: discord.ui.Button) -> None: await self.open_ticket(interaction, "Bug") + + +class CloseTicketView(discord.ui.View): + def __init__(self, cog: "Tickets"): + super().__init__(timeout=None) + self.cog = cog + + @discord.ui.button(label="סגירת כרטיס", emoji="🔒", style=discord.ButtonStyle.danger, custom_id="editil:ticket:close") + async def close(self, interaction: discord.Interaction, _: discord.ui.Button) -> None: + await self.cog.close_ticket(interaction) + + +class Tickets(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + bot.add_view(TicketView(self)) + bot.add_view(CloseTicketView(self)) + + async def _ticket_row(self, interaction: discord.Interaction): + if not interaction.channel: + return None + return await self.bot.db.fetchone("SELECT opener_id FROM tickets WHERE channel_id = ? AND status = 'open'", (interaction.channel.id,)) + + async def _is_staff(self, interaction: discord.Interaction) -> bool: + if not interaction.guild or not isinstance(interaction.user, discord.Member): return False + role_id = int(await self.bot.db.get_guild_setting(interaction.guild_id, "ticket_staff_role_id", self.bot.settings.ticket_staff_role_id) or 0) + return interaction.user.guild_permissions.manage_channels or bool(role_id and interaction.user.get_role(role_id)) + + async def close_ticket(self, interaction: discord.Interaction) -> None: + if not isinstance(interaction.channel, discord.TextChannel) or not interaction.guild: + return + row = await self._ticket_row(interaction) + if not row: + await interaction.response.send_message("זה אינו כרטיס פעיל.", ephemeral=True) + return + if interaction.user.id != row[0] and not await self._is_staff(interaction): + await interaction.response.send_message(embed=error("רק פותח הכרטיס או הצוות יכולים לסגור אותו."), ephemeral=True) + return + await interaction.response.defer() + messages = [f"[{m.created_at:%Y-%m-%d %H:%M}] {m.author}: {m.clean_content}" async for m in interaction.channel.history(limit=None, oldest_first=True)] + content = "\n".join(messages) + transcript = discord.File(io.BytesIO(content.encode("utf-8")), filename=f"ticket-{interaction.channel.id}.txt") + log_id = int(await self.bot.db.get_guild_setting(interaction.guild_id, "log_channel_id", self.bot.settings.log_channel_id) or 0) + log_channel = interaction.guild.get_channel(log_id) + if isinstance(log_channel, discord.TextChannel): + await log_channel.send(embed=embed("🔒 כרטיס נסגר", f"נסגר על ידי {interaction.user.mention}.\nערוץ: {interaction.channel.name}", PURPLE), file=transcript) + await self.bot.db.execute("INSERT OR REPLACE INTO ticket_transcripts (channel_id, guild_id, closed_by, content) VALUES (?, ?, ?, ?)", (interaction.channel.id, interaction.guild.id, interaction.user.id, content)) + await self.bot.db.execute("UPDATE tickets SET status = 'closed' WHERE channel_id = ?", (interaction.channel.id,)) + await interaction.channel.delete(reason=f"Ticket closed by {interaction.user}") + + @app_commands.command(name="ticket", description="פתיחת לוח כרטיסים") + async def ticket(self, interaction: discord.Interaction) -> None: + await interaction.response.send_message(embed=embed("🎫 פתיחת כרטיס", "בחרו את סוג הכרטיס המבוקש.", PURPLE), view=TicketView(self), ephemeral=True) + + @app_commands.command(name="close", description="סגירת הכרטיס הנוכחי") + async def close_command(self, interaction: discord.Interaction) -> None: + await self.close_ticket(interaction) + + @app_commands.command(name="transcript", description="יצירת תמליל של הכרטיס") + async def transcript(self, interaction: discord.Interaction) -> None: + if not await self._ticket_row(interaction) or not await self._is_staff(interaction): + await interaction.response.send_message(embed=error("הפקודה זמינה לצוות בתוך כרטיס פעיל בלבד."), ephemeral=True); return + await interaction.response.defer(ephemeral=True) + messages = [f"[{m.created_at:%Y-%m-%d %H:%M}] {m.author}: {m.clean_content}" async for m in interaction.channel.history(limit=None, oldest_first=True)] + file = discord.File(io.BytesIO("\n".join(messages).encode("utf-8")), filename=f"ticket-{interaction.channel.id}.txt") + await interaction.followup.send(file=file, ephemeral=True) + + @app_commands.command(name="add", description="הוספת משתמש לכרטיס") + async def add(self, interaction: discord.Interaction, member: discord.Member) -> None: + if not await self._ticket_row(interaction) or not await self._is_staff(interaction): + await interaction.response.send_message(embed=error("הפקודה זמינה לצוות בתוך כרטיס פעיל בלבד."), ephemeral=True); return + await interaction.channel.set_permissions(member, view_channel=True, send_messages=True, read_message_history=True) + await self.bot.db.execute("INSERT OR IGNORE INTO ticket_members (channel_id, user_id) VALUES (?, ?)", (interaction.channel.id, member.id)) + await interaction.response.send_message(embed=success(f"{member.mention} נוסף לכרטיס."), ephemeral=True) + + @app_commands.command(name="remove", description="הסרת משתמש מכרטיס") + async def remove(self, interaction: discord.Interaction, member: discord.Member) -> None: + if not await self._ticket_row(interaction) or not await self._is_staff(interaction): + await interaction.response.send_message(embed=error("הפקודה זמינה לצוות בתוך כרטיס פעיל בלבד."), ephemeral=True); return + await interaction.channel.set_permissions(member, overwrite=None) + await self.bot.db.execute("DELETE FROM ticket_members WHERE channel_id = ? AND user_id = ?", (interaction.channel.id, member.id)) + await interaction.response.send_message(embed=success(f"{member.mention} הוסר מהכרטיס."), ephemeral=True) + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Tickets(bot)) diff --git a/editil_bot/cogs/utility.py b/editil_bot/cogs/utility.py new file mode 100644 index 0000000000..9222aea153 --- /dev/null +++ b/editil_bot/cogs/utility.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import discord +from discord import app_commands +from discord.ext import commands + +from ..embeds import PURPLE, embed, success + + +class Utility(commands.Cog): + def __init__(self, bot: commands.Bot): self.bot = bot + + @app_commands.command(name="embed", description="שליחת הודעת Embed") + @app_commands.checks.has_permissions(manage_guild=True) + async def embed_command(self, interaction: discord.Interaction, channel: discord.TextChannel, title: str, description: str) -> None: + await interaction.response.defer(ephemeral=True) + await channel.send(embed=embed(title, description, PURPLE)) + await interaction.followup.send(embed=success(f"ההודעה נשלחה ל־{channel.mention}."), ephemeral=True) + + @app_commands.command(name="announce", description="פרסום הודעה רשמית") + @app_commands.checks.has_permissions(manage_guild=True) + async def announce(self, interaction: discord.Interaction, channel: discord.TextChannel, message: str) -> None: + await interaction.response.defer(ephemeral=True) + sent = await channel.send(embed=embed("📣 הודעה", message, PURPLE)) + await interaction.followup.send(embed=success(f"ההודעה פורסמה: {sent.jump_url}"), ephemeral=True) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Utility(bot)) diff --git a/editil_bot/config.py b/editil_bot/config.py new file mode 100644 index 0000000000..77d1f4caeb --- /dev/null +++ b/editil_bot/config.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass + + +def _integer(name: str, default: int = 0) -> int: + try: + return int(os.getenv(name, default)) + except ValueError: + return default + + +@dataclass(frozen=True) +class Settings: + token: str + guild_id: int + welcome_channel_id: int + rules_channel_id: int + log_channel_id: int + ticket_category_id: int + ticket_staff_role_id: int + new_member_role_id: int + member_role_id: int + booster_role_id: int + booster_channel_id: int + winners_channel_id: int + allowed_link_channels: set[int] + bad_words: set[str] + + @classmethod + def from_environment(cls) -> "Settings": + links = {int(value) for value in os.getenv("LINKS_ALLOWED_CHANNEL_IDS", "").split(",") if value.strip().isdigit()} + words = {word.strip().casefold() for word in os.getenv("BAD_WORDS", "").split(",") if word.strip()} + return cls( + token=os.getenv("DISCORD_TOKEN", ""), guild_id=_integer("GUILD_ID"), + welcome_channel_id=_integer("WELCOME_CHANNEL_ID"), rules_channel_id=_integer("RULES_CHANNEL_ID"), + log_channel_id=_integer("LOG_CHANNEL_ID"), ticket_category_id=_integer("TICKET_CATEGORY_ID"), + ticket_staff_role_id=_integer("TICKET_STAFF_ROLE_ID"), new_member_role_id=_integer("NEW_MEMBER_ROLE_ID"), + member_role_id=_integer("MEMBER_ROLE_ID"), booster_role_id=_integer("BOOSTER_ROLE_ID"), + booster_channel_id=_integer("BOOSTER_CHANNEL_ID"), winners_channel_id=_integer("WINNERS_CHANNEL_ID"), + allowed_link_channels=links, bad_words=words, + ) diff --git a/editil_bot/database.py b/editil_bot/database.py new file mode 100644 index 0000000000..b78d29dd4a --- /dev/null +++ b/editil_bot/database.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import json + +import aiosqlite + + +class Database: + def __init__(self, path: str = "editil.db"): + self.path = path + self.connection: aiosqlite.Connection | None = None + + async def connect(self) -> None: + self.connection = await aiosqlite.connect(self.path) + await self.connection.executescript(""" + PRAGMA journal_mode=WAL; + CREATE TABLE IF NOT EXISTS profiles (user_id INTEGER PRIMARY KEY, xp INTEGER NOT NULL DEFAULT 0, edits INTEGER NOT NULL DEFAULT 0, wins INTEGER NOT NULL DEFAULT 0, software TEXT NOT NULL DEFAULT 'לא נבחר'); + CREATE TABLE IF NOT EXISTS warnings (id INTEGER PRIMARY KEY AUTOINCREMENT, guild_id INTEGER, user_id INTEGER, moderator_id INTEGER, reason TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP); + CREATE TABLE IF NOT EXISTS tickets (channel_id INTEGER PRIMARY KEY, guild_id INTEGER, opener_id INTEGER, type TEXT, status TEXT DEFAULT 'open', created_at TEXT DEFAULT CURRENT_TIMESTAMP); + CREATE TABLE IF NOT EXISTS contests (id INTEGER PRIMARY KEY AUTOINCREMENT, guild_id INTEGER, title TEXT, description TEXT, ends_at INTEGER, active INTEGER DEFAULT 1); + CREATE TABLE IF NOT EXISTS submissions (id INTEGER PRIMARY KEY AUTOINCREMENT, contest_id INTEGER, user_id INTEGER, url TEXT, UNIQUE(contest_id, user_id)); + CREATE TABLE IF NOT EXISTS votes (contest_id INTEGER, voter_id INTEGER, submission_id INTEGER, UNIQUE(contest_id, voter_id)); + CREATE TABLE IF NOT EXISTS guild_settings ( + guild_id INTEGER NOT NULL, + setting_key TEXT NOT NULL, + setting_value TEXT NOT NULL, + PRIMARY KEY (guild_id, setting_key) + ); + CREATE TABLE IF NOT EXISTS ticket_members ( + channel_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + PRIMARY KEY (channel_id, user_id) + ); + CREATE TABLE IF NOT EXISTS ticket_transcripts ( + channel_id INTEGER PRIMARY KEY, + guild_id INTEGER NOT NULL, + closed_by INTEGER NOT NULL, + content TEXT NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + """) + await self.connection.commit() + + async def close(self) -> None: + if self.connection: + await self.connection.close() + + async def execute(self, query: str, values: tuple = ()) -> aiosqlite.Cursor: + assert self.connection + cursor = await self.connection.execute(query, values) + await self.connection.commit() + return cursor + + async def fetchone(self, query: str, values: tuple = ()): + assert self.connection + cursor = await self.connection.execute(query, values) + return await cursor.fetchone() + + async def fetchall(self, query: str, values: tuple = ()): + assert self.connection + cursor = await self.connection.execute(query, values) + return await cursor.fetchall() + + async def get_guild_settings(self, guild_id: int) -> dict[str, object]: + rows = await self.fetchall( + "SELECT setting_key, setting_value FROM guild_settings WHERE guild_id = ?", + (guild_id,), + ) + result: dict[str, object] = {} + for key, value in rows: + try: + result[key] = json.loads(value) + except (TypeError, json.JSONDecodeError): + result[key] = value + return result + + async def get_guild_setting(self, guild_id: int, key: str, default=None): + row = await self.fetchone( + "SELECT setting_value FROM guild_settings WHERE guild_id = ? AND setting_key = ?", + (guild_id, key), + ) + if row is None: + return default + try: + return json.loads(row[0]) + except (TypeError, json.JSONDecodeError): + return row[0] + + async def set_guild_setting(self, guild_id: int, key: str, value: object) -> None: + await self.execute( + """INSERT INTO guild_settings (guild_id, setting_key, setting_value) + VALUES (?, ?, ?) + ON CONFLICT(guild_id, setting_key) + DO UPDATE SET setting_value = excluded.setting_value""", + (guild_id, key, json.dumps(value, ensure_ascii=False)), + ) + + async def reset_guild_settings(self, guild_id: int) -> None: + await self.execute("DELETE FROM guild_settings WHERE guild_id = ?", (guild_id,)) + + async def add_xp(self, user_id: int, amount: int = 5) -> tuple[int, int]: + await self.execute("INSERT OR IGNORE INTO profiles (user_id) VALUES (?)", (user_id,)) + await self.execute("UPDATE profiles SET xp = xp + ? WHERE user_id = ?", (amount, user_id)) + row = await self.fetchone("SELECT xp FROM profiles WHERE user_id = ?", (user_id,)) + return row[0], row[0] // 100 diff --git a/editil_bot/embeds.py b/editil_bot/embeds.py new file mode 100644 index 0000000000..a45bf2f37e --- /dev/null +++ b/editil_bot/embeds.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import discord + +BLUE = discord.Colour.from_rgb(88, 101, 242) +PURPLE = discord.Colour.from_rgb(155, 89, 182) +RED = discord.Colour.red() +GREEN = discord.Colour.green() + + +def embed(title: str, description: str = "", colour: discord.Colour = BLUE) -> discord.Embed: + return discord.Embed(title=title, description=description, colour=colour, timestamp=discord.utils.utcnow()) + + +def success(description: str) -> discord.Embed: + return embed("✅ בוצע", description, GREEN) + + +def error(description: str) -> discord.Embed: + return embed("⚠️ שגיאה", description, RED) diff --git a/editil_bot/logging.py b/editil_bot/logging.py new file mode 100644 index 0000000000..1feeb93799 --- /dev/null +++ b/editil_bot/logging.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import discord +from .embeds import PURPLE, embed + + +async def log(guild: discord.Guild, channel_id: int, title: str, description: str) -> None: + channel = guild.get_channel(channel_id) + if isinstance(channel, discord.TextChannel): + await channel.send(embed=embed(title, description, PURPLE)) diff --git a/editil_bot/main.py b/editil_bot/main.py new file mode 100644 index 0000000000..e94aac00d0 --- /dev/null +++ b/editil_bot/main.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import asyncio +import logging +from collections import Counter +from pathlib import Path + +import discord +from discord.ext import commands +from dotenv import load_dotenv + +from .config import Settings +from .database import Database + +load_dotenv(".env.editil") +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") +logger = logging.getLogger(__name__) + + +class EditILBot(commands.Bot): + def __init__(self) -> None: + intents = discord.Intents.default() + intents.members = True + intents.message_content = True + super().__init__(command_prefix="!", intents=intents) + self.settings = Settings.from_environment() + self.db = Database("/app/data/editil.db" if Path("/app").exists() else "editil.db") + self.loaded_modules: list[str] = [] + self.failed_modules: dict[str, str] = {} + self.registered_command_count = 0 + self.last_error_reference: str | None = None + + async def load_cogs(self) -> None: + """Load every cog independently so one failure does not hide the rest.""" + cog_directory = Path(__file__).parent / "cogs" + for path in sorted(cog_directory.glob("*.py")): + if path.stem == "__init__": + continue + + extension = f"editil_bot.cogs.{path.stem}" + try: + await self.load_extension(extension) + except Exception as exc: + self.failed_modules[extension] = f"{type(exc).__name__}: {exc}" + logger.exception("Failed to load extension %s", extension) + else: + self.loaded_modules.append(extension) + logger.info("Loaded extension %s", extension) + + def validate_command_tree(self) -> None: + """Fail before syncing if Discord would receive duplicate command paths.""" + command_names = [command.qualified_name for command in self.tree.walk_commands()] + duplicates = sorted(name for name, count in Counter(command_names).items() if count > 1) + if duplicates: + raise RuntimeError(f"Duplicate application command paths: {', '.join(duplicates)}") + + async def setup_hook(self) -> None: + await self.db.connect() + await self.load_cogs() + self.validate_command_tree() + + logger.info( + "Extension loading complete: %d loaded, %d failed", + len(self.loaded_modules), + len(self.failed_modules), + ) + if self.settings.guild_id: + guild = discord.Object(id=self.settings.guild_id) + self.tree.copy_global_to(guild=guild) + synced = await self.tree.sync(guild=guild) + logger.info( + "Development sync completed: %d commands registered to guild %d", + len(synced), + self.settings.guild_id, + ) + else: + synced = await self.tree.sync() + logger.info( + "Production sync completed: %d global commands registered; global updates may take longer to appear", + len(synced), + ) + self.registered_command_count = len(synced) + + async def close(self) -> None: + await self.db.close() + await super().close() + + +async def run() -> None: + bot = EditILBot() + if not bot.settings.token: + raise RuntimeError("DISCORD_TOKEN חסר בקובץ .env.editil") + async with bot: + await bot.start(bot.settings.token) + + +if __name__ == "__main__": + asyncio.run(run()) diff --git a/editil_bot/permissions.py b/editil_bot/permissions.py new file mode 100644 index 0000000000..ec43f40994 --- /dev/null +++ b/editil_bot/permissions.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import discord +from discord import app_commands + + +LEVEL_KEYS = { + "verified": "verified_role_id", + "helper": "helper_role_id", + "moderator": "moderator_role_id", + "administrator": "administrator_role_id", +} + + +async def configured_role_id(interaction: discord.Interaction, level: str) -> int: + if not interaction.guild_id: + return 0 + key = LEVEL_KEYS[level] + value = await interaction.client.db.get_guild_setting(interaction.guild_id, key, 0) + if not value and level == "verified": + value = interaction.client.settings.member_role_id + if not value and level == "helper": + value = interaction.client.settings.ticket_staff_role_id + return int(value or 0) + + +def server_owner_only() -> Callable[[Any], Any]: + async def predicate(interaction: discord.Interaction) -> bool: + if not interaction.guild or interaction.user.id != interaction.guild.owner_id: + raise app_commands.CheckFailure("server_owner_only") + return True + return app_commands.check(predicate) + + +def permission_level(level: str) -> Callable[[Any], Any]: + async def predicate(interaction: discord.Interaction) -> bool: + if not interaction.guild or not isinstance(interaction.user, discord.Member): + raise app_commands.CheckFailure("guild_only") + permissions = interaction.user.guild_permissions + if interaction.user.id == interaction.guild.owner_id: + return True + native = { + "verified": False, + "helper": False, + "moderator": permissions.moderate_members or permissions.manage_messages, + "administrator": permissions.administrator or permissions.manage_guild, + }[level] + role_id = await configured_role_id(interaction, level) + if native or (role_id and interaction.user.get_role(role_id)): + return True + raise app_commands.MissingPermissions([level]) + return app_commands.check(predicate) + + +def target_is_manageable(actor: discord.Member, target: discord.Member, bot_member: discord.Member) -> bool: + if target.id == target.guild.owner_id or target.id == actor.id: + return False + if target.guild_permissions.administrator: + return False + if actor.id != actor.guild.owner_id and target.top_role >= actor.top_role: + return False + return target.top_role < bot_member.top_role diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000000..a587062c54 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ES2022", + "checkJs": false, + "allowJs": true, + "noEmit": true, + "strict": false + }, + "include": ["src/**/*", "scripts/**/*", "public/**/*"], + "exclude": ["node_modules"] +} diff --git a/lavalink/application.yml b/lavalink/application.yml new file mode 100644 index 0000000000..bd1caffa61 --- /dev/null +++ b/lavalink/application.yml @@ -0,0 +1,81 @@ +server: + port: 2333 + address: 0.0.0.0 + +lavalink: + plugins: + - dependency: "dev.lavalink.youtube:youtube-plugin:1.18.1" + repository: "https://maven.lavalink.dev/releases" + - dependency: "com.github.topi314.lavasrc:lavasrc-plugin:4.8.2" + repository: "https://maven.lavalink.dev/releases" + server: + password: "${LAVALINK_SERVER_PASSWORD:youshallnotpass}" + sources: + youtube: false + bandcamp: true + soundcloud: true + twitch: true + vimeo: true + http: true + local: false + filters: + volume: true + equalizer: true + karaoke: true + timescale: true + tremolo: true + vibrato: true + distortion: true + rotation: true + channelMix: true + lowPass: true + bufferDurationMs: 400 + frameBufferDurationMs: 5000 + opusEncodingQuality: 10 + resamplingQuality: LOW + trackStuckThresholdMs: 10000 + useSeekGhosting: true + youtubePlaylistLoadLimit: 6 + playerUpdateInterval: 5 + youtubeSearchEnabled: true + soundcloudSearchEnabled: true + +plugins: + youtube: + enabled: true + allowSearch: true + allowDirectVideoIds: true + allowDirectPlaylistIds: true + clients: + - MUSIC + - TV + - TVHTML5_SIMPLY + - WEB + - WEBEMBEDDED + oauth: + enabled: true + refreshToken: "${YOUTUBE_REFRESH_TOKEN:}" + lavasrc: + providers: + - 'scsearch:"%ARTIST% - %TITLE%"' + sources: + applemusic: false + applemusic: + countryCode: "IL" + playlistLoadLimit: 6 + albumLoadLimit: 6 + +metrics: + prometheus: + enabled: false + endpoint: /metrics + +sentry: + dsn: "" + +logging: + file: + path: ./logs/ + level: + root: INFO + lavalink: INFO diff --git a/package-lock.json b/package-lock.json index f3a8311dd0..07ab25d9d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,17 +9,21 @@ "version": "2.0.0", "dependencies": { "@discordjs/rest": "^2.6.1", + "@discordjs/voice": "^0.18.0", "axios": "^1.13.2", "discord.js": "^14.15.3", "dotenv": "^17.2.3", "express": "^5.1.0", + "lavalink-client": "^2.10.0", + "libsodium-wrappers": "^0.8.4", "node-cron": "^4.2.1", + "opusscript": "^0.0.8", "pg": "^8.11.3", + "play-dl": "^1.9.7", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", "zod": "^3.25.76" }, - "devDependencies": {}, "engines": { "node": ">=18.0.0" } @@ -140,6 +144,31 @@ "url": "https://github.com/discordjs/discord.js?sponsor" } }, + "node_modules/@discordjs/voice": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@discordjs/voice/-/voice-0.18.0.tgz", + "integrity": "sha512-BvX6+VJE5/vhD9azV9vrZEt9hL1G+GlOdsQaVl5iv9n87fkXjf3cSwllhR3GdaUC8m6dqT8umXIWtn3yCu4afg==", + "license": "Apache-2.0", + "dependencies": { + "@types/ws": "^8.5.12", + "discord-api-types": "^0.37.103", + "prism-media": "^1.3.5", + "tslib": "^2.6.3", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/voice/node_modules/discord-api-types": { + "version": "0.37.120", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.120.tgz", + "integrity": "sha512-7xpNK0EiWjjDFp2nAhHXezE4OUWm7s1zhc/UXXN6hnFFU8dfoPHgV0Hx0RPiCa3ILRpdeh152icc68DGCyXYIw==", + "license": "MIT" + }, "node_modules/@discordjs/ws": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", @@ -892,6 +921,35 @@ "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" }, + "node_modules/lavalink-client": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/lavalink-client/-/lavalink-client-2.10.2.tgz", + "integrity": "sha512-iqu19lr/HS/Bwv41SGYQZPQS8J992vaRyLeyIf6UXdpBsOmtIAQnNuGiW3oekSMxa+wkKdTXyCzt1EJT2vJZgg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1", + "ws": "^8.20.0" + }, + "engines": { + "bun": ">=1.1.27", + "node": ">=18.0.0" + } + }, + "node_modules/libsodium": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.8.4.tgz", + "integrity": "sha512-lMcYaRi0zcs7tarATsQUYC7rstliIXZuoq0c6zXSgNtSNtdvBgkSegjWhpMJAXzKX3SUSwIp7+zEsob+j3LuRw==", + "license": "ISC" + }, + "node_modules/libsodium-wrappers": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/libsodium-wrappers/-/libsodium-wrappers-0.8.4.tgz", + "integrity": "sha512-mu8aAWucZjTB5O/BtGXtW4e1agy7uHxNYG7zPthmmD1jU43LCDmSWZLN4JhflbdPXj3yDO4lxM1O9hLDgIOXDw==", + "license": "ISC", + "dependencies": { + "libsodium": "^0.8.0" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -1048,6 +1106,12 @@ "fn.name": "1.x.x" } }, + "node_modules/opusscript": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.0.8.tgz", + "integrity": "sha512-VSTi1aWFuCkRCVq+tx/BQ5q9fMnQ9pVZ3JU4UHKqTkf0ED3fKEPdr+gKAAl3IA2hj9rrP6iyq3hlcJq3HELtNQ==", + "license": "MIT" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1146,6 +1210,24 @@ "split2": "^4.1.0" } }, + "node_modules/play-audio": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/play-audio/-/play-audio-0.5.2.tgz", + "integrity": "sha512-ZAqHUKkQLix2Iga7pPbsf1LpUoBjcpwU93F1l3qBIfxYddQLhxS6GKmS0d3jV8kSVaUbr6NnOEcEMFvuX93SWQ==", + "license": "GPL-3.0" + }, + "node_modules/play-dl": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/play-dl/-/play-dl-1.9.7.tgz", + "integrity": "sha512-KpgerWxUCY4s9Mhze2qdqPhiqd8Ve6HufpH9mBH3FN+vux55qSh6WJKDabfie8IBHN7lnrAlYcT/UdGax58c2A==", + "license": "GPL-3.0", + "dependencies": { + "play-audio": "^0.5.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -1181,6 +1263,32 @@ "node": ">=0.10.0" } }, + "node_modules/prism-media": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz", + "integrity": "sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA==", + "license": "Apache-2.0", + "peerDependencies": { + "@discordjs/opus": ">=0.8.0 <1.0.0", + "ffmpeg-static": "^5.0.2 || ^4.2.7 || ^3.0.0 || ^2.4.0", + "node-opus": "^0.3.3", + "opusscript": "^0.0.8" + }, + "peerDependenciesMeta": { + "@discordjs/opus": { + "optional": true + }, + "ffmpeg-static": { + "optional": true + }, + "node-opus": { + "optional": true + }, + "opusscript": { + "optional": true + } + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1695,6 +1803,25 @@ "discord-api-types": "^0.38.33" } }, + "@discordjs/voice": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@discordjs/voice/-/voice-0.18.0.tgz", + "integrity": "sha512-BvX6+VJE5/vhD9azV9vrZEt9hL1G+GlOdsQaVl5iv9n87fkXjf3cSwllhR3GdaUC8m6dqT8umXIWtn3yCu4afg==", + "requires": { + "@types/ws": "^8.5.12", + "discord-api-types": "^0.37.103", + "prism-media": "^1.3.5", + "tslib": "^2.6.3", + "ws": "^8.18.0" + }, + "dependencies": { + "discord-api-types": { + "version": "0.37.120", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.120.tgz", + "integrity": "sha512-7xpNK0EiWjjDFp2nAhHXezE4OUWm7s1zhc/UXXN6hnFFU8dfoPHgV0Hx0RPiCa3ILRpdeh152icc68DGCyXYIw==" + } + } + }, "@discordjs/ws": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", @@ -2225,6 +2352,28 @@ "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" }, + "lavalink-client": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/lavalink-client/-/lavalink-client-2.10.2.tgz", + "integrity": "sha512-iqu19lr/HS/Bwv41SGYQZPQS8J992vaRyLeyIf6UXdpBsOmtIAQnNuGiW3oekSMxa+wkKdTXyCzt1EJT2vJZgg==", + "requires": { + "tslib": "^2.8.1", + "ws": "^8.20.0" + } + }, + "libsodium": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.8.4.tgz", + "integrity": "sha512-lMcYaRi0zcs7tarATsQUYC7rstliIXZuoq0c6zXSgNtSNtdvBgkSegjWhpMJAXzKX3SUSwIp7+zEsob+j3LuRw==" + }, + "libsodium-wrappers": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/libsodium-wrappers/-/libsodium-wrappers-0.8.4.tgz", + "integrity": "sha512-mu8aAWucZjTB5O/BtGXtW4e1agy7uHxNYG7zPthmmD1jU43LCDmSWZLN4JhflbdPXj3yDO4lxM1O9hLDgIOXDw==", + "requires": { + "libsodium": "^0.8.0" + } + }, "lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -2335,6 +2484,11 @@ "fn.name": "1.x.x" } }, + "opusscript": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.0.8.tgz", + "integrity": "sha512-VSTi1aWFuCkRCVq+tx/BQ5q9fMnQ9pVZ3JU4UHKqTkf0ED3fKEPdr+gKAAl3IA2hj9rrP6iyq3hlcJq3HELtNQ==" + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2405,6 +2559,19 @@ "split2": "^4.1.0" } }, + "play-audio": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/play-audio/-/play-audio-0.5.2.tgz", + "integrity": "sha512-ZAqHUKkQLix2Iga7pPbsf1LpUoBjcpwU93F1l3qBIfxYddQLhxS6GKmS0d3jV8kSVaUbr6NnOEcEMFvuX93SWQ==" + }, + "play-dl": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/play-dl/-/play-dl-1.9.7.tgz", + "integrity": "sha512-KpgerWxUCY4s9Mhze2qdqPhiqd8Ve6HufpH9mBH3FN+vux55qSh6WJKDabfie8IBHN7lnrAlYcT/UdGax58c2A==", + "requires": { + "play-audio": "^0.5.2" + } + }, "postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -2428,6 +2595,12 @@ "xtend": "^4.0.0" } }, + "prism-media": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz", + "integrity": "sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA==", + "requires": {} + }, "proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", diff --git a/package.json b/package.json index 2fdbbd3b0d..d5c4b2619d 100644 --- a/package.json +++ b/package.json @@ -16,17 +16,21 @@ }, "dependencies": { "@discordjs/rest": "^2.6.1", + "@discordjs/voice": "^0.18.0", "axios": "^1.13.2", + "lavalink-client": "^2.10.0", "discord.js": "^14.15.3", "dotenv": "^17.2.3", "express": "^5.1.0", + "libsodium-wrappers": "^0.8.4", "node-cron": "^4.2.1", + "opusscript": "^0.0.8", "pg": "^8.11.3", + "play-dl": "^1.9.7", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", "zod": "^3.25.76" }, - "devDependencies": {}, "engines": { "node": ">=18.0.0" } diff --git a/public/dashboard.html b/public/dashboard.html new file mode 100644 index 0000000000..22b6f984ca --- /dev/null +++ b/public/dashboard.html @@ -0,0 +1,719 @@ + + + + + + Itay100K Bot Dashboard + + + + + + + +
+
+
+
+
+
+ + +
+
+
+ +
+
+
Loading…
+
Online
+
+
+ + 🏓 —ms + ↻ 30s + + + Add to Server + + +
+
+ + + + +
+ + +
+
+
🖥️
Servers
+
👥
Total Users
+
Commands
+
⏱️
Uptime
+
🗄️
Database
+
+
+
+
⚡ Commands
+
+
+
+
🖥️ Servers
+
+
+
+
+ + +
+
+
⚡ All Commands
+
+
+
+ + +
+
+
🖥️ All Servers
+
+
+
+ + +
+
+ + +
+
+
🛡️ Mod Commands prefix >
+
+
+
+ + +
+
+
🎮 Fun Commands prefix ?
+
+
+
+ +
+
+ + +
+
+
+
👑 Owner Panel
+
+
+
+
+
Owner Prefix Commands
+
+
+
+
System Info
+
+
+
+
+
+
+ +
+ + + + + + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000000..df99e1ebc2 --- /dev/null +++ b/public/index.html @@ -0,0 +1,2 @@ + +Redirecting… diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000000..976f04614f --- /dev/null +++ b/public/login.html @@ -0,0 +1,424 @@ + + + + + + Sign In — Itay100K Itay100K Bot Dashboard + + + + + + + +
+
+ + +
+ + Itay100K Bot Dashboard +
+
Online
+
+
+ + +
+
+
+
+ + +
+
+
🤖
+ +
+
+
Dashboard
+
Sign in to manage your bot
+
+ + + + + + + Continue with Discord + + + + + + + + +
+
Discord login requires CLIENT_SECRET in your .env file.
+ +
+
+
+ + +
+ itay100k Bot + + v1.0 + + Secure login +
+ + + + diff --git a/requirements-editil.txt b/requirements-editil.txt new file mode 100644 index 0000000000..c8f0c24a0d --- /dev/null +++ b/requirements-editil.txt @@ -0,0 +1,3 @@ +discord.py>=2.4,<3.0 +aiosqlite>=0.20,<1.0 +python-dotenv>=1.0,<2.0 diff --git a/src/app.js b/src/app.js index c0625b6b5a..588296754a 100644 --- a/src/app.js +++ b/src/app.js @@ -1,16 +1,42 @@ import 'dotenv/config'; -import { Client, Collection, GatewayIntentBits } from 'discord.js'; +import { Client, Collection, GatewayIntentBits, Partials } from 'discord.js'; import { REST } from '@discordjs/rest'; import express from 'express'; -import cron from 'node-cron'; +import path from 'path'; +import crypto from 'crypto'; +import { fileURLToPath } from 'url'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// ── Dashboard auth helpers ──────────────────────────────────────── +const DASHBOARD_SECRET = process.env.DASHBOARD_SECRET || crypto.randomBytes(32).toString('hex'); + +function createToken(payload, remember = false) { + const base = { ...payload }; + if (!remember) base.exp = Date.now() + 24 * 3600 * 1000; + const data = Buffer.from(JSON.stringify(base)).toString('base64'); + const sig = crypto.createHmac('sha256', DASHBOARD_SECRET).update(data).digest('hex'); + return `${data}.${sig}`; +} + +function verifyToken(token) { + if (!token) return null; + const dot = token.lastIndexOf('.'); + if (dot < 0) return null; + const data = token.slice(0, dot); + const sig = token.slice(dot + 1); + const expected = crypto.createHmac('sha256', DASHBOARD_SECRET).update(data).digest('hex'); + if (sig !== expected) return null; + try { + const payload = JSON.parse(Buffer.from(data, 'base64').toString()); + if (payload.exp && Date.now() > payload.exp) return null; + return payload; + } catch { return null; } +} import config from './config/application.js'; import { initializeDatabase } from './utils/database.js'; import { getGuildConfig } from './services/guildConfig.js'; -import { getServerCounters, saveServerCounters, updateCounter } from './services/serverstatsService.js'; import { logger, startupLog, shutdownLog } from './utils/logger.js'; -import { checkBirthdays } from './services/birthdayService.js'; -import { checkGiveaways } from './services/giveawayService.js'; import { loadCommands, registerCommands as registerSlashCommands } from './handlers/commandLoader.js'; class TitanBot extends Client { @@ -23,14 +49,15 @@ class TitanBot extends Client { GatewayIntentBits.GuildMessages, - GatewayIntentBits.GuildMessageReactions, GatewayIntentBits.MessageContent, + GatewayIntentBits.DirectMessages, GatewayIntentBits.GuildVoiceStates, - GatewayIntentBits.GuildBans, + GatewayIntentBits.GuildBans, ], + partials: [Partials.Message, Partials.Channel, Partials.GuildMember, Partials.User], }); this.config = config; @@ -71,7 +98,7 @@ class TitanBot extends Client { startupLog('Starting web server...'); this.startWebServer(); - + startupLog('Loading commands...'); await loadCommands(this); startupLog(`Commands loaded: ${this.commands.size}`); @@ -150,6 +177,177 @@ class TitanBot extends Client { next(); }); + // ── Static files ────────────────────────────────────────────── + app.use(express.static(path.join(__dirname, '../public'))); + app.use(express.json()); + + // ── Page routes ─────────────────────────────────────────────── + app.get('/', (req, res) => res.redirect('/dashboard')); + app.get('/dashboard', (req, res) => res.sendFile(path.join(__dirname, '../public/dashboard.html'))); + app.get('/login', (req, res) => res.sendFile(path.join(__dirname, '../public/login.html'))); + + // ── Public API ──────────────────────────────────────────────── + app.get('/api/botinfo', (req, res) => { + res.json({ + username: this.user?.username || 'Bot', + avatar: this.user?.displayAvatarURL({ size: 128 }) || '', + }); + }); + + app.get('/api/loginconfig', (req, res) => { + res.json({ + username: this.user?.username || 'Bot', + avatar: this.user?.displayAvatarURL({ size: 128 }) || '', + discordEnabled: !!process.env.CLIENT_SECRET, + emailEnabled: !!process.env.DASHBOARD_EMAIL, + }); + }); + + // ── Discord OAuth2 ──────────────────────────────────────────── + app.get('/auth/discord', (req, res) => { + if (!process.env.CLIENT_SECRET) { + return res.redirect('/login?error=oauth_disabled'); + } + const redirectUri = process.env.REDIRECT_URI || `http://localhost:${process.env.PORT || 3000}/auth/discord/callback`; + const params = new URLSearchParams({ + client_id: process.env.CLIENT_ID, + redirect_uri: redirectUri, + response_type: 'code', + scope: 'identify', + state: req.query.remember === '1' ? '1' : '0', + }); + res.redirect(`https://discord.com/api/oauth2/authorize?${params}`); + }); + + app.get('/auth/discord/callback', async (req, res) => { + const { code } = req.query; + if (!code) return res.redirect('/login?error=no_code'); + try { + const redirectUri = process.env.REDIRECT_URI || `http://localhost:${process.env.PORT || 3000}/auth/discord/callback`; + const tokenRes = await fetch('https://discord.com/api/oauth2/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: process.env.CLIENT_ID, + client_secret: process.env.CLIENT_SECRET, + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + }), + }); + if (!tokenRes.ok) return res.redirect('/login?error=token_failed'); + const { access_token } = await tokenRes.json(); + + const userRes = await fetch('https://discord.com/api/users/@me', { + headers: { Authorization: `Bearer ${access_token}` }, + }); + if (!userRes.ok) return res.redirect('/login?error=user_failed'); + const user = await userRes.json(); + + const ownerIds = process.env.OWNER_IDS?.split(',').map(id => id.trim()) ?? []; + const isOwner = ownerIds.includes(user.id); + const remember = req.query.state === '1'; + const token = createToken({ userId: user.id, username: user.username, isOwner }, remember); + + res.send(``); + } catch (err) { + logger.error('Discord OAuth2 callback error:', err); + res.redirect('/login?error=token_failed'); + } + }); + + // ── Email login ─────────────────────────────────────────────── + app.post('/api/login/email', (req, res) => { + const { email, password, remember } = req.body || {}; + const cfgEmail = process.env.DASHBOARD_EMAIL; + const cfgPass = process.env.DASHBOARD_PASSWORD; + if (!cfgEmail) return res.status(404).json({ error: 'Email login is not configured.' }); + if (!email || !password || email !== cfgEmail || password !== cfgPass) { + return res.status(401).json({ error: 'Invalid email or password.' }); + } + const ownerIds = process.env.OWNER_IDS?.split(',').map(id => id.trim()) ?? []; + const isOwner = ownerIds.includes(process.env.DASHBOARD_OWNER_ID || '') || true; + const token = createToken({ userId: email, username: email.split('@')[0], isOwner }, !!remember); + res.json({ token }); + }); + + // ── Auth middleware ─────────────────────────────────────────── + const auth = (req, res, next) => { + const hdr = req.headers.authorization; + if (!hdr?.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' }); + const payload = verifyToken(hdr.slice(7)); + if (!payload) return res.status(401).json({ error: 'Invalid or expired token' }); + req.user = payload; + next(); + }; + + // ── Protected API ───────────────────────────────────────────── + app.get('/api/me', auth, (req, res) => res.json(req.user)); + + app.get('/api/stats', auth, (req, res) => { + const dbStatus = this.db?.getStatus?.() || {}; + res.json({ + username: this.user?.username || 'Bot', + avatar: this.user?.displayAvatarURL({ size: 128 }) || '', + guilds: this.guilds.cache.size, + users: this.guilds.cache.reduce((a, g) => a + (g.memberCount || 0), 0), + commands: this.commands.size, + uptime: process.uptime(), + ping: Math.round(this.ws.ping), + dbStatus: dbStatus.isDegraded ? '⚠️ Degraded' : '✅ Online', + isOwner: req.user.isOwner, + }); + }); + + app.get('/api/commands', auth, (req, res) => { + const categories = {}; + for (const [name, cmd] of this.commands) { + const cat = cmd.category + ? cmd.category.charAt(0).toUpperCase() + cmd.category.slice(1) + : 'Other'; + if (!categories[cat]) categories[cat] = []; + categories[cat].push(name); + } + res.json(categories); + }); + + app.get('/api/servers', auth, (req, res) => { + const servers = this.guilds.cache.map(g => ({ + id: g.id, + name: g.name, + memberCount: g.memberCount || 0, + icon: g.iconURL({ size: 64 }) || null, + })).sort((a, b) => b.memberCount - a.memberCount); + res.json(servers); + }); + + app.get('/api/servers/:guildId/invite', auth, async (req, res) => { + const guild = this.guilds.cache.get(req.params.guildId); + if (!guild) return res.status(404).json({ error: 'Server not found' }); + const channel = guild.channels.cache.find(c => + c.isTextBased() && !c.isThread() && + guild.members.me?.permissionsIn(c).has('CreateInstantInvite') + ); + if (!channel) return res.status(403).json({ error: 'No channel available to create invite' }); + try { + const invite = await channel.createInvite({ maxAge: 0, maxUses: 0, unique: false }); + res.json({ url: invite.url }); + } catch (err) { + logger.error('Failed to create invite:', err); + res.status(500).json({ error: 'Could not create invite' }); + } + }); + + app.get('/api/botinvite', auth, (req, res) => { + const clientId = process.env.CLIENT_ID; + if (!clientId) return res.status(500).json({ error: 'CLIENT_ID not set' }); + const url = `https://discord.com/api/oauth2/authorize?client_id=${clientId}&permissions=8&scope=bot+applications.commands`; + res.json({ url }); + }); + app.get('/health', (req, res) => { const dbStatus = this.db?.getStatus?.() || { isDegraded: 'unknown' }; const status = { @@ -182,13 +380,6 @@ class TitanBot extends Client { }); }); - app.get('/', (req, res) => { - res.status(200).json({ - message: 'TitanBot System Online', - version: '2.0.0', - timestamp: new Date().toISOString() - }); - }); const startServer = (port, attempt = 0) => { let hasStartedListening = false; @@ -228,45 +419,7 @@ class TitanBot extends Client { } setupCronJobs() { - cron.schedule('0 6 * * *', () => checkBirthdays(this)); - cron.schedule('* * * * *', () => checkGiveaways(this)); - cron.schedule('*/15 * * * *', () => this.updateAllCounters()); - } - - async updateAllCounters() { - if (!this.db) { - logger.warn('Database not available for counter updates'); - return; - } - - for (const [guildId, guild] of this.guilds.cache) { - try { - const counters = await getServerCounters(this, guildId); - const validCounters = []; - const orphanedCounters = []; - - 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); - } else { - orphanedCounters.push(counter); - logger.info(`Removing orphaned counter ${counter.id} (type: ${counter.type}, deleted channel: ${counter.channelId}) from guild ${guildId}`); - } - } - } - - // Save cleaned counters if any were orphaned - if (orphanedCounters.length > 0) { - await saveServerCounters(this, guildId, validCounters); - logger.info(`Cleaned up ${orphanedCounters.length} orphaned counter(s) from guild ${guildId} during scheduled update`); - } - } catch (error) { - logger.error(`Error updating counters for guild ${guildId}:`, error); - } - } + // Reserved for future scheduled tasks. This release has no background jobs. } async loadHandlers() { @@ -301,7 +454,18 @@ class TitanBot extends Client { async registerCommands() { try { - await registerSlashCommands(this, this.config.bot.guildId); + // Clear leftover guild-specific commands if a guild ID is configured + if (this.config.bot.guildId) { + try { + const guild = await this.guilds.fetch(this.config.bot.guildId); + await guild.commands.set([]); + logger.info('Cleared guild-specific commands — now using global registration'); + } catch (e) { + logger.warn('Could not clear guild commands:', e.message); + } + } + // Pass null to register globally (works across all servers) + await registerSlashCommands(this, null); } catch (error) { logger.error('Error registering commands:', error); } @@ -315,9 +479,6 @@ class TitanBot extends Client { try { - logger.info('Stopping cron jobs...'); - cron.getTasks().forEach(task => task.stop()); - logger.info('✅ Cron jobs stopped'); // Close database connection if (this.db && this.db.db) { @@ -381,6 +542,3 @@ try { } export default TitanBot; - - - diff --git a/src/commands/Birthday/birthday.js b/src/commands/Birthday/birthday.js deleted file mode 100644 index fcd9fafd5f..0000000000 --- a/src/commands/Birthday/birthday.js +++ /dev/null @@ -1,118 +0,0 @@ -import { SlashCommandBuilder, MessageFlags, ChannelType } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; - -import birthdaySet from './modules/birthday_set.js'; -import birthdayInfo from './modules/birthday_info.js'; -import birthdayList from './modules/birthday_list.js'; -import birthdayRemove from './modules/birthday_remove.js'; -import nextBirthdays from './modules/next_birthdays.js'; -import birthdaySetchannel from './modules/birthday_setchannel.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName('birthday') - .setDescription('Birthday system commands') - .addSubcommand(subcommand => - subcommand - .setName('set') - .setDescription('Set your birthday') - .addIntegerOption(option => - option - .setName('month') - .setDescription('Birth month (1-12)') - .setRequired(true) - .setMinValue(1) - .setMaxValue(12) - ) - .addIntegerOption(option => - option - .setName('day') - .setDescription('Birth day (1-31)') - .setRequired(true) - .setMinValue(1) - .setMaxValue(31) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName('info') - .setDescription('View birthday information') - .addUserOption(option => - option - .setName('user') - .setDescription('User to check birthday for') - .setRequired(false) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName('list') - .setDescription('List all birthdays in the server') - ) - .addSubcommand(subcommand => - subcommand - .setName('remove') - .setDescription('Remove your birthday') - ) - .addSubcommand(subcommand => - subcommand - .setName('next') - .setDescription('Show upcoming birthdays') - ) - .addSubcommand(subcommand => - subcommand - .setName('setchannel') - .setDescription('Set or disable the channel for birthday announcements. (Manage Server required)') - .addChannelOption(option => - option - .setName('channel') - .setDescription('The text channel for announcements. Leave empty to disable.') - .addChannelTypes(ChannelType.GuildText) - .setRequired(false) - ) - ), - - async execute(interaction, config, client) { - try { - const subcommand = interaction.options.getSubcommand(); - - switch (subcommand) { - case 'set': - return await birthdaySet.execute(interaction, config, client); - case 'info': - return await birthdayInfo.execute(interaction, config, client); - case 'list': - return await birthdayList.execute(interaction, config, client); - case 'remove': - return await birthdayRemove.execute(interaction, config, client); - case 'next': - return await nextBirthdays.execute(interaction, config, client); - case 'setchannel': - return await birthdaySetchannel.execute(interaction, config, client); - default: - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Error', 'Unknown subcommand')], - flags: MessageFlags.Ephemeral - }); - } - } catch (error) { - logger.error('Birthday command execution failed', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'birthday', - subcommand: interaction.options.getSubcommand() - }); - await handleInteractionError(interaction, error, { - commandName: 'birthday', - source: 'birthday_command' - }); - } - } -}; - - diff --git a/src/commands/Birthday/modules/birthday_info.js b/src/commands/Birthday/modules/birthday_info.js deleted file mode 100644 index 16a2393a1a..0000000000 --- a/src/commands/Birthday/modules/birthday_info.js +++ /dev/null @@ -1,64 +0,0 @@ -import { MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed } from '../../../utils/embeds.js'; -import { getUserBirthday } from '../../../services/birthdayService.js'; -import { logger } from '../../../utils/logger.js'; -import { handleInteractionError } from '../../../utils/errorHandler.js'; - -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -export default { - async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction); - - const targetUser = interaction.options.getUser("user") || interaction.user; - const userId = targetUser.id; - const guildId = interaction.guildId; - - - const birthdayData = await getUserBirthday(client, guildId, userId); - - if (!birthdayData) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [createEmbed({ - title: '❌ No Birthday Found', - description: 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.`, - color: 'error' - })] - }); - } - - const embed = createEmbed({ - title: "🎂 Birthday Information", - description: `**Date:** ${birthdayData.monthName} ${birthdayData.day}\n**User:** ${targetUser.toString()}`, - color: 'info', - footer: targetUser.id === interaction.user.id ? "Your Birthday" : `${targetUser.username}'s Birthday` - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - - logger.info('Birthday info retrieved successfully', { - userId: interaction.user.id, - targetUserId: targetUser.id, - guildId, - commandName: 'birthday_info' - }); - } catch (error) { - logger.error("Birthday info command execution failed", { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'birthday_info' - }); - await handleInteractionError(interaction, error, { - commandName: 'birthday_info', - source: 'birthday_info_module' - }); - } - } -}; - - - diff --git a/src/commands/Birthday/modules/birthday_list.js b/src/commands/Birthday/modules/birthday_list.js deleted file mode 100644 index 81662b4cc4..0000000000 --- a/src/commands/Birthday/modules/birthday_list.js +++ /dev/null @@ -1,99 +0,0 @@ -import { MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed } from '../../../utils/embeds.js'; -import { getAllBirthdays } from '../../../services/birthdayService.js'; -import { deleteBirthday } from '../../../utils/database.js'; -import { logger } from '../../../utils/logger.js'; -import { handleInteractionError } from '../../../utils/errorHandler.js'; - -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -export default { - async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction); - - const guildId = interaction.guildId; - - - const sortedBirthdays = await getAllBirthdays(client, guildId); - - if (sortedBirthdays.length === 0) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [createEmbed({ - title: '❌ No Birthdays', - description: 'No birthdays have been set in this server yet.', - color: 'error' - })] - }); - } - - const embed = createEmbed({ - title: "🎂 Server Birthdays", - color: 'info' - }); - - // Batch fetch to verify which users are still in the guild - const userIds = sortedBirthdays.map(b => b.userId); - const fetchedMembers = await interaction.guild.members.fetch({ user: userIds }).catch(() => null); - - let birthdayList = ''; - let displayIndex = 0; - const staleUserIds = []; - - for (const birthday of sortedBirthdays) { - if (fetchedMembers && !fetchedMembers.has(birthday.userId)) { - staleUserIds.push(birthday.userId); - continue; - } - displayIndex++; - birthdayList += `${displayIndex}. <@${birthday.userId}> - ${birthday.monthName} ${birthday.day}\n`; - } - - // Clean up birthday entries for members who left the server - if (fetchedMembers && staleUserIds.length > 0) { - for (const userId of staleUserIds) { - deleteBirthday(client, guildId, userId).catch(() => null); - } - } - - if (displayIndex === 0) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [createEmbed({ - title: '❌ No Birthdays', - description: 'No birthdays have been set by current server members.', - color: 'error' - })] - }); - } - - birthdayList = `**${displayIndex} birthday${displayIndex !== 1 ? 's' : ''} in ${interaction.guild.name}**\n\n` + birthdayList; - - embed.setDescription(birthdayList); - embed.setFooter({ text: `Total: ${displayIndex} birthday${displayIndex !== 1 ? 's' : ''}` }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - - logger.info('Birthday list retrieved successfully', { - userId: interaction.user.id, - guildId, - birthdayCount: displayIndex, - staleRemoved: staleUserIds.length, - commandName: 'birthday_list' - }); - } catch (error) { - logger.error("Birthday list command execution failed", { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'birthday_list' - }); - await handleInteractionError(interaction, error, { - commandName: 'birthday_list', - source: 'birthday_list_module' - }); - } - } -}; - - - diff --git a/src/commands/Birthday/modules/birthday_remove.js b/src/commands/Birthday/modules/birthday_remove.js deleted file mode 100644 index 998d7e071e..0000000000 --- a/src/commands/Birthday/modules/birthday_remove.js +++ /dev/null @@ -1,52 +0,0 @@ -import { MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed } from '../../../utils/embeds.js'; -import { deleteBirthday } from '../../../services/birthdayService.js'; -import { logger } from '../../../utils/logger.js'; -import { handleInteractionError } from '../../../utils/errorHandler.js'; - -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -export default { - async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction); - - const userId = interaction.user.id; - const guildId = interaction.guildId; - - - const result = await deleteBirthday(client, guildId, userId); - - if (result.success) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed( - "Your birthday has been successfully removed from the server.", - "Birthday Removed 🗑️" - )] - }); - } else if (result.notFound) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [createEmbed({ - title: '❌ No Birthday Found', - description: "You don't have a birthday set to remove.", - color: 'error' - })] - }); - } - } catch (error) { - logger.error("Birthday remove command execution failed", { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'birthday_remove' - }); - await handleInteractionError(interaction, error, { - commandName: 'birthday_remove', - source: 'birthday_remove_module' - }); - } - } -}; - - - diff --git a/src/commands/Birthday/modules/birthday_set.js b/src/commands/Birthday/modules/birthday_set.js deleted file mode 100644 index dad5616d2d..0000000000 --- a/src/commands/Birthday/modules/birthday_set.js +++ /dev/null @@ -1,44 +0,0 @@ -import { MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed } from '../../../utils/embeds.js'; -import { setBirthday } from '../../../services/birthdayService.js'; -import { logger } from '../../../utils/logger.js'; -import { handleInteractionError } from '../../../utils/errorHandler.js'; - -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -export default { - async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction); - - const month = interaction.options.getInteger("month"); - const day = interaction.options.getInteger("day"); - const userId = interaction.user.id; - const guildId = interaction.guildId; - - - const result = await setBirthday(client, guildId, userId, month, day); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed( - `Your birthday has been set to **${result.data.monthName} ${result.data.day}**!`, - "Birthday Set! 🎂" - )] - }); - } catch (error) { - logger.error("Birthday set command execution failed", { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'birthday_set' - }); - await handleInteractionError(interaction, error, { - commandName: 'birthday_set', - source: 'birthday_set_module' - }); - } - } -}; - - - diff --git a/src/commands/Birthday/modules/birthday_setchannel.js b/src/commands/Birthday/modules/birthday_setchannel.js deleted file mode 100644 index 2da4b9409a..0000000000 --- a/src/commands/Birthday/modules/birthday_setchannel.js +++ /dev/null @@ -1,44 +0,0 @@ -import { PermissionsBitField, MessageFlags } from 'discord.js'; -import { errorEmbed, successEmbed } from '../../../utils/embeds.js'; -import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { logger } from '../../../utils/logger.js'; - -export default { - async execute(interaction, config, client) { - if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Permission Denied', 'You need **Manage Server** permissions to configure the birthday channel.')], - flags: MessageFlags.Ephemeral, - }); - } - - try { - const channel = interaction.options.getChannel('channel'); - const guildId = interaction.guildId; - const guildConfig = await getGuildConfig(client, guildId); - - if (channel) { - guildConfig.birthdayChannelId = channel.id; - await setGuildConfig(client, guildId, guildConfig); - return InteractionHelper.safeReply(interaction, { - embeds: [successEmbed('🎂 Birthday Announcements Enabled', `Birthday announcements will now be posted in ${channel}.`)], - flags: MessageFlags.Ephemeral, - }); - } else { - guildConfig.birthdayChannelId = null; - await setGuildConfig(client, guildId, guildConfig); - return InteractionHelper.safeReply(interaction, { - embeds: [successEmbed('🎂 Birthday Announcements Disabled', 'No channel provided — birthday announcements have been disabled.')], - flags: MessageFlags.Ephemeral, - }); - } - } catch (error) { - logger.error('birthday_setchannel error:', error); - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Configuration Error', 'Could not save the birthday channel configuration.')], - flags: MessageFlags.Ephemeral, - }); - } - }, -}; diff --git a/src/commands/Birthday/modules/next_birthdays.js b/src/commands/Birthday/modules/next_birthdays.js deleted file mode 100644 index 778d392e22..0000000000 --- a/src/commands/Birthday/modules/next_birthdays.js +++ /dev/null @@ -1,102 +0,0 @@ -import { MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed } from '../../../utils/embeds.js'; -import { getUpcomingBirthdays } from '../../../services/birthdayService.js'; -import { deleteBirthday } from '../../../utils/database.js'; -import { logger } from '../../../utils/logger.js'; -import { handleInteractionError } from '../../../utils/errorHandler.js'; - -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -export default { - async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction); - - - const next5 = await getUpcomingBirthdays(client, interaction.guildId, 5); - - if (next5.length === 0) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - createEmbed({ - title: '❌ No Birthdays Found', - description: 'No birthdays have been set up in this server yet. Use `/birthday set` to add birthdays!', - color: 'error' - }) - ] - }); - } - - const embed = createEmbed({ - title: '🎂 Next 5 Upcoming Birthdays', - description: `Here are the next 5 birthdays in ${interaction.guild.name}:`, - color: 'info' - }); - - let displayIndex = 0; - for (const birthday of next5) { - const member = await interaction.guild.members.fetch(birthday.userId).catch(() => null); - if (!member) { - deleteBirthday(client, interaction.guildId, birthday.userId).catch(() => null); - continue; - } - displayIndex++; - - let timeUntil = ''; - if (birthday.daysUntil === 0) { - timeUntil = '🎉 **Today!**'; - } else if (birthday.daysUntil === 1) { - timeUntil = '📅 **Tomorrow!**'; - } else { - timeUntil = `In ${birthday.daysUntil} day${birthday.daysUntil > 1 ? 's' : ''}`; - } - - embed.addFields({ - name: `${displayIndex}. ${member.displayName}`, - value: `<@${birthday.userId}>\n📅 **Date:** ${birthday.monthName} ${birthday.day}\n⏰ **Time:** ${timeUntil}`, - inline: false - }); - } - - if (displayIndex === 0) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - createEmbed({ - title: '❌ No Upcoming Birthdays', - description: 'No upcoming birthdays found for current server members.', - color: 'error' - }) - ] - }); - } - - embed.setFooter({ - text: 'Use /birthday set to add your birthday!', - iconURL: interaction.guild.iconURL() - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - - logger.info('Next birthdays retrieved successfully', { - userId: interaction.user.id, - guildId: interaction.guildId, - upcomingCount: displayIndex, - commandName: 'next_birthdays' - }); - } catch (error) { - logger.error('Next birthdays command execution failed', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'next_birthdays' - }); - await handleInteractionError(interaction, error, { - commandName: 'next_birthdays', - source: 'next_birthdays_module' - }); - } - } -}; - - - diff --git a/src/commands/Community/app-admin.js b/src/commands/Community/app-admin.js deleted file mode 100644 index 099398c340..0000000000 --- a/src/commands/Community/app-admin.js +++ /dev/null @@ -1,733 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ComponentType, LabelBuilder, RoleSelectMenuBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; -import { getColor } from '../../config/bot.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import ApplicationService from '../../services/applicationService.js'; -import { - getApplicationSettings, - saveApplicationSettings, - getApplication, - getApplications, - updateApplication, - getApplicationRoles, - saveApplicationRoles, - getApplicationRoleSettings, - saveApplicationRoleSettings, - deleteApplication -} from '../../utils/database.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import appDashboard from './modules/app_dashboard.js'; - -function getApplicationStatusPresentation(statusValue) { - const normalized = typeof statusValue === 'string' ? statusValue.trim().toLowerCase() : 'unknown'; - const statusLabel = - normalized === 'pending' ? 'In Progress' : - normalized === 'approved' ? 'Accepted' : - normalized === 'denied' ? 'Denied' : - 'Unknown'; - const statusEmoji = - normalized === 'pending' ? '🟡' : - normalized === 'approved' ? '🟢' : - normalized === 'denied' ? '🔴' : - '⚪'; - - return { normalized, statusLabel, statusEmoji }; -} - -export default { - data: new SlashCommandBuilder() - .setName("app-admin") - .setDescription("Manage staff applications") - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .addSubcommand((subcommand) => - subcommand - .setName("setup") - .setDescription("Set up a new application") - ) - .addSubcommand((subcommand) => - subcommand - .setName("review") - .setDescription("Approve or deny an application") - .addStringOption((option) => - option - .setName("id") - .setDescription("The application ID") - .setRequired(true), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("list") - .setDescription("List all applications") - .addStringOption((option) => - option - .setName("status") - .setDescription("Filter by status") - .addChoices( - { name: "Pending", value: "pending" }, - { name: "Approved", value: "approved" }, - { name: "Denied", value: "denied" }, - ), - ) - .addStringOption((option) => - option.setName("role").setDescription("Filter by role ID"), - ) - .addUserOption((option) => - option.setName("user").setDescription("Filter by user"), - ) - .addNumberOption((option) => - option - .setName("limit") - .setDescription( - "Maximum number of applications to show (default: 10)", - ) - .setMinValue(1) - .setMaxValue(25), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("dashboard") - .setDescription("Open the applications configuration dashboard") - .addStringOption((option) => - option - .setName("application") - .setDescription("Select an application to configure") - .setRequired(false) - .setAutocomplete(true), - ), - ), - - category: "Community", - - execute: withErrorHandling(async (interaction) => { - if (!interaction.inGuild()) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed("This command can only be used in a server.")], - flags: ["Ephemeral"], - }); - } - - const { options, guild, member } = interaction; - const subcommand = options.getSubcommand(); - - if (subcommand !== 'dashboard' && subcommand !== 'setup') { - await InteractionHelper.safeDefer(interaction, { flags: ['Ephemeral'] }); - } - - logger.info(`App-admin command executed: ${subcommand}`, { - userId: interaction.user.id, - guildId: guild.id, - subcommand - }); - - // ✓ Permission check: User must have ManageGuild permission or a configured manager role - // This prevents unauthorized users from accessing admin functions - await ApplicationService.checkManagerPermission(interaction.client, guild.id, member); - - if (subcommand === "setup") { - await handleSetup(interaction); - } else if (subcommand === "review") { - await handleReview(interaction); - } else if (subcommand === "list") { - await handleList(interaction); - } else if (subcommand === "dashboard") { - const selectedAppName = interaction.options.getString("application"); - await appDashboard.execute(interaction, null, interaction.client, selectedAppName); - } - }, { type: 'command', commandName: 'app-admin' }) -}; - -async function handleSetup(interaction) { - // Ensure interaction hasn't been deferred/replied yet (safety check) - if (interaction.deferred || interaction.replied) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed("This interaction has already been processed. Please try the command again.")], - flags: ["Ephemeral"], - }); - } - - // Build modal using LabelBuilder API with a native role select dropdown - const modal = new ModalBuilder() - .setCustomId('app_setup_modal') - .setTitle('Set Up New Application'); - - const roleSelect = new RoleSelectMenuBuilder() - .setCustomId('role_id') - .setPlaceholder('Select the role users will apply for') - .setRequired(true); - - const roleLabel = new LabelBuilder() - .setLabel('Application Role') - .setDescription('The role that users will be applying for') - .setRoleSelectMenuComponent(roleSelect); - - const appNameInput = new TextInputBuilder() - .setCustomId('app_name') - .setStyle(TextInputStyle.Short) - .setPlaceholder('e.g., Moderator, Helper, Developer') - .setMaxLength(50) - .setMinLength(1) - .setRequired(true); - - const appNameLabel = new LabelBuilder() - .setLabel('Application Name') - .setTextInputComponent(appNameInput); - - const q1Input = new TextInputBuilder() - .setCustomId('app_question_1') - .setStyle(TextInputStyle.Short) - .setPlaceholder('Why do you want this role?') - .setMaxLength(100) - .setMinLength(1) - .setRequired(true); - - const q1Label = new LabelBuilder() - .setLabel('Question 1 (required)') - .setTextInputComponent(q1Input); - - const q2Input = new TextInputBuilder() - .setCustomId('app_question_2') - .setStyle(TextInputStyle.Short) - .setPlaceholder('What experience do you have?') - .setMaxLength(100) - .setRequired(false); - - const q2Label = new LabelBuilder() - .setLabel('Question 2 (optional)') - .setTextInputComponent(q2Input); - - const q3Input = new TextInputBuilder() - .setCustomId('app_question_3') - .setStyle(TextInputStyle.Short) - .setMaxLength(100) - .setRequired(false); - - const q3Label = new LabelBuilder() - .setLabel('Question 3 (optional)') - .setTextInputComponent(q3Input); - - modal.addLabelComponents(roleLabel, appNameLabel, q1Label, q2Label, q3Label); - - await interaction.showModal(modal); - - const submitted = await interaction.awaitModalSubmit({ - time: 15 * 60 * 1000, // 15 minutes - filter: (i) => - i.customId === 'app_setup_modal' && - i.user.id === interaction.user.id, - }).catch(() => null); - - if (!submitted) { - 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; - - if (!roleId) { - await submitted.reply({ - embeds: [errorEmbed('No Role Selected', 'You must select a role for the application.')], - flags: ['Ephemeral'], - }); - return; - } - - const questions = [ - submitted.fields.getTextInputValue('app_question_1').trim(), - submitted.fields.getTextInputValue('app_question_2').trim(), - submitted.fields.getTextInputValue('app_question_3').trim(), - ].filter(q => q.length > 0); - - // Get the role to verify it exists - const role = await interaction.guild.roles.fetch(roleId).catch(() => null); - if (!role) { - await submitted.reply({ - embeds: [errorEmbed('Invalid Role', 'The selected role could not be found.')], - flags: ['Ephemeral'], - }); - return; - } - - // Check if this role is already an application - const existingRoles = await getApplicationRoles(interaction.client, interaction.guild.id); - if (existingRoles.some(r => r.roleId === roleId)) { - await submitted.reply({ - embeds: [errorEmbed('Already Configured', `The role ${role} is already configured as an application.`)], - flags: ['Ephemeral'], - }); - return; - } - - // Add the role to applications with enabled status - existingRoles.push({ - roleId: roleId, - name: appName, - enabled: true, // New applications start enabled - }); - - await saveApplicationRoles(interaction.client, interaction.guild.id, existingRoles); - - // Enable the system - const settings = await getApplicationSettings(interaction.client, interaction.guild.id); - if (!settings.enabled) { - await ApplicationService.updateSettings(interaction.client, interaction.guild.id, { enabled: true }); - } - - // Save the questions for this specific role - await saveApplicationRoleSettings(interaction.client, interaction.guild.id, roleId, { questions }); - - 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.`, - )], - flags: ['Ephemeral'], - }); - - // Auto-open dashboard with this app selected - setTimeout(() => { - appDashboard.execute(submitted, null, interaction.client, appName); - }, 500); -} - - -async function handleReview(interaction) { - const appId = interaction.options.getString("id"); - - const application = await getApplication( - interaction.client, - interaction.guild.id, - appId, - ); - if (!application) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("Application not found.")], - flags: ["Ephemeral"], - }); - } - - if (application.status !== "pending") { - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed("This application has already been processed."), - ], - flags: ["Ephemeral"], - }); - } - - // Show application details with approve/deny buttons - const appEmbed = createEmbed({ - title: `📋 Review Application`, - description: `**User:** <@${application.userId}>\n**Application:** ${application.roleName}\n**Application ID:** \`${appId}\``, - color: 'info', - }); - - // Add application answers to the embed - 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*', - inline: false - }); - }); - } - - const buttonRow = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`app_review_approve_${appId}`) - .setLabel('Approve') - .setStyle(ButtonStyle.Success), - new ButtonBuilder() - .setCustomId(`app_review_deny_${appId}`) - .setLabel('Deny') - .setStyle(ButtonStyle.Danger), - ); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [appEmbed], - components: [buttonRow], - flags: ["Ephemeral"], - }); - - // Setup button collector - const collector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.Button, - filter: i => - i.user.id === interaction.user.id && - (i.customId.startsWith(`app_review_approve_${appId}`) || - i.customId.startsWith(`app_review_deny_${appId}`)), - time: 300_000, // 5 minutes - max: 1, - }); - - collector.on('collect', async buttonInteraction => { - const isApprove = buttonInteraction.customId.includes('approve'); - - // Show modal for reason - const reasonModal = new ModalBuilder() - .setCustomId(`app_review_reason_${appId}_${isApprove ? 'approve' : 'deny'}`) - .setTitle(`${isApprove ? 'Approve' : 'Deny'} Application - Reason`); - - reasonModal.addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('review_reason') - .setLabel('Reason (optional)') - .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('Provide a reason for this decision...') - .setMaxLength(500) - .setRequired(false), - ), - ); - - await buttonInteraction.showModal(reasonModal); - - try { - const reasonSubmit = await buttonInteraction.awaitModalSubmit({ - time: 5 * 60 * 1000, // 5 minutes - filter: i => - i.customId === `app_review_reason_${appId}_${isApprove ? 'approve' : 'deny'}` && - i.user.id === buttonInteraction.user.id, - }).catch(() => null); - - if (!reasonSubmit) return; - - const reason = reasonSubmit.fields.getTextInputValue('review_reason').trim() || "No reason provided."; - const action = isApprove ? 'approve' : 'deny'; - const status = isApprove ? 'approved' : 'denied'; - - const updatedApplication = await ApplicationService.reviewApplication( - reasonSubmit.client, - interaction.guild.id, - appId, - { - action, - reason, - reviewerId: reasonSubmit.user.id - } - ); - - // Send DM to user - try { - const user = await reasonSubmit.client.users.fetch(application.userId); - const statusColor = status === "approved" ? getColor('success') : getColor('error'); - const reviewStatus = getApplicationStatusPresentation(status); - const dmEmbed = createEmbed( - `${reviewStatus.statusEmoji} Application ${reviewStatus.statusLabel}`, - `Your application for **${application.roleName}** has been **${status}**\n` + - `**Note:** ${reason}\n\n` + - `Use \`/apply status id:${appId}\` to view details.` - ).setColor(statusColor); - - await user.send({ embeds: [dmEmbed] }); - } catch (error) { - logger.warn('Failed to send DM to user for application review', { - error: error.message, - userId: application.userId, - applicationId: appId - }); - } - - // Update log message - if (application.logMessageId && application.logChannelId) { - try { - const statusColor = status === "approved" ? getColor('success') : getColor('error'); - const logChannel = interaction.guild.channels.cache.get( - application.logChannelId, - ); - if (logChannel) { - const logMessage = await logChannel.messages.fetch( - application.logMessageId, - ); - if (logMessage) { - const embed = logMessage.embeds[0]; - if (embed) { - const reviewStatus = getApplicationStatusPresentation(status); - const newEmbed = EmbedBuilder.from(embed) - .setColor(statusColor) - .spliceFields(0, 1, { - name: "Status", - value: `${reviewStatus.statusEmoji} ${reviewStatus.statusLabel}`, - }); - - await logMessage.edit({ - embeds: [newEmbed], - components: [], - }); - } - } - } - } catch (error) { - logger.warn('Failed to update log message for application', { - error: error.message, - applicationId: appId, - logMessageId: application.logMessageId - }); - } - } - - // Assign role if approved - if (isApprove) { - try { - const member = await interaction.guild.members.fetch( - application.userId, - ); - await member.roles.add(application.roleId); - } catch (error) { - logger.error('Failed to assign role to approved applicant', { - error: error.message, - userId: application.userId, - roleId: application.roleId, - applicationId: appId - }); - } - } - - // Respond to modal submission - await reasonSubmit.reply({ - embeds: [ - successEmbed( - `Application ${status}`, - `The application has been **${status}**.`, - ), - ], - flags: ["Ephemeral"], - }); - - } catch (error) { - logger.error('Error reviewing application:', error); - await buttonInteraction.reply({ - embeds: [errorEmbed('Error', 'An error occurred while reviewing the application.')], - flags: ["Ephemeral"], - }); - } - }); - - collector.on('end', async (collected, reason) => { - if (reason === 'time') { - const timeoutEmbed = createEmbed({ - title: '⏱️ Review Timeout', - description: 'The review buttons have timed out.', - color: 'warning', - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [timeoutEmbed], - components: [], - }).catch(() => {}); - } - }); -} - -async function handleList(interaction) { - const status = interaction.options.getString("status"); - const user = interaction.options.getUser("user"); - const limit = interaction.options.getNumber("limit") || 10; - - const filters = {}; - // Default to showing only pending applications if no status specified - if (status) { - filters.status = status; - } else { - filters.status = 'pending'; - } - - let applications = await getApplications( - interaction.client, - interaction.guild.id, - filters, - ); - - // Filter out applications from users who are no longer in the guild (except if filtering by specific user) - if (!user) { - applications = await Promise.all( - applications.map(async (app) => { - try { - await interaction.guild.members.fetch(app.userId); - return app; // User still in guild - } catch { - // User no longer in guild, delete the application - await deleteApplication(interaction.client, interaction.guild.id, app.id, app.userId); - return null; // Mark for removal - } - }) - ).then(results => results.filter(Boolean)); // Remove nulls - } - - if (user) { - applications = applications.filter((app) => app.userId === user.id); - } - - if (applications.length === 0) { - const applicationRoles = await getApplicationRoles(interaction.client, interaction.guild.id); - - 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:" - }); - - 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`, - inline: false - }); - }); - - embed.setFooter({ - text: "Users can apply with /apply submit or see available roles with /apply list" - }); - - return InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"] }); - } else { - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "No applications found and no application roles configured.\n" + - "Use `/app-admin roles add` to configure application roles first." - ), - ], - flags: ["Ephemeral"], - }); - } - } - - applications = applications - .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) - .slice(0, limit); - - const embed = createEmbed({ title: "Submitted Applications", description: `Showing ${applications.length} applications.`, }); - - applications.forEach((app) => { - const statusView = getApplicationStatusPresentation(app?.status); - const roleName = app?.roleName || 'Unknown Role'; - const username = app?.username || 'Unknown User'; - const createdAt = app?.createdAt ? new Date(app.createdAt) : null; - const createdAtDisplay = createdAt && !Number.isNaN(createdAt.getTime()) - ? createdAt.toLocaleString() - : 'Unknown date'; - - embed.addFields({ - name: `${statusView.statusEmoji} ${roleName} - ${username}`, - value: - `**ID:** \`${app.id}\`\n` + - `**Status:** ${statusView.statusEmoji} ${statusView.statusLabel}\n` + - `**Date:** ${createdAtDisplay}`, - inline: true, - }); - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - flags: ["Ephemeral"], - }); -} - -export async function handleApplicationReviewModal(interaction) { - if (!interaction.isModalSubmit()) return; - - const customId = interaction.customId; - if (!customId.startsWith('app_review_')) return; - - const [, appId, action] = customId.split('_'); - const reason = interaction.fields.getTextInputValue('reason') || 'No reason provided.'; - const isApprove = action === 'approve'; - - try { - const application = await getApplication(interaction.client, interaction.guild.id, appId); - if (!application) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Application not found.')], - flags: ["Ephemeral"] - }); - } - - const status = isApprove ? 'approved' : 'denied'; - await updateApplication(interaction.client, interaction.guild.id, appId, { - status, - reviewer: interaction.user.id, - reviewMessage: reason, - reviewedAt: new Date().toISOString() - }); - - try { - const user = await interaction.client.users.fetch(application.userId); - const reviewStatus = getApplicationStatusPresentation(status); - const dmEmbed = createEmbed( - `${reviewStatus.statusEmoji} Application ${reviewStatus.statusLabel}`, - `Your application for **${application.roleName}** has been **${status}**.\n` + - `**Note:** ${reason}\n\n` + - `Use \`/apply status id:${appId}\` to view details.`, - isApprove ? '#00FF00' : '#FF0000' - ); - - await user.send({ embeds: [dmEmbed] }); - } catch (error) { - logger.error('Error sending DM to user:', error); - } - - if (application.logMessageId && application.logChannelId) { - try { - const logChannel = interaction.guild.channels.cache.get(application.logChannelId); - if (logChannel) { - const logMessage = await logChannel.messages.fetch(application.logMessageId); - if (logMessage) { - const embed = logMessage.embeds[0]; - if (embed) { - const reviewStatus = getApplicationStatusPresentation(status); - const newEmbed = EmbedBuilder.from(embed) - .setColor(isApprove ? '#00FF00' : '#FF0000') - .spliceFields(0, 1, { - name: 'Status', - value: `${reviewStatus.statusEmoji} ${reviewStatus.statusLabel}` - }); - - await logMessage.edit({ - embeds: [newEmbed], - components: [] - }); - } - } - } - } catch (error) { - logger.error('Error updating log message:', error); - } - } - - if (isApprove) { - try { - const member = await interaction.guild.members.fetch(application.userId); - await member.roles.add(application.role); - } catch (error) { - logger.error('Error assigning role:', error); - } - } - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - `${getApplicationStatusPresentation(status).statusEmoji} Application ${getApplicationStatusPresentation(status).statusLabel}`, - `The application has been marked as ${getApplicationStatusPresentation(status).statusLabel}.` - ) - ], - flags: ["Ephemeral"] - }); - - } catch (error) { - logger.error('Error processing application review:', error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('An error occurred while processing the application.')], - flags: ["Ephemeral"] - }); - } -} - - - diff --git a/src/commands/Community/apply.js b/src/commands/Community/apply.js deleted file mode 100644 index f92bd14197..0000000000 --- a/src/commands/Community/apply.js +++ /dev/null @@ -1,433 +0,0 @@ -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, ActionRowBuilder, ModalBuilder, TextInputBuilder, TextInputStyle } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import ApplicationService from '../../services/applicationService.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { - getApplicationSettings, - getUserApplications, - createApplication, - getApplication, - getApplicationRoles, - updateApplication, - getApplicationRoleSettings -} from '../../utils/database.js'; - -function getApplicationStatusPresentation(statusValue) { - const normalized = typeof statusValue === 'string' ? statusValue.trim().toLowerCase() : 'unknown'; - const statusLabel = - normalized === 'pending' ? 'In Progress' : - normalized === 'approved' ? 'Accepted' : - normalized === 'denied' ? 'Denied' : - 'Unknown'; - const statusEmoji = - normalized === 'pending' ? '🟡' : - normalized === 'approved' ? '🟢' : - normalized === 'denied' ? '🔴' : - '⚪'; - - return { normalized, statusLabel, statusEmoji }; -} - -export default { - data: new SlashCommandBuilder() - .setName("apply") - .setDescription("Manage role applications") - .addSubcommand((subcommand) => - subcommand - .setName("submit") - .setDescription("Submit an application for a role") - .addStringOption((option) => - option - .setName("application") - .setDescription("The application you want to submit") - .setRequired(true) - .setAutocomplete(true), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("status") - .setDescription("Check the status of your application") - .addStringOption((option) => - option - .setName("id") - .setDescription("Application ID (leave empty to see all)") - .setRequired(false), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("list") - .setDescription("List available applications to apply for"), - ), - - category: "Community", - - execute: withErrorHandling(async (interaction) => { - if (!interaction.inGuild()) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed("This command can only be used in a server.")], - flags: ["Ephemeral"], - }); - } - - const { options, guild, member } = interaction; - const subcommand = options.getSubcommand(); - - if (subcommand !== "submit") { - const isListCommand = subcommand === "list"; - await InteractionHelper.safeDefer(interaction, { flags: isListCommand ? [] : ["Ephemeral"] }); - } - - logger.info(`Apply command executed: ${subcommand}`, { - userId: interaction.user.id, - guildId: guild.id, - subcommand - }); - - const settings = await getApplicationSettings( - interaction.client, - guild.id, - ); - - if (!settings.enabled) { - throw createError( - 'Applications are disabled', - ErrorTypes.CONFIGURATION, - 'Applications are currently disabled in this server.', - { guildId: guild.id } - ); - } - - if (subcommand === "submit") { - await handleSubmit(interaction, settings); - } else if (subcommand === "status") { - await handleStatus(interaction); - } else if (subcommand === "list") { - await handleList(interaction); - } - }, { type: 'command', commandName: 'apply' }) -}; - -export async function handleApplicationModal(interaction) { - if (!interaction.isModalSubmit()) return; - - const customId = interaction.customId; - if (!customId.startsWith('app_modal_')) return; - - const roleId = customId.split('_')[2]; - - const applicationRoles = await getApplicationRoles(interaction.client, interaction.guild.id); - const applicationRole = applicationRoles.find(appRole => appRole.roleId === roleId); - - if (!applicationRole) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Application configuration not found.')], - flags: ["Ephemeral"] - }); - } - - const role = interaction.guild.roles.cache.get(roleId); - - if (!role) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Role not found.')], - flags: ["Ephemeral"] - }); - } - - const answers = []; - const settings = await getApplicationSettings(interaction.client, interaction.guild.id); - - // Get questions - use per-application questions if they exist, otherwise use global - let questions = settings.questions || ["Why do you want this role?", "What is your experience?"]; - const roleSettings = await getApplicationRoleSettings(interaction.client, interaction.guild.id, roleId); - if (roleSettings.questions && roleSettings.questions.length > 0) { - questions = roleSettings.questions; - } - - for (let i = 0; i < questions.length; i++) { - const answer = interaction.fields.getTextInputValue(`q${i}`); - answers.push({ - question: questions[i], - answer: answer - }); - } - - try { - const application = await ApplicationService.submitApplication(interaction.client, { - guildId: interaction.guild.id, - userId: interaction.user.id, - roleId: roleId, - roleName: applicationRole.name, - username: interaction.user.tag, - avatar: interaction.user.displayAvatarURL(), - answers: answers - }); - - 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}\`` - ); - - 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); - - // Use per-application log channel if exists, otherwise use global - const logChannelId = roleSettings.logChannelId || settings.logChannelId; - - if (logChannelId) { - const logChannel = interaction.guild.channels.cache.get(logChannelId); - if (logChannel) { - const logEmbed = createEmbed({ - title: '📝 New Application', - description: `**User:** <@${interaction.user.id}> (${interaction.user.tag})\n` + - `**Application:** ${applicationRole.name}\n` + - `**Role:** ${role.name}\n` + - `**Application ID:** \`${application.id}\`\n` + - `**Status:** 🟡 In Progress` - }).setColor(getColor('warning')); - - const logMessage = await logChannel.send({ embeds: [logEmbed] }); - - await updateApplication(interaction.client, interaction.guild.id, application.id, { - logMessageId: logMessage.id, - logChannelId: logChannelId - }); - } - } - - } catch (error) { - logger.error('Error creating application:', { - error: error.message, - userId: interaction.user.id, - guildId: interaction.guild.id, - roleId, - stack: error.stack - }); - - await handleInteractionError(interaction, error, { - type: 'modal', - handler: 'application_submission' - }); - } -} - -async function handleList(interaction) { - try { - const applicationRoles = await getApplicationRoles(interaction.client, interaction.guild.id); - - if (applicationRoles.length === 0) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("No applications are currently available.")], - }); - } - - const embed = createEmbed({ - title: "Available Applications", - description: "Here are the roles you can apply for:" - }); - - 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}"\``, - inline: false - }); - }); - - embed.setFooter({ - text: "Use /apply submit application: to apply for any of these roles." - }); - - return InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } catch (error) { - logger.error('Error listing applications:', { - error: error.message, - guildId: interaction.guild.id, - stack: error.stack - }); - - throw createError( - 'Failed to load applications', - ErrorTypes.DATABASE, - 'Failed to load applications. Please try again later.', - { guildId: interaction.guild.id } - ); - } -} - -async function handleSubmit(interaction, settings) { - const applicationName = interaction.options.getString("application"); - const member = interaction.member; - - const applicationRoles = await getApplicationRoles(interaction.client, interaction.guild.id); - - const applicationRole = applicationRoles.find(appRole => - appRole.name.toLowerCase() === applicationName.toLowerCase() - ); - - if (!applicationRole) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Application not found.", - "Use `/apply list` to see available applications." - ), - ], - flags: ["Ephemeral"], - }); - } - - const userApps = await getUserApplications( - interaction.client, - interaction.guild.id, - interaction.user.id, - ); - const pendingApp = userApps.find((app) => app.status === "pending"); - - if (pendingApp) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - `You already have a pending application. Please wait for it to be reviewed.`, - ), - ], - flags: ["Ephemeral"], - }); - } - - const role = interaction.guild.roles.cache.get(applicationRole.roleId); - if (!role) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('The role for this application no longer exists.')], - flags: ["Ephemeral"] - }); - } - - const modal = new ModalBuilder() - .setCustomId(`app_modal_${applicationRole.roleId}`) - .setTitle(`Application for ${applicationRole.name}`); - - // Get questions - use per-application questions if they exist, otherwise use global - let questions = settings.questions || ["Why do you want this role?", "What is your experience?"]; - const roleSettings = await getApplicationRoleSettings(interaction.client, interaction.guild.id, applicationRole.roleId); - if (roleSettings.questions && roleSettings.questions.length > 0) { - questions = roleSettings.questions; - } - - questions.forEach((question, index) => { - const input = new TextInputBuilder() - .setCustomId(`q${index}`) - .setLabel( - question.length > 45 - ? `${question.substring(0, 42)}...` - : question, - ) - .setStyle(TextInputStyle.Paragraph) - .setRequired(true) - .setMaxLength(1000); - - const row = new ActionRowBuilder().addComponents(input); - modal.addComponents(row); - }); - - await interaction.showModal(modal); -} - -async function handleStatus(interaction) { - const appId = interaction.options.getString("id"); - - if (appId) { - const application = await getApplication( - interaction.client, - interaction.guild.id, - appId, - ); - - if (!application || application.userId !== interaction.user.id) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Application not found or you do not have permission to view it.", - ), - ], - flags: ["Ephemeral"], - }); - } - - const submittedAt = application?.createdAt ? new Date(application.createdAt) : null; - const submittedAtDisplay = submittedAt && !Number.isNaN(submittedAt.getTime()) - ? submittedAt.toLocaleString() - : 'Unknown date'; - const statusView = getApplicationStatusPresentation(application.status); - const embed = createEmbed({ - title: `Application #${application.id} - ${application.roleName || 'Unknown Role'}`, - description: - `**Application ID:** \`${application.id}\`\n` + - `**Status:** ${statusView.statusEmoji} ${statusView.statusLabel}\n` + - `**Submitted:** ${submittedAtDisplay}` - }); - - return InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"] }); - } else { - const applications = await getUserApplications( - interaction.client, - interaction.guild.id, - interaction.user.id, - ); - - if (applications.length === 0) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed("You have not submitted any applications yet."), - ], - flags: ["Ephemeral"], - }); - } - - const recentApplications = applications - .sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0)) - .slice(0, 10); - - const embed = createEmbed({ - title: "Your Applications", - description: `Showing ${recentApplications.length} recent application(s).` - }); - - recentApplications.forEach((application) => { - const submittedAt = application?.createdAt ? new Date(application.createdAt) : null; - const submittedAtDisplay = submittedAt && !Number.isNaN(submittedAt.getTime()) - ? submittedAt.toLocaleDateString() - : 'Unknown date'; - const statusView = getApplicationStatusPresentation(application.status); - - embed.addFields({ - name: `${statusView.statusEmoji} ${application.roleName || 'Unknown Role'} (${statusView.statusLabel})`, - value: - `**ID:** \`${application.id}\`\n` + - `**Status:** ${statusView.statusEmoji} ${statusView.statusLabel}\n` + - `**Submitted:** ${submittedAtDisplay}`, - inline: true, - }); - }); - - if (applications.length > recentApplications.length) { - embed.setFooter({ text: `Showing latest ${recentApplications.length} of ${applications.length} applications.` }); - } - - return InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"] }); - } -} - - - diff --git a/src/commands/Community/modules/app_dashboard.js b/src/commands/Community/modules/app_dashboard.js deleted file mode 100644 index beb9b7d849..0000000000 --- a/src/commands/Community/modules/app_dashboard.js +++ /dev/null @@ -1,1295 +0,0 @@ -import { getColor } from '../../../config/bot.js'; -import { - ActionRowBuilder, - StringSelectMenuBuilder, - StringSelectMenuOptionBuilder, - ModalBuilder, - TextInputBuilder, - TextInputStyle, - ChannelSelectMenuBuilder, - RoleSelectMenuBuilder, - ButtonBuilder, - ButtonStyle, - ChannelType, - MessageFlags, - ComponentType, - EmbedBuilder, - LabelBuilder, - CheckboxBuilder, - TextDisplayBuilder, -} from 'discord.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { successEmbed, errorEmbed } from '../../../utils/embeds.js'; -import { logger } from '../../../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../../../utils/errorHandler.js'; -import { safeDeferInteraction } from '../../../utils/interactionValidator.js'; -import { - getApplicationSettings, - saveApplicationSettings, - getApplicationRoles, - saveApplicationRoles, - getApplicationRoleSettings, - saveApplicationRoleSettings, - deleteApplicationRoleSettings, - getApplications, - deleteApplication, -} from '../../../utils/database.js'; - -// ─── Embed & Menu Builders ──────────────────────────────────────────────────── - -function buildDashboardEmbed(settings, roles, guild) { - const logChannel = settings.logChannelId ? `<#${settings.logChannelId}>` : '`Not set`'; - const managerRoleList = - settings.managerRoles?.length > 0 - ? settings.managerRoles.map(id => `<@&${id}>`).join(', ') - : '`None configured`'; - const roleList = - roles.length > 0 - ? roles.map(r => `<@&${r.roleId}> — ${r.name}`).join('\n') - : '`No application roles configured`'; - 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`'; - - return new EmbedBuilder() - .setTitle('📋 Applications Dashboard') - .setDescription(`Manage application settings for **${guild.name}**.\nSelect an option below to modify a setting.`) - .setColor(getColor('info')) - .addFields( - { name: '⚙️ Application Status', value: settings.enabled ? '✅ Enabled' : '❌ Disabled', inline: true }, - { name: '📢 Log Channel', 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: '🗑️ Retention', - value: `Pending: **${settings.pendingApplicationRetentionDays ?? 30}d** · Reviewed: **${settings.reviewedApplicationRetentionDays ?? 14}d**`, - inline: false, - }, - ) - .setFooter({ text: 'Dashboard closes after 15 minutes of inactivity' }) - .setTimestamp(); -} - -function buildSelectMenu(guildId) { - return new StringSelectMenuBuilder() - .setCustomId(`app_cfg_${guildId}`) - .setPlaceholder('Select a setting to configure...') - .addOptions( - new StringSelectMenuOptionBuilder() - .setLabel('Log Channel') - .setDescription('Set the channel where new applications are logged') - .setValue('log_channel') - .setEmoji('📢'), - new StringSelectMenuOptionBuilder() - .setLabel('Manager Roles') - .setDescription('Add or remove a role that can manage applications') - .setValue('manager_role') - .setEmoji('🛡️'), - new StringSelectMenuOptionBuilder() - .setLabel('Edit Questions') - .setDescription('Customise the questions shown on the application form') - .setValue('questions') - .setEmoji('📝'), - new StringSelectMenuOptionBuilder() - .setLabel('Add Application Role') - .setDescription('Add a role that members can apply for') - .setValue('role_add') - .setEmoji('➕'), - new StringSelectMenuOptionBuilder() - .setLabel('Remove Application Role') - .setDescription('Remove a role from the applications list') - .setValue('role_remove') - .setEmoji('➖'), - new StringSelectMenuOptionBuilder() - .setLabel('Retention Period') - .setDescription('Set how long pending and reviewed applications are kept') - .setValue('retention') - .setEmoji('🗑️'), - ); -} - -function buildButtonRow(settings, guildId, disabled = false) { - const systemOn = settings.enabled === true; - return new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`app_cfg_toggle_${guildId}`) - .setLabel('Applications') - .setStyle(systemOn ? ButtonStyle.Success : ButtonStyle.Danger) - .setDisabled(disabled), - ); -} - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -async function refreshDashboard(rootInteraction, settings, roles, guildId) { - const selectMenu = buildSelectMenu(guildId); - await InteractionHelper.safeEditReply(rootInteraction, { - embeds: [buildDashboardEmbed(settings, roles, rootInteraction.guild)], - components: [ - buildButtonRow(settings, guildId), - new ActionRowBuilder().addComponents(selectMenu), - ], - }).catch(() => {}); -} - -// ─── Main Export ────────────────────────────────────────────────────────────── - -export default { - async execute(interaction, config, client, selectedAppName = null) { - try { - const guildId = interaction.guild.id; - - // Defer immediately to prevent Discord interaction timeout - await InteractionHelper.safeDefer(interaction, { flags: ['Ephemeral'] }); - - const [settings, roles] = await Promise.all([ - getApplicationSettings(client, guildId), - getApplicationRoles(client, guildId), - ]); - - // Check if application system is completely unconfigured - const isCompletelyUnconfigured = - !settings.logChannelId && - !settings.enabled && - (settings.managerRoles?.length ?? 0) === 0 && - roles.length === 0; - - if (isCompletelyUnconfigured) { - throw new TitanBotError( - '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.', - ); - } - - // If no application roles exist, show global settings to add one - if (roles.length === 0) { - await showGlobalDashboard(interaction, settings, roles, guildId, client); - return; - } - - // If a specific app was selected via autocomplete, show its dashboard directly - if (selectedAppName) { - const selectedRole = roles.find(r => r.name.toLowerCase() === selectedAppName.toLowerCase()); - if (selectedRole) { - await showApplicationDashboard(interaction, selectedRole, settings, roles, guildId, client); - return; - } - // If name doesn't match, fall through - } - - // Default: Show first application if no selection made - const defaultRole = roles[0]; - await showApplicationDashboard(interaction, defaultRole, settings, roles, guildId, client); - - } catch (error) { - if (error instanceof TitanBotError) throw error; - logger.error('Unexpected error in app_dashboard:', error); - throw new TitanBotError( - `Applications dashboard failed: ${error.message}`, - ErrorTypes.UNKNOWN, - 'Failed to open the applications dashboard.', - ); - } - }, -}; - -// ─── Application Selector (for multiple applications) ────────────────────────── - -async function showApplicationSelector(interaction, roles, settings, guildId, client) { - const selectMenu = new StringSelectMenuBuilder() - .setCustomId(`app_select_${guildId}`) - .setPlaceholder('Select an application to configure...') - .addOptions( - roles.map(role => - new StringSelectMenuOptionBuilder() - .setLabel(role.name) - .setDescription(`Configure the ${role.name} application`) - .setValue(role.roleId) - .setEmoji('📋'), - ), - ); - - const embed = new EmbedBuilder() - .setTitle('🎯 Select Application') - .setDescription('Choose which application role you want to configure.') - .setColor(getColor('info')); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - components: [new ActionRowBuilder().addComponents(selectMenu)], - }); - - const collector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.StringSelect, - filter: i => - i.user.id === interaction.user.id && i.customId === `app_select_${guildId}`, - time: 600_000, - max: 1, - }); - - collector.on('collect', async selectInteraction => { - const deferred = await safeDeferInteraction(selectInteraction); - if (!deferred) return; - - const selectedRoleId = selectInteraction.values[0]; - const selectedRole = roles.find(r => r.roleId === selectedRoleId); - - if (selectedRole) { - await showApplicationDashboard(interaction, selectedRole, settings, roles, guildId, client); - } - }); - - collector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Timed Out', 'No selection was made. The dashboard has closed.')], - components: [], - }).catch(() => {}); - } - }); -} - -// ─── Global Dashboard ────────────────────────────────────────────────────────── - -async function showGlobalDashboard(interaction, settings, roles, guildId, client) { - const selectMenu = buildSelectMenu(guildId); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [buildDashboardEmbed(settings, roles, interaction.guild)], - components: [ - buildButtonRow(settings, guildId), - new ActionRowBuilder().addComponents(selectMenu), - ], - }); - - setupCollectors(interaction, settings, roles, guildId, client, null); -} - -// ─── Application-Specific Dashboard ──────────────────────────────────────────── - -async function showApplicationDashboard(rootInteraction, selectedRole, settings, roles, guildId, client) { - const roleObj = rootInteraction.guild.roles.cache.get(selectedRole.roleId); - - // Get application-specific settings - const appSettings = await getApplicationRoleSettings(client, guildId, selectedRole.roleId); - const questions = appSettings.questions || settings.questions || []; - const appLogChannelId = appSettings.logChannelId || settings.logChannelId; - const isEnabled = selectedRole.enabled !== false; // Default to true if not specified - - // Build comprehensive embed - const logChannelDisplay = appLogChannelId - ? `<#${appLogChannelId}>` - : '`Inherits global log channel`'; - - const questionsDisplay = questions.length > 0 - ? 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(', ') - : '`None configured`'; - - const embed = new EmbedBuilder() - .setTitle('🎭 Application Dashboard') - .setDescription(`Configuration for **${selectedRole.name}**`) - .setColor(isEnabled ? getColor('success') : getColor('error')) - .addFields( - { - name: '🎭 Role', - value: roleObj ? roleObj.toString() : `<@&${selectedRole.roleId}>`, - inline: true - }, - { - name: '⚙️ Application Status', - value: isEnabled ? '✅ **Enabled**' : '❌ **Disabled**', - inline: true - }, - { name: '\u200B', value: '\u200B', inline: true }, - { - name: '📝 Questions', - value: questionsDisplay, - inline: false - }, - { - name: '📢 Log Channel', - value: logChannelDisplay, - inline: true - }, - { - name: '🛡️ Manager Roles', - value: managerRolesDisplay, - inline: true - }, - { - name: '🗑️ Retention Period', - value: `Pending: **${settings.pendingApplicationRetentionDays ?? 30}d** · Reviewed: **${settings.reviewedApplicationRetentionDays ?? 14}d**`, - inline: false - }, - ) - .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) - .setTimestamp(); - - // Create dropdown button with customization options - const configMenu = buildApplicationSelectMenu(guildId, selectedRole.roleId); - - // Create control buttons - const controlButtons = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`app_toggle_${selectedRole.roleId}`) - .setLabel(isEnabled ? 'Disable Application' : 'Enable Application') - .setStyle(isEnabled ? ButtonStyle.Danger : ButtonStyle.Success), - new ButtonBuilder() - .setCustomId(`app_delete_${selectedRole.roleId}`) - .setLabel('Delete Application') - .setStyle(ButtonStyle.Danger) - .setEmoji('🗑️'), - ); - - const menuRow = new ActionRowBuilder().addComponents(configMenu); - - await InteractionHelper.safeEditReply(rootInteraction, { - embeds: [embed], - components: [menuRow, controlButtons], - }); - - setupCollectors(rootInteraction, settings, roles, guildId, client, selectedRole.roleId); -} - -// ─── Collector Setup ────────────────────────────────────────────────────────── - -function setupCollectors(interaction, settings, roles, guildId, client, selectedRoleId) { - const customIdPrefix = selectedRoleId ? `app_cfg_${selectedRoleId}` : `app_cfg_${guildId}`; - - const collector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.StringSelect, - filter: i => - i.user.id === interaction.user.id && - (selectedRoleId - ? i.customId === customIdPrefix - : (i.customId === `app_cfg_${guildId}` || i.customId === `app_select_${guildId}`)), - time: 600_000, - }); - - collector.on('collect', async selectInteraction => { - const selectedOption = selectInteraction.values[0]; - try { - // Catch expired interactions - if (!selectInteraction.isStringSelectMenu()) { - return; - } - switch (selectedOption) { - case 'log_channel': - await handleLogChannel(selectInteraction, interaction, settings, roles, guildId, client, selectedRoleId); - break; - case 'manager_role': - await handleManagerRole(selectInteraction, interaction, settings, roles, guildId, client, selectedRoleId); - break; - case 'questions': - await handleQuestions(selectInteraction, interaction, settings, roles, guildId, client, selectedRoleId); - break; - case 'role_add': - await handleRoleAdd(selectInteraction, interaction, settings, roles, guildId, client); - break; - case 'role_remove': - await handleRoleRemove(selectInteraction, interaction, settings, roles, guildId, client); - break; - case 'retention': - await handleRetention(selectInteraction, interaction, settings, roles, guildId, client, selectedRoleId); - break; - } - } catch (error) { - if (error instanceof TitanBotError) { - logger.debug(`Applications config validation error: ${error.message}`); - } else { - logger.error('Unexpected applications dashboard error:', error); - } - - const errorMessage = - error instanceof TitanBotError - ? error.userMessage || 'An error occurred while processing your selection.' - : 'An unexpected error occurred while updating the configuration.'; - - if (!selectInteraction.replied && !selectInteraction.deferred) { - await safeDeferInteraction(selectInteraction); - } - - await selectInteraction - .followUp({ - embeds: [errorEmbed('Configuration Error', errorMessage)], - flags: MessageFlags.Ephemeral, - }) - .catch(() => {}); - } - }); - - 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.') - .setColor(getColor('error')); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [timeoutEmbed], - components: [], - }).catch(() => {}); - } - }); - - // ── Global Toggle Button Collector ────────────────────────────────────────── - if (!selectedRoleId) { - const globalToggleCollector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.Button, - filter: i => - i.user.id === interaction.user.id && - i.customId === `app_cfg_toggle_${guildId}`, - time: 600_000, - }); - - globalToggleCollector.on('collect', async toggleInteraction => { - const deferred = await safeDeferInteraction(toggleInteraction); - if (!deferred) return; - - try { - const wasEnabled = settings.enabled === true; - settings.enabled = !wasEnabled; - - // Save the updated settings - await saveApplicationSettings(interaction.client, guildId, settings); - - // Refresh dashboard to show new status - const updatedSettings = await getApplicationSettings(interaction.client, guildId); - const updatedRoles = await getApplicationRoles(interaction.client, guildId); - await showGlobalDashboard(interaction, updatedSettings, updatedRoles, guildId, interaction.client); - - await toggleInteraction.followUp({ - embeds: [successEmbed( - wasEnabled ? '🔴 Applications Disabled' : '🟢 Applications Enabled', - `The applications system is now **${wasEnabled ? 'disabled' : 'enabled'}**.\n\n${ - wasEnabled - ? 'Members will no longer be able to apply for roles.' - : 'Members can now start applying for roles.' - }`, - )], - flags: MessageFlags.Ephemeral, - }); - - } catch (error) { - logger.error('Error toggling global application status:', error); - await toggleInteraction.followUp({ - embeds: [errorEmbed('Error', 'An error occurred while toggling the application status.')], - flags: MessageFlags.Ephemeral, - }); - } - }); - - 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.') - .setColor(getColor('warning')); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [timeoutEmbed], - components: [], - }).catch(() => {}); - } - }); - } - - // ── Delete Button Collector (for application-specific dashboard) ────────────── - if (selectedRoleId) { - const btnCollector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.Button, - filter: i => - i.user.id === interaction.user.id && - i.customId === `app_delete_${selectedRoleId}`, - time: 600_000, - }); - - btnCollector.on('collect', async btnInteraction => { - // Show confirmation modal - const appRoleForDelete = roles.find(r => r.roleId === selectedRoleId); - const appNameForDelete = appRoleForDelete?.name ?? 'this application'; - - const confirmModal = new ModalBuilder() - .setCustomId('app_delete_confirm') - .setTitle('Confirm Application Deletion'); - - 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.`); - - const deleteCheckbox = new CheckboxBuilder() - .setCustomId('confirm_delete') - .setDefault(false); - - const deleteCheckboxLabel = new LabelBuilder() - .setLabel('I confirm — this cannot be undone') - .setCheckboxComponent(deleteCheckbox); - - confirmModal - .addTextDisplayComponents(deleteWarningText) - .addLabelComponents(deleteCheckboxLabel); - - try { - await btnInteraction.showModal(confirmModal); - } catch (error) { - logger.error('Error showing delete confirmation modal:', error); - await btnInteraction.followUp({ - embeds: [errorEmbed('Error', 'Failed to show confirmation modal. Please try again.')], - flags: MessageFlags.Ephemeral, - }).catch(() => {}); - return; - } - - try { - const confirmSubmit = await btnInteraction.awaitModalSubmit({ - time: 60_000, - filter: i => - i.customId === 'app_delete_confirm' && i.user.id === btnInteraction.user.id, - }).catch(() => null); - - if (!confirmSubmit) { - await btnInteraction.followUp({ - embeds: [errorEmbed('Cancelled', 'Application deletion was cancelled.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - - const confirmed = confirmSubmit.fields.getCheckbox('confirm_delete'); - if (!confirmed) { - await confirmSubmit.reply({ - embeds: [errorEmbed('Not Confirmed', 'You must tick the confirmation checkbox to delete the application.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - - // Delete the application - await handleDeleteApplication(confirmSubmit, selectedRoleId, guildId, roles, client); - collector.stop(); - btnCollector.stop(); - - } catch (error) { - logger.error('Error confirming application deletion:', error); - await btnInteraction.followUp({ - embeds: [errorEmbed('Error', 'An error occurred while deleting the application.')], - flags: MessageFlags.Ephemeral, - }); - } - }); - - 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.') - .setColor(getColor('warning')); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [timeoutEmbed], - components: [], - }).catch(() => {}); - } - }); - - // ── Toggle Enable/Disable Button Collector ────────────────────────────── - const toggleCollector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.Button, - filter: i => - i.user.id === interaction.user.id && - i.customId === `app_toggle_${selectedRoleId}`, - time: 900_000, - }); - - toggleCollector.on('collect', async toggleInteraction => { - const deferred = await safeDeferInteraction(toggleInteraction); - if (!deferred) return; - - try { - // Find and toggle the role - const roleIndex = roles.findIndex(r => r.roleId === selectedRoleId); - if (roleIndex === -1) { - await toggleInteraction.followUp({ - embeds: [errorEmbed('Not Found', 'Application role not found.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - - const wasEnabled = roles[roleIndex].enabled !== false; - roles[roleIndex].enabled = !wasEnabled; - - // Save the updated roles - await saveApplicationRoles(interaction.client, guildId, roles); - - // Refresh dashboard to show new status - const updatedRole = roles[roleIndex]; - const updatedSettings = await getApplicationSettings(interaction.client, guildId); - await showApplicationDashboard(interaction, updatedRole, updatedSettings, roles, guildId, interaction.client); - - await toggleInteraction.followUp({ - embeds: [successEmbed( - wasEnabled ? '🔴 Application Disabled' : '🟢 Application Enabled', - `The **${updatedRole.name}** application is now **${wasEnabled ? 'disabled' : 'enabled'}**.\n\n${ - wasEnabled - ? 'This application will no longer appear in `/apply submit` options.' - : 'This application will now appear in `/apply submit` options.' - }`, - )], - flags: MessageFlags.Ephemeral, - }); - - } catch (error) { - logger.error('Error toggling application status:', error); - await toggleInteraction.followUp({ - embeds: [errorEmbed('Error', 'An error occurred while toggling the application status.')], - flags: MessageFlags.Ephemeral, - }); - } - }); - - 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.') - .setColor(getColor('warning')); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [timeoutEmbed], - components: [], - }).catch(() => {}); - } - }); - } -} - -// ─── Build Select Menus ──────────────────────────────────────────────────────── - -function buildApplicationSelectMenu(guildId, roleId) { - return new StringSelectMenuBuilder() - .setCustomId(`app_cfg_${roleId}`) - .setPlaceholder('Select a setting to configure...') - .addOptions( - new StringSelectMenuOptionBuilder() - .setLabel('Log Channel') - .setDescription('Set the channel where applications are logged') - .setValue('log_channel') - .setEmoji('📢'), - new StringSelectMenuOptionBuilder() - .setLabel('Manager Roles') - .setDescription('Add or remove a role that can manage applications') - .setValue('manager_role') - .setEmoji('🛡️'), - new StringSelectMenuOptionBuilder() - .setLabel('Edit Questions') - .setDescription('Customise the questions shown on the application form') - .setValue('questions') - .setEmoji('📝'), - new StringSelectMenuOptionBuilder() - .setLabel('Retention Period') - .setDescription('Set how long pending and reviewed applications are kept') - .setValue('retention') - .setEmoji('🗑️'), - ); -} - -// ─── Log Channel ────────────────────────────────────────────────────────────── - -async function handleLogChannel(selectInteraction, rootInteraction, settings, roles, guildId, client, selectedRoleId) { - const deferred = await safeDeferInteraction(selectInteraction); - if (!deferred) return; - - const channelSelect = new ChannelSelectMenuBuilder() - .setCustomId('app_cfg_log_channel') - .setPlaceholder('Select a text channel...') - .addChannelTypes(ChannelType.GuildText) - .setMaxValues(1); - - let currentChannel = settings.logChannelId; - if (selectedRoleId) { - const roleSettings = await getApplicationRoleSettings(client, guildId, selectedRoleId); - currentChannel = roleSettings.logChannelId || settings.logChannelId; - } - - await selectInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('📢 Log Channel') - .setDescription( - `**Current:** ${currentChannel ? `<#${currentChannel}>` : '`Not set`'}\n\nSelect the channel where new application submissions will be logged.`, - ) - .setColor(getColor('info')), - ], - components: [new ActionRowBuilder().addComponents(channelSelect)], - flags: MessageFlags.Ephemeral, - }); - - const chanCollector = rootInteraction.channel.createMessageComponentCollector({ - componentType: ComponentType.ChannelSelect, - filter: i => - i.user.id === selectInteraction.user.id && i.customId === 'app_cfg_log_channel', - time: 60_000, - max: 1, - }); - - chanCollector.on('collect', async chanInteraction => { - const deferred = await safeDeferInteraction(chanInteraction); - if (!deferred) return; - - const channel = chanInteraction.channels.first(); - - if (!channel.isTextBased()) { - await chanInteraction.followUp({ - embeds: [errorEmbed('Invalid Channel', 'Please select a text channel.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - - if (selectedRoleId) { - // Save per-application log channel - const roleSettings = await getApplicationRoleSettings(client, guildId, selectedRoleId); - roleSettings.logChannelId = channel.id; - await saveApplicationRoleSettings(client, guildId, selectedRoleId, roleSettings); - } else { - // Save global log channel - settings.logChannelId = channel.id; - await saveApplicationSettings(client, guildId, settings); - } - - await chanInteraction.followUp({ - embeds: [successEmbed('✅ Log Channel Updated', `Application submissions will now be logged in ${channel}.`)], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, settings, roles, guildId); - }); - - chanCollector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - selectInteraction.followUp({ - embeds: [errorEmbed('Timed Out', 'No channel was selected. The setting was not changed.')], - flags: MessageFlags.Ephemeral, - }).catch(() => {}); - } - }); -} - -// ─── Manager Role ───────────────────────────────────────────────────────────── - -async function handleManagerRole(selectInteraction, rootInteraction, settings, roles, guildId, client) { - const deferred = await safeDeferInteraction(selectInteraction); - if (!deferred) return; - - const currentRoles = settings.managerRoles ?? []; - const currentList = - currentRoles.length > 0 ? currentRoles.map(id => `<@&${id}>`).join(', ') : '`None`'; - - const roleSelect = new RoleSelectMenuBuilder() - .setCustomId('app_cfg_manager_role') - .setPlaceholder('Select a role to add or remove...') - .setMaxValues(1); - - await selectInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('🛡️ Manager Roles') - .setDescription( - `**Current:** ${currentList}\n\nSelect a role to **toggle** it — selecting an existing manager role will remove it, selecting a new one will add it.`, - ) - .setColor(getColor('info')), - ], - components: [new ActionRowBuilder().addComponents(roleSelect)], - flags: MessageFlags.Ephemeral, - }); - - const roleCollector = rootInteraction.channel.createMessageComponentCollector({ - componentType: ComponentType.RoleSelect, - filter: i => - i.user.id === selectInteraction.user.id && i.customId === 'app_cfg_manager_role', - time: 60_000, - max: 1, - }); - - roleCollector.on('collect', async roleInteraction => { - const deferred = await safeDeferInteraction(roleInteraction); - if (!deferred) return; - - const role = roleInteraction.roles.first(); - const roleSet = new Set(settings.managerRoles ?? []); - const wasPresent = roleSet.has(role.id); - - if (wasPresent) { - roleSet.delete(role.id); - } else { - roleSet.add(role.id); - } - - settings.managerRoles = Array.from(roleSet); - await saveApplicationSettings(client, guildId, settings); - - await roleInteraction.followUp({ - embeds: [ - successEmbed( - '✅ Manager Role Updated', - `${role} has been **${wasPresent ? 'removed from' : 'added to'}** the manager roles list.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, settings, roles, guildId); - }); - - roleCollector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - selectInteraction.followUp({ - embeds: [errorEmbed('Timed Out', 'No role was selected. The setting was not changed.')], - flags: MessageFlags.Ephemeral, - }).catch(() => {}); - } - }); -} - -// ─── Edit Questions ─────────────────────────────────────────────────────────── - -async function handleQuestions(selectInteraction, rootInteraction, settings, roles, guildId, client, selectedRoleId) { - let currentQuestions = settings.questions ?? []; - - if (selectedRoleId) { - const roleSettings = await getApplicationRoleSettings(client, guildId, selectedRoleId); - currentQuestions = roleSettings.questions ?? currentQuestions; - } - - const modal = new ModalBuilder() - .setCustomId('app_cfg_questions') - .setTitle('Edit Application Questions') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('q1') - .setLabel('Question 1 (required)') - .setStyle(TextInputStyle.Short) - .setValue(currentQuestions[0] ?? '') - .setMaxLength(100) - .setMinLength(1) - .setRequired(true), - ), - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('q2') - .setLabel('Question 2 (optional)') - .setStyle(TextInputStyle.Short) - .setValue(currentQuestions[1] ?? '') - .setMaxLength(100) - .setRequired(false), - ), - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('q3') - .setLabel('Question 3 (optional)') - .setStyle(TextInputStyle.Short) - .setValue(currentQuestions[2] ?? '') - .setMaxLength(100) - .setRequired(false), - ), - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('q4') - .setLabel('Question 4 (optional)') - .setStyle(TextInputStyle.Short) - .setValue(currentQuestions[3] ?? '') - .setMaxLength(100) - .setRequired(false), - ), - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('q5') - .setLabel('Question 5 (optional)') - .setStyle(TextInputStyle.Short) - .setValue(currentQuestions[4] ?? '') - .setMaxLength(100) - .setRequired(false), - ), - ); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => - i.customId === 'app_cfg_questions' && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - const newQuestions = ['q1', 'q2', 'q3', 'q4', 'q5'] - .map(key => submitted.fields.getTextInputValue(key).trim()) - .filter(Boolean); - - if (newQuestions.length === 0) { - await submitted.reply({ - embeds: [errorEmbed('No Questions', 'At least one question is required.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - - if (selectedRoleId) { - // Save per-application questions - const roleSettings = await getApplicationRoleSettings(client, guildId, selectedRoleId); - roleSettings.questions = newQuestions; - await saveApplicationRoleSettings(client, guildId, selectedRoleId, roleSettings); - } else { - // Save global questions - settings.questions = newQuestions; - await saveApplicationSettings(client, guildId, settings); - } - - await submitted.reply({ - embeds: [ - successEmbed( - '✅ Questions Updated', - `${newQuestions.length} question${newQuestions.length !== 1 ? 's' : ''} saved.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, settings, roles, guildId); -} - -// ─── Add Application Role ───────────────────────────────────────────────────── - -async function handleRoleAdd(selectInteraction, rootInteraction, settings, roles, guildId, client) { - const deferred = await safeDeferInteraction(selectInteraction); - if (!deferred) return; - - const roleSelect = new RoleSelectMenuBuilder() - .setCustomId('app_cfg_role_add_pick') - .setPlaceholder('Select the Discord role to add...') - .setMaxValues(1); - - await selectInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('➕ Add Application Role') - .setDescription( - 'Select a role that members can apply for. You can optionally set a custom display name after selecting.', - ) - .setColor(getColor('info')), - ], - components: [new ActionRowBuilder().addComponents(roleSelect)], - flags: MessageFlags.Ephemeral, - }); - - const roleCollector = rootInteraction.channel.createMessageComponentCollector({ - componentType: ComponentType.RoleSelect, - filter: i => - i.user.id === selectInteraction.user.id && i.customId === 'app_cfg_role_add_pick', - time: 60_000, - max: 1, - }); - - roleCollector.on('collect', async roleInteraction => { - const role = roleInteraction.roles.first(); - - // Check for duplicate - if (roles.some(r => r.roleId === role.id)) { - const deferred = await safeDeferInteraction(roleInteraction); - if (!deferred) return; - - await roleInteraction.followUp({ - embeds: [errorEmbed('Already Added', `${role} is already an application role.`)], - flags: MessageFlags.Ephemeral, - }); - return; - } - - // Show modal for optional custom name - const nameModal = new ModalBuilder() - .setCustomId('app_cfg_role_add_name') - .setTitle('Application Role Name') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('role_name') - .setLabel('Display name (leave blank to use role name)') - .setStyle(TextInputStyle.Short) - .setValue(role.name) - .setMaxLength(50) - .setRequired(false), - ), - ); - - await roleInteraction.showModal(nameModal); - - const nameSubmit = await roleInteraction - .awaitModalSubmit({ - filter: i => - i.customId === 'app_cfg_role_add_name' && i.user.id === roleInteraction.user.id, - time: 60_000, - }) - .catch(() => null); - - if (!nameSubmit) return; - - const customName = nameSubmit.fields.getTextInputValue('role_name').trim() || role.name; - - roles.push({ roleId: role.id, name: customName }); - await saveApplicationRoles(client, guildId, roles); - - await nameSubmit.reply({ - embeds: [ - successEmbed( - '✅ Role Added', - `${role} has been added as an application role with name **${customName}**.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, settings, roles, guildId); - }); - - roleCollector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - selectInteraction.followUp({ - embeds: [errorEmbed('Timed Out', 'No role was selected. Nothing was added.')], - flags: MessageFlags.Ephemeral, - }).catch(() => {}); - } - }); -} - -// ─── Remove Application Role ────────────────────────────────────────────────── - -async function handleRoleRemove(selectInteraction, rootInteraction, settings, roles, guildId, client) { - const deferred = await safeDeferInteraction(selectInteraction); - if (!deferred) return; - - if (roles.length === 0) { - await selectInteraction.followUp({ - embeds: [errorEmbed('No Roles', 'There are no application roles configured to remove.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - - const roleSelect = new RoleSelectMenuBuilder() - .setCustomId('app_cfg_role_remove_pick') - .setPlaceholder('Select the role to remove...') - .setMaxValues(1); - - await selectInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('➖ Remove Application Role') - .setDescription( - `**Current roles:** ${roles.map(r => `<@&${r.roleId}> (${r.name})`).join(', ')}\n\nSelect the role to remove from the applications list.`, - ) - .setColor(getColor('info')), - ], - components: [new ActionRowBuilder().addComponents(roleSelect)], - flags: MessageFlags.Ephemeral, - }); - - const roleCollector = rootInteraction.channel.createMessageComponentCollector({ - componentType: ComponentType.RoleSelect, - filter: i => - i.user.id === selectInteraction.user.id && i.customId === 'app_cfg_role_remove_pick', - time: 60_000, - max: 1, - }); - - roleCollector.on('collect', async roleInteraction => { - const deferred = await safeDeferInteraction(roleInteraction); - if (!deferred) return; - - const role = roleInteraction.roles.first(); - const index = roles.findIndex(r => r.roleId === role.id); - - if (index === -1) { - await roleInteraction.followUp({ - embeds: [errorEmbed('Not Found', `${role} is not in the application roles list.`)], - flags: MessageFlags.Ephemeral, - }); - return; - } - - roles.splice(index, 1); - await saveApplicationRoles(client, guildId, roles); - - await roleInteraction.followUp({ - embeds: [successEmbed('✅ Role Removed', `${role} has been removed from the application roles.`)], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, settings, roles, guildId); - }); - - roleCollector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - selectInteraction.followUp({ - embeds: [errorEmbed('Timed Out', 'No role was selected. Nothing was removed.')], - flags: MessageFlags.Ephemeral, - }).catch(() => {}); - } - }); -} - -// ─── Retention Period ───────────────────────────────────────────────────────── - -async function handleRetention(selectInteraction, rootInteraction, settings, roles, guildId, client) { - const modal = new ModalBuilder() - .setCustomId('app_cfg_retention') - .setTitle('Application Retention Periods'); - - 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).', - ); - - const pendingLabel = new LabelBuilder() - .setLabel('Pending retention (days)') - .setTextInputComponent( - new TextInputBuilder() - .setCustomId('pending_days') - .setStyle(TextInputStyle.Short) - .setValue(String(settings.pendingApplicationRetentionDays ?? 30)) - .setMaxLength(4) - .setMinLength(1) - .setRequired(true), - ); - - const reviewedLabel = new LabelBuilder() - .setLabel('Reviewed retention (days)') - .setTextInputComponent( - new TextInputBuilder() - .setCustomId('reviewed_days') - .setStyle(TextInputStyle.Short) - .setValue(String(settings.reviewedApplicationRetentionDays ?? 14)) - .setMaxLength(4) - .setMinLength(1) - .setRequired(true), - ); - - modal - .addTextDisplayComponents(retentionInfo) - .addLabelComponents(pendingLabel, reviewedLabel); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => - i.customId === 'app_cfg_retention' && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - const pendingDays = parseInt(submitted.fields.getTextInputValue('pending_days').trim(), 10); - const reviewedDays = parseInt(submitted.fields.getTextInputValue('reviewed_days').trim(), 10); - - if (isNaN(pendingDays) || pendingDays < 1 || pendingDays > 3650) { - await submitted.reply({ - embeds: [errorEmbed('Invalid Value', 'Pending retention must be a whole number between **1** and **3650** days.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - - if (isNaN(reviewedDays) || reviewedDays < 1 || reviewedDays > 3650) { - await submitted.reply({ - embeds: [errorEmbed('Invalid Value', 'Reviewed retention must be a whole number between **1** and **3650** days.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - - settings.pendingApplicationRetentionDays = pendingDays; - settings.reviewedApplicationRetentionDays = reviewedDays; - await saveApplicationSettings(client, guildId, settings); - - await submitted.reply({ - embeds: [ - successEmbed( - '✅ Retention Updated', - `Pending applications will be kept for **${pendingDays} days**.\nReviewed applications will be kept for **${reviewedDays} days**.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, settings, roles, guildId); -} - -// ─── Delete Application ─────────────────────────────────────────────────────── - -async function handleDeleteApplication(confirmSubmit, selectedRoleId, guildId, roles, client) { - try { - // Find the application in the roles array - const roleIndex = roles.findIndex(r => r.roleId === selectedRoleId); - if (roleIndex === -1) { - await confirmSubmit.reply({ - embeds: [errorEmbed('Not Found', 'Application role not found.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - - const deletedRole = roles[roleIndex]; - - // Remove from roles array - roles.splice(roleIndex, 1); - - // Save updated roles list - await saveApplicationRoles(client, guildId, roles); - - // Delete per-application settings - await deleteApplicationRoleSettings(client, guildId, selectedRoleId); - - // Get all applications for this guild and find ones with this roleId - const allApplications = await getApplications(client, guildId); - const applicationsToDelete = allApplications.filter(app => app.roleId === selectedRoleId); - - // Delete each application - for (const app of applicationsToDelete) { - await deleteApplication(client, guildId, app.id, app.userId); - } - - // Send success message - 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' : ''}`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - } catch (error) { - logger.error('Error in handleDeleteApplication:', error); - await confirmSubmit.reply({ - embeds: [errorEmbed('Error', 'An error occurred while deleting the application. Please try again.')], - flags: MessageFlags.Ephemeral, - }); - } -} diff --git a/src/commands/Core/bug.js b/src/commands/Core/bug.js deleted file mode 100644 index 9703be0ee6..0000000000 --- a/src/commands/Core/bug.js +++ /dev/null @@ -1,40 +0,0 @@ -import { SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js'; -import { createEmbed } from '../../utils/embeds.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("bug") - .setDescription("Report a bug or issue with the bot"), - - async execute(interaction) { - const githubButton = new ButtonBuilder() - .setLabel('?? Report Bug on GitHub') - .setStyle(ButtonStyle.Link) - .setURL('https://github.com/codebymitch/TitanBot/issues'); - - const row = 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!', - color: 'error' - }) - .setTimestamp(); - - await InteractionHelper.safeReply(interaction, { - embeds: [bugReportEmbed], - components: [row], - }); - }, -}; - - - - diff --git a/src/commands/Core/help.js b/src/commands/Core/help.js deleted file mode 100644 index 4ad58be101..0000000000 --- a/src/commands/Core/help.js +++ /dev/null @@ -1,234 +0,0 @@ -import { - SlashCommandBuilder, - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, -} from "discord.js"; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { createEmbed } from "../../utils/embeds.js"; -import { - createSelectMenu, -} from "../../utils/components.js"; -import fs from "fs/promises"; -import path from "path"; -import { fileURLToPath } from "url"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const CATEGORY_SELECT_ID = "help-category-select"; -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 = { - Core: "ℹ️", - Moderation: "🛡️", - Economy: "💰", - Fun: "🎮", - Leveling: "📊", - Utility: "🔧", - Ticket: "🎫", - Welcome: "👋", - Giveaway: "🎉", - Counter: "🔢", - Tools: "🛠️", - Search: "🔍", - Reaction_Roles: "🎭", - Community: "👥", - Birthday: "🎂", - Config: "⚙️", -}; - - - - - -export async function createInitialHelpMenu(client) { - const commandsPath = path.join(__dirname, "../../commands"); - const categoryDirs = ( - await fs.readdir(commandsPath, { withFileTypes: true }) - ) - .filter((dirent) => dirent.isDirectory()) - .map((dirent) => dirent.name) - .sort(); - - const options = [ - { - label: "📋 All Commands", - description: "View all available commands with pagination", - value: ALL_COMMANDS_ID, - }, - ...categoryDirs.map((category) => { - const categoryName = - category.charAt(0).toUpperCase() + - category.slice(1).toLowerCase(); - const icon = CATEGORY_ICONS[categoryName] || "🔍"; - return { - label: `${icon} ${categoryName}`, - description: `View commands in the ${categoryName} category`, - value: category, - }; - }), - ]; - - const botName = client?.user?.username || "Bot"; - const embed = createEmbed({ - title: `🤖 ${botName} Help Center`, - description: "Your all-in-one Discord companion for moderation, economy, fun, and server management.", - color: 'primary' - }); - - embed.addFields( - { - name: "🛡️ **Moderation**", - value: "Server moderation, user management, and enforcement tools", - inline: true - }, - { - name: "💰 **Economy**", - value: "Currency system, shops, and virtual economy", - inline: true - }, - { - name: "🎮 **Fun**", - value: "Games, entertainment, and interactive commands", - inline: true - }, - { - name: "📊 **Leveling**", - value: "User levels, XP system, and progression tracking", - inline: true - }, - { - name: "🎫 **Tickets**", - value: "Support ticket system for server management", - inline: true - }, - { - name: "🎉 **Giveaways**", - value: "Automated giveaway management and distribution", - inline: true - }, - { - name: "👋 **Welcome**", - value: "Member welcome messages and onboarding", - inline: true - }, - { - name: "🎂 **Birthdays**", - value: "Birthday tracking and celebration features", - inline: true - }, - { - name: "👥 **Community**", - value: "Community tools, applications, and member engagement", - inline: true - }, - { - name: "⚙️ **Config**", - value: "Server and bot configuration management commands", - inline: true - }, - { - name: "🔢 **Counter**", - value: "Live counter channel setup and counter controls", - inline: true - }, - { - name: "🎙️ **Join to Create**", - value: "Dynamic voice channel creation and management", - inline: true - }, - { - name: "🎭 **Reaction Roles**", - value: "Self-assignable roles using reaction-role systems", - inline: true - }, - { - name: "✅ **Verification**", - value: "Member verification workflows and access gating", - inline: true - }, - { - name: "🔧 **Utilities**", - value: "Useful tools and server utilities", - inline: true - } - ); - - embed.setFooter({ - text: "Made with ❤️" - }); - embed.setTimestamp(); - - const bugReportButton = new ButtonBuilder() - .setCustomId(BUG_REPORT_BUTTON_ID) - .setLabel("Report Bug") - .setStyle(ButtonStyle.Danger); - - const supportButton = new ButtonBuilder() - .setLabel("Support Server") - .setURL("https://discord.gg/QnWNz2dKCE") - .setStyle(ButtonStyle.Link); - - const touchpointButton = new ButtonBuilder() - .setLabel("Learn from Touchpoint") - .setURL("https://www.youtube.com/@TouchDisc") - .setStyle(ButtonStyle.Link); - - const selectRow = createSelectMenu( - CATEGORY_SELECT_ID, - "Select to view the commands", - options, - ); - - const buttonRow = new ActionRowBuilder().addComponents([ - bugReportButton, - supportButton, - touchpointButton, - ]); - - return { - embeds: [embed], - components: [buttonRow, selectRow], - }; -} - -export default { - data: new SlashCommandBuilder() - .setName("help") - .setDescription("Displays the help menu with all available commands"), - - async execute(interaction, guildConfig, client) { - - const { MessageFlags } = await import('discord.js'); - await InteractionHelper.safeDefer(interaction); - - const { embeds, components } = await createInitialHelpMenu(client); - - await InteractionHelper.safeEditReply(interaction, { - embeds, - components, - }); - - setTimeout(async () => { - try { - const closedEmbed = createEmbed({ - title: "Help menu closed", - description: "Help menu has been closed, use /help again.", - color: "secondary", - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [closedEmbed], - components: [], - }); - } catch (error) { - - } - }, HELP_MENU_TIMEOUT_MS); - }, -}; - - diff --git a/src/commands/Core/overview.js b/src/commands/Core/overview.js deleted file mode 100644 index 4c06839a80..0000000000 --- a/src/commands/Core/overview.js +++ /dev/null @@ -1,115 +0,0 @@ -import { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } from 'discord.js'; -import { getColor } from '../../config/bot.js'; -import { getGuildConfig } from '../../services/guildConfig.js'; -import { getLoggingStatus } from '../../services/loggingService.js'; -import { getLevelingConfig } from '../../services/leveling.js'; -import { getConfiguration as getJoinToCreateConfiguration } from '../../services/joinToCreateService.js'; -import { getWelcomeConfig, getApplicationSettings } from '../../utils/database.js'; -import { errorEmbed } from '../../utils/embeds.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { logger } from '../../utils/logger.js'; - -function pill(enabled) { - return enabled ? '✅ On' : '❌ Off'; -} - -async function formatChannelMention(guild, id) { - 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 formatRoleMention(guild, id) { - if (!id) return '`Not configured`'; - const role = guild.roles.cache.get(id); - return role ? role.toString() : `⚠️ Missing (${id})`; -} - -export default { - data: new SlashCommandBuilder() - .setName('overview') - .setDescription('Read-only snapshot of all server system statuses.') - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .setDMPermission(false), - - async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction); - - const [guildConfig, loggingStatus, levelingConfig, welcomeConfig, applicationConfig, joinToCreateConfig] = - await Promise.all([ - getGuildConfig(client, interaction.guildId), - getLoggingStatus(client, interaction.guildId), - getLevelingConfig(client, interaction.guildId), - getWelcomeConfig(client, interaction.guildId), - getApplicationSettings(client, interaction.guildId), - getJoinToCreateConfiguration(client, interaction.guildId), - ]); - - const verificationEnabled = Boolean(guildConfig.verification?.enabled); - const autoVerifyEnabled = Boolean(guildConfig.verification?.autoVerify?.enabled); - const autoRoleId = guildConfig.autoRole || welcomeConfig?.roleIds?.[0]; - - // ── Channels ────────────────────────────────────────────────────── - const [auditChannel, lifecycleChannel, transcriptChannel, reportChannel, birthdayChannel] = - await Promise.all([ - formatChannelMention(interaction.guild, loggingStatus.channelId || guildConfig.logging?.channelId || guildConfig.logChannelId), - formatChannelMention(interaction.guild, guildConfig.ticketLogsChannelId), - formatChannelMention(interaction.guild, guildConfig.ticketTranscriptChannelId), - formatChannelMention(interaction.guild, guildConfig.reportChannelId), - formatChannelMention(interaction.guild, guildConfig.birthdayChannelId), - ]); - - const embed = new EmbedBuilder() - .setTitle('🖥️ System Overview') - .setDescription(`Read-only snapshot for **${interaction.guild.name}**. Use the relevant command's dashboard to make changes.`) - .setColor(getColor('primary')) - .addFields( - // ── Core systems ── - { - name: '⚙️ Core Systems', - value: [ - `🧾 **Audit Logging** — ${pill(Boolean(loggingStatus.enabled))}`, - `📈 **Leveling** — ${pill(Boolean(levelingConfig?.enabled))}`, - `👋 **Welcome** — ${pill(Boolean(welcomeConfig?.enabled))}`, - `👋 **Goodbye** — ${pill(Boolean(welcomeConfig?.goodbyeEnabled))}`, - `🎂 **Birthdays** — ${pill(Boolean(guildConfig.birthdayChannelId))}`, - `📋 **Applications** — ${pill(Boolean(applicationConfig?.enabled))}`, - `✅ **Verification** — ${pill(verificationEnabled)}`, - `🤖 **Auto-Verify** — ${pill(autoVerifyEnabled)}`, - `🎧 **Join to Create** — ${pill(Boolean(joinToCreateConfig?.enabled))}`, - `🛡️ **Auto Role** — ${autoRoleId ? `✅ ${formatRoleMention(interaction.guild, autoRoleId)}` : '❌ Off'}`, - ].join('\n'), - inline: false, - }, - // ── Channels ── - { - name: '📡 Configured Channels', - value: [ - `**Audit Log:** ${auditChannel}`, - `**Ticket Lifecycle:** ${lifecycleChannel}`, - `**Ticket Transcripts:** ${transcriptChannel}`, - `**Reports:** ${reportChannel}`, - `**Birthdays:** ${birthdayChannel}`, - ].join('\n'), - inline: false, - }, - // ── Refresh stamp ── - { - name: '🕒 Snapshot Taken', - value: ``, - inline: true, - }, - ) - .setFooter({ text: 'Read-only — run /logging dashboard to manage audit settings' }) - .setTimestamp(); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } catch (error) { - logger.error('overview command error:', error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Overview Error', 'Failed to load the system overview.')], - }); - } - }, -}; diff --git a/src/commands/Core/ping.js b/src/commands/Core/ping.js deleted file mode 100644 index c20d1ad496..0000000000 --- a/src/commands/Core/ping.js +++ /dev/null @@ -1,55 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export default { - data: new SlashCommandBuilder() - .setName("ping") - .setDescription("Checks the bot's latency and API speed"), - - async execute(interaction) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Ping interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'ping' - }); - return; - } - - try { - await InteractionHelper.safeEditReply(interaction, { - content: "Pinging...", - }); - - const latency = Date.now() - interaction.createdTimestamp; - const apiLatency = 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 }, - ); - - await InteractionHelper.safeEditReply(interaction, { - content: null, - embeds: [embed], - }); - } catch (error) { - 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' })], - flags: MessageFlags.Ephemeral, - }); - } catch (replyError) { - logger.error('Failed to send error reply:', replyError); - } - } - }, -}; - - - - diff --git a/src/commands/Core/stats.js b/src/commands/Core/stats.js deleted file mode 100644 index b2e634a6af..0000000000 --- a/src/commands/Core/stats.js +++ /dev/null @@ -1,47 +0,0 @@ -import { SlashCommandBuilder, version, MessageFlags } from 'discord.js'; -import { createEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("stats") - .setDescription("View bot statistics"), - - async execute(interaction) { - try { - await InteractionHelper.safeDefer(interaction); - - const totalGuilds = interaction.client.guilds.cache.size; - const totalMembers = interaction.client.guilds.cache.reduce( - (acc, guild) => acc + guild.memberCount, - 0, - ); - const nodeVersion = process.version; - - const embed = createEmbed({ title: "📊 System Statistics", description: "Real-time performance metrics." }).addFields( - { name: "Servers", value: `${totalGuilds}`, inline: true }, - { name: "Users", value: `${totalMembers}`, inline: true }, - { name: "Node.js", value: `${nodeVersion}`, inline: true }, - { name: "Discord.js", value: `v${version}`, inline: true }, - { - name: "Memory Usage", - value: `${(process.memoryUsage().heapUsed / 1024 / 1024).toFixed(2)} MB`, - inline: true, - }, - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } 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' })], - flags: MessageFlags.Ephemeral, - }); - } - }, -}; - - - - diff --git a/src/commands/Core/support.js b/src/commands/Core/support.js deleted file mode 100644 index aa392e3a66..0000000000 --- a/src/commands/Core/support.js +++ /dev/null @@ -1,46 +0,0 @@ -import { SlashCommandBuilder, ButtonBuilder, ButtonStyle, ActionRowBuilder, MessageFlags } from 'discord.js'; -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"; -export default { - data: new SlashCommandBuilder() - .setName("support") - .setDescription("Get link to the support server"), - - async execute(interaction) { - try { - const supportButton = new ButtonBuilder() - .setLabel("Join Support Server") - .setStyle(ButtonStyle.Link) - .setURL(SUPPORT_SERVER_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!" }), - ], - components: [actionRow], - flags: MessageFlags.Ephemeral, - }); - } catch (error) { - logger.error('Support command error:', error); - - try { - return await InteractionHelper.safeReply(interaction, { - embeds: [createEmbed({ title: 'System Error', description: 'Could not display support information.', color: 'error' })], - flags: MessageFlags.Ephemeral, - }); - } catch (replyError) { - logger.error('Failed to send error reply:', replyError); - } - } - }, -}; - - - - - diff --git a/src/commands/Core/uptime.js b/src/commands/Core/uptime.js deleted file mode 100644 index 7c33d49b82..0000000000 --- a/src/commands/Core/uptime.js +++ /dev/null @@ -1,48 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("uptime") - .setDescription("Check how long the bot has been online"), - - async execute(interaction) { - try { - await InteractionHelper.safeDefer(interaction); - - let totalSeconds = interaction.client.uptime / 1000; - let days = Math.floor(totalSeconds / 86400); - totalSeconds %= 86400; - let hours = Math.floor(totalSeconds / 3600); - totalSeconds %= 3600; - let minutes = Math.floor(totalSeconds / 60); - let seconds = Math.floor(totalSeconds % 60); - - const uptimeStr = `${days}d ${hours}h ${minutes}m ${seconds}s`; - - await InteractionHelper.safeEditReply(interaction, { - embeds: [createEmbed({ - title: "⏱️ System Uptime", - description: `\`\`\`${uptimeStr}\`\`\`` - })], - }); - } catch (error) { - logger.error('Uptime command error:', error); - - try { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [createEmbed({ title: 'System Error', description: 'Could not compute uptime.', color: 'error' })], - flags: MessageFlags.Ephemeral, - }); - } catch (replyError) { - logger.error('Failed to send error reply:', replyError); - } - } - }, -}; - - - - diff --git a/src/commands/Economy/balance.js b/src/commands/Economy/balance.js deleted file mode 100644 index 16cd240eb3..0000000000 --- a/src/commands/Economy/balance.js +++ /dev/null @@ -1,86 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, getMaxBankCapacity } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export default { - data: new SlashCommandBuilder() - .setName('balance') - .setDescription("Check your or someone else's balance") - .addUserOption(option => - option - .setName('user') - .setDescription('User to check balance for') - .setRequired(false) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const targetUser = interaction.options.getUser("user") || interaction.user; - const guildId = interaction.guildId; - - logger.debug(`[ECONOMY] Balance check for ${targetUser.id}`, { userId: targetUser.id, guildId }); - - if (targetUser.bot) { - throw createError( - "Bot user queried for balance", - ErrorTypes.VALIDATION, - "Bots don't have an economy balance." - ); - } - - const userData = await getEconomyData(client, guildId, targetUser.id); - - if (!userData) { - throw createError( - "Failed to load economy data", - ErrorTypes.DATABASE, - "Failed to load economy data. Please try again later.", - { userId: targetUser.id, guildId } - ); - } - - const maxBank = getMaxBankCapacity(userData); - - const wallet = typeof userData.wallet === 'number' ? userData.wallet : 0; - const bank = typeof userData.bank === 'number' ? userData.bank : 0; - - const embed = createEmbed({ - title: `💰 ${targetUser.username}'s Balance`, - description: `Here is the current financial status for ${targetUser.username}.`, - }) - .addFields( - { - name: "💵 Cash", - value: `$${wallet.toLocaleString()}`, - inline: true, - }, - { - name: "🏦 Bank", - value: `$${bank.toLocaleString()} / $${maxBank.toLocaleString()}`, - inline: true, - }, - { - name: "💎 Total", - value: `$${(wallet + bank).toLocaleString()}`, - inline: true, - } - ) - .setFooter({ - text: `Requested by ${interaction.user.tag}`, - iconURL: interaction.user.displayAvatarURL(), - }); - - logger.info(`[ECONOMY] Balance retrieved`, { userId: targetUser.id, wallet, bank }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'balance' }) -}; - - - - diff --git a/src/commands/Economy/beg.js b/src/commands/Economy/beg.js deleted file mode 100644 index 260ebc656f..0000000000 --- a/src/commands/Economy/beg.js +++ /dev/null @@ -1,103 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { botConfig } from '../../config/bot.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { MessageTemplates } from '../../utils/messageTemplates.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const COOLDOWN = 30 * 60 * 1000; -const MIN_WIN = 50; -const MAX_WIN = 200; -const SUCCESS_CHANCE = 0.7; - -export default { - data: new SlashCommandBuilder() - .setName('beg') - .setDescription('Beg for a small amount of money'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - - let userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - const lastBeg = userData.lastBeg || 0; - const remainingTime = lastBeg + COOLDOWN - Date.now(); - - if (remainingTime > 0) { - const minutes = Math.floor(remainingTime / 60000); - const seconds = Math.floor((remainingTime % 60000) / 1000); - - let timeMessage = - minutes > 0 ? `${minutes} minute(s)` : `${seconds} second(s)`; - - throw createError( - "Beg cooldown active", - ErrorTypes.RATE_LIMIT, - `You are tired from begging! Try again in **${timeMessage}**.`, - { remainingTime, minutes, seconds, cooldownType: 'beg' } - ); - } - - const success = Math.random() < SUCCESS_CHANCE; - - let replyEmbed; - let newCash = userData.wallet; - - if (success) { - const amountWon = - Math.floor(Math.random() * (MAX_WIN - MIN_WIN + 1)) + MIN_WIN; - - newCash += amountWon; - - const successMessages = [ - `A kind stranger drops **$${amountWon.toLocaleString()}** into your cup.`, - `You spotted an unattended wallet! You grab **$${amountWon.toLocaleString()}** and run.`, - `Someone took pity on you and gave you **$${amountWon.toLocaleString()}**!`, - `You found **$${amountWon.toLocaleString()}** under a park bench.`, - ]; - - replyEmbed = MessageTemplates.SUCCESS.DATA_UPDATED( - "begging", - successMessages[ - Math.floor(Math.random() * successMessages.length) - ] - ); - } else { - const failMessages = [ - "The police chased you off. You got nothing.", - "Someone yelled, 'Get a job!' and walked past.", - "A squirrel stole the single coin you had.", - "You tried to beg, but you were too embarrassed and gave up.", - ]; - - replyEmbed = MessageTemplates.ERRORS.INSUFFICIENT_FUNDS( - "nothing", - "You failed to get any money from begging." - ); - replyEmbed.data.description = failMessages[Math.floor(Math.random() * failMessages.length)]; - } - - userData.wallet = newCash; -userData.lastBeg = Date.now(); - - await setEconomyData(client, guildId, userId, userData); - - await InteractionHelper.safeEditReply(interaction, { embeds: [replyEmbed] }); - }, { command: 'beg' }) -}; - - diff --git a/src/commands/Economy/buy.js b/src/commands/Economy/buy.js deleted file mode 100644 index 72f4c62bb9..0000000000 --- a/src/commands/Economy/buy.js +++ /dev/null @@ -1,163 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { shopItems } from '../../config/shop/items.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { getGuildConfig } from '../../services/guildConfig.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { MessageTemplates } from '../../utils/messageTemplates.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const SHOP_ITEMS = shopItems; - -export default { - data: new SlashCommandBuilder() - .setName('buy') - .setDescription('Buy an item from the shop') - .addStringOption(option => - option - .setName('item_id') - .setDescription('ID of the item to buy') - .setRequired(true) - ) - .addIntegerOption(option => - option - .setName('quantity') - .setDescription('Quantity to buy (default: 1)') - .setRequired(false) - .setMinValue(1) - .setMaxValue(10) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const itemId = interaction.options.getString("item_id").toLowerCase(); - const quantity = interaction.options.getInteger("quantity") || 1; - - const item = SHOP_ITEMS.find(i => i.id === itemId); - - if (!item) { - throw createError( - `Item ${itemId} not found`, - ErrorTypes.VALIDATION, - `The item ID \`${itemId}\` does not exist in the shop.`, - { itemId } - ); - } - - if (quantity < 1) { - throw createError( - "Invalid quantity", - ErrorTypes.VALIDATION, - "You must purchase a quantity of 1 or more.", - { quantity } - ); - } - - const totalCost = item.price * quantity; - - const guildConfig = await getGuildConfig(client, guildId); - const PREMIUM_ROLE_ID = guildConfig.premiumRoleId; - - const userData = await getEconomyData(client, guildId, userId); - - if (userData.wallet < totalCost) { - throw createError( - "Insufficient funds", - ErrorTypes.VALIDATION, - `You need **$${totalCost.toLocaleString()}** to purchase ${quantity}x **${item.name}**, but you only have **$${userData.wallet.toLocaleString()}** in cash.`, - { required: totalCost, current: userData.wallet, itemId, quantity } - ); - } - - if (item.type === "role" && itemId === "premium_role") { - if (!PREMIUM_ROLE_ID) { - throw createError( - "Premium role not configured", - ErrorTypes.CONFIGURATION, - "The **Premium Shop Role** has not been configured by a server administrator yet.", - { itemId } - ); - } - if (interaction.member.roles.cache.has(PREMIUM_ROLE_ID)) { - throw createError( - "Role already owned", - ErrorTypes.VALIDATION, - `You already have the **${item.name}** role.`, - { itemId, roleId: PREMIUM_ROLE_ID } - ); - } - if (quantity > 1) { - throw createError( - "Invalid quantity for role", - ErrorTypes.VALIDATION, - `You can only purchase the **${item.name}** role once.`, - { itemId, quantity } - ); - } - } - - userData.wallet -= totalCost; - - let successDescription = `You successfully purchased ${quantity}x **${item.name}** for **$${totalCost.toLocaleString()}**!`; - - if (item.type === "role" && itemId === "premium_role") { - const member = interaction.member; - - const role = interaction.guild.roles.cache.get(PREMIUM_ROLE_ID); - - if (!role) { - throw createError( - "Role not found", - ErrorTypes.CONFIGURATION, - "The configured premium role no longer exists in this guild.", - { roleId: PREMIUM_ROLE_ID } - ); - } - - try { - await member.roles.add( - role, - `Purchased role: ${item.name}`, - ); - successDescription += `\n\n**👑 The role ${role.toString()} has been granted to you!**`; - } catch (roleError) { - userData.wallet += totalCost; - await setEconomyData(client, guildId, userId, userData); - throw createError( - "Role assignment failed", - ErrorTypes.DISCORD_API, - "Successfully deducted money, but failed to grant the role. Your cash has been refunded.", - { roleId: PREMIUM_ROLE_ID, originalError: roleError.message } - ); - } - } else if (item.type === "upgrade") { - userData.upgrades[itemId] = true; - successDescription += `\n\n**✨ Your upgrade is now active!**`; - } else if (item.type === "consumable") { - userData.inventory[itemId] = - (userData.inventory[itemId] || 0) + quantity; - } - - await setEconomyData(client, guildId, userId, userData); - - const embed = successEmbed( - "💰 Purchase Successful", - successDescription, - ).addFields({ - name: "New Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: [MessageFlags.Ephemeral] }); - }, { command: 'buy' }) -}; - - - - - diff --git a/src/commands/Economy/crime.js b/src/commands/Economy/crime.js deleted file mode 100644 index 2403fe4613..0000000000 --- a/src/commands/Economy/crime.js +++ /dev/null @@ -1,122 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { MessageTemplates } from '../../utils/messageTemplates.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const CRIME_COOLDOWN = 60 * 60 * 1000; -const MIN_CRIME_AMOUNT = 100; -const MAX_CRIME_AMOUNT = 2000; -const FAILURE_RATE = 0.4; -const JAIL_TIME = 2 * 60 * 60 * 1000; - -const CRIME_TYPES = [ - { name: "Pickpocketing", min: 100, max: 500, risk: 0.3 }, - { name: "Burglary", min: 300, max: 1000, risk: 0.4 }, - { name: "Bank Heist", min: 1000, max: 5000, risk: 0.6 }, - { name: "Art Theft", min: 2000, max: 10000, risk: 0.7 }, - { name: "Cybercrime", min: 5000, max: 20000, risk: 0.8 }, -]; - -export default { - data: new SlashCommandBuilder() - .setName('crime') - .setDescription('Commit a crime to earn money (risky)') - .addStringOption(option => - option - .setName('type') - .setDescription('Type of crime to commit') - .setRequired(true) - .addChoices( - { name: 'Pickpocketing', value: 'pickpocketing' }, - { name: 'Burglary', value: 'burglary' }, - { name: 'Bank Heist', value: 'bank-heist' }, - { name: 'Art Theft', value: 'art-theft' }, - { name: 'Cybercrime', value: 'cybercrime' }, - ) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - await InteractionHelper.safeDefer(interaction); - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - const userData = await getEconomyData(client, guildId, userId); - const lastCrime = userData.cooldowns?.crime || 0; - const isJailed = userData.jailedUntil && userData.jailedUntil > now; - - if (isJailed) { - const timeLeft = Math.ceil((userData.jailedUntil - now) / (1000 * 60)); - throw createError( - "User is in jail", - ErrorTypes.RATE_LIMIT, - `You're in jail for ${timeLeft} more minutes!`, - { jailTimeRemaining: userData.jailedUntil - now } - ); - } - - if (now < lastCrime + CRIME_COOLDOWN) { - const timeLeft = Math.ceil((lastCrime + CRIME_COOLDOWN - now) / (1000 * 60)); - throw createError( - "Crime cooldown active", - ErrorTypes.RATE_LIMIT, - `You need to wait ${timeLeft} more minutes before committing another crime.`, - { remaining: lastCrime + CRIME_COOLDOWN - now, cooldownType: 'crime' } - ); - } - - const crimeType = interaction.options.getString("type").toLowerCase(); - const crime = CRIME_TYPES.find( - c => c.name.toLowerCase().replace(/\s+/g, '-') === crimeType - ); - - if (!crime) { - throw createError( - "Invalid crime type", - ErrorTypes.VALIDATION, - "Please select a valid crime type.", - { crimeType } - ); - } - - const isSuccess = Math.random() > crime.risk; - const amountEarned = isSuccess - ? Math.floor(Math.random() * (crime.max - crime.min + 1)) + crime.min - : 0; - - userData.cooldowns = userData.cooldowns || {}; - userData.cooldowns.crime = now; - - if (isSuccess) { - userData.wallet = (userData.wallet || 0) + amountEarned; - - await setEconomyData(client, guildId, userId, userData); - - const embed = successEmbed( - "Crime Successful!", - `You successfully committed ${crime.name} and earned **${amountEarned}** coins!` - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } else { - const fine = Math.floor(amountEarned * 0.2); - userData.wallet = Math.max(0, (userData.wallet || 0) - fine); - userData.jailedUntil = now + JAIL_TIME; - - await setEconomyData(client, guildId, userId, userData); - - const embed = errorEmbed( - "Crime Failed!", - `You were caught while attempting ${crime.name} and have been sent to jail! ` + - `You were fined ${fine} coins and will be in jail for 2 hours.` - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } - }, { command: 'crime' }) -}; - - diff --git a/src/commands/Economy/daily.js b/src/commands/Economy/daily.js deleted file mode 100644 index ba3d9bee3d..0000000000 --- a/src/commands/Economy/daily.js +++ /dev/null @@ -1,107 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { getGuildConfig } from '../../services/guildConfig.js'; -import { formatDuration } from '../../utils/helpers.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const DAILY_COOLDOWN = 24 * 60 * 60 * 1000; -const DAILY_AMOUNT = 1000; -const PREMIUM_BONUS_PERCENTAGE = 0.1; - -export default { - data: new SlashCommandBuilder() - .setName('daily') - .setDescription('Claim your daily cash reward'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - logger.debug(`[ECONOMY] Daily claimed started for ${userId}`, { userId, guildId }); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data for daily", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - const lastDaily = userData.lastDaily || 0; - - if (now < lastDaily + DAILY_COOLDOWN) { - const timeRemaining = lastDaily + DAILY_COOLDOWN - now; - throw createError( - "Daily cooldown active", - ErrorTypes.RATE_LIMIT, - `You need to wait before claiming daily again. Try again in **${formatDuration(timeRemaining)}**.`, - { timeRemaining, cooldownType: 'daily' } - ); - } - - const guildConfig = await getGuildConfig(client, guildId); - const PREMIUM_ROLE_ID = guildConfig.premiumRoleId; - - let earned = DAILY_AMOUNT; - let bonusMessage = ""; - let hasPremiumRole = false; - - if ( - PREMIUM_ROLE_ID && - interaction.member && - interaction.member.roles.cache.has(PREMIUM_ROLE_ID) - ) { - const bonusAmount = Math.floor( - DAILY_AMOUNT * PREMIUM_BONUS_PERCENTAGE, - ); - earned += bonusAmount; - bonusMessage = `\n✨ **Premium Bonus:** +$${bonusAmount.toLocaleString()}`; - hasPremiumRole = true; - } - - userData.wallet = (userData.wallet || 0) + earned; - userData.lastDaily = now; - - await setEconomyData(client, guildId, userId, userData); - - logger.info(`[ECONOMY_TRANSACTION] Daily claimed`, { - userId, - guildId, - amount: earned, - newWallet: userData.wallet, - hasPremium: hasPremiumRole, - timestamp: new Date().toISOString() - }); - - const embed = successEmbed( - "✅ Daily Claimed!", - `You have claimed your daily **$${earned.toLocaleString()}**!${bonusMessage}` - ) - .addFields({ - name: "New Cash Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }) - .setFooter({ - text: hasPremiumRole - ? `Next claim in 24 hours. (Premium Active)` - : `Next claim in 24 hours.`, - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'daily' }) -}; - - - - diff --git a/src/commands/Economy/deposit.js b/src/commands/Economy/deposit.js deleted file mode 100644 index 79cab9b424..0000000000 --- a/src/commands/Economy/deposit.js +++ /dev/null @@ -1,144 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData, getMaxBankCapacity } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { MessageTemplates } from '../../utils/messageTemplates.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export default { - data: new SlashCommandBuilder() - .setName('deposit') - .setDescription('Deposit money from your wallet into your bank') - .addStringOption(option => - option - .setName('amount') - .setDescription('Amount to deposit (number or "all")') - .setRequired(true) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const amountInput = interaction.options.getString("amount"); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - const maxBank = getMaxBankCapacity(userData); - let depositAmount; - - if (amountInput.toLowerCase() === "all") { - depositAmount = userData.wallet; - } else { - depositAmount = parseInt(amountInput); - - if (isNaN(depositAmount) || depositAmount <= 0) { - throw createError( - "Invalid deposit amount", - ErrorTypes.VALIDATION, - `Please enter a valid number or 'all'. You entered: \`${amountInput}\``, - { amountInput, userId } - ); - } - } - - if (depositAmount === 0) { - throw createError( - "Zero deposit amount", - ErrorTypes.VALIDATION, - "You have no cash to deposit.", - { userId, walletBalance: userData.wallet } - ); - } - - if (depositAmount > userData.wallet) { - depositAmount = userData.wallet; - await interaction.followUp({ - embeds: [ - MessageTemplates.ERRORS.INVALID_INPUT( - "deposit amount", - `You tried to deposit more than you have. Depositing your remaining cash: **$${depositAmount.toLocaleString()}**` - ) - ], - flags: ["Ephemeral"], - }); - } - - const availableSpace = maxBank - userData.bank; - - if (availableSpace <= 0) { - throw createError( - "Bank is full", - ErrorTypes.VALIDATION, - `Your bank is currently full (Max Capacity: $${maxBank.toLocaleString()}). Purchase a **Bank Upgrade** to increase your limit.`, - { maxBank, currentBank: userData.bank, userId } - ); - } - - if (depositAmount > availableSpace) { - const originalDepositAmount = depositAmount; - depositAmount = availableSpace; - - if (amountInput.toLowerCase() !== "all") { - await interaction.followUp({ - embeds: [ - MessageTemplates.ERRORS.INVALID_INPUT( - "deposit amount", - `You only had space for **$${depositAmount.toLocaleString()}** in your bank account (Max: $${maxBank.toLocaleString()}). The rest remains in your cash.` - ) - ], - flags: ["Ephemeral"], - }); - } - } - - if (depositAmount === 0) { - throw createError( - "No space or cash for deposit", - ErrorTypes.VALIDATION, - "The amount you tried to deposit was either 0 or exceeded your bank capacity after checking your cash balance.", - { depositAmount, availableSpace, walletBalance: userData.wallet } - ); - } - - userData.wallet -= depositAmount; - userData.bank += depositAmount; - - await setEconomyData(client, guildId, userId, userData); - - const embed = MessageTemplates.SUCCESS.DATA_UPDATED( - "deposit", - `You successfully deposited **$${depositAmount.toLocaleString()}** into your bank.` - ) - .addFields( - { - name: "💵 New Cash Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }, - { - name: "🏦 New Bank Balance", - value: `$${userData.bank.toLocaleString()} / $${maxBank.toLocaleString()}`, - inline: true, - }, - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'deposit' }) -}; - - - - - diff --git a/src/commands/Economy/eleaderboard.js b/src/commands/Economy/eleaderboard.js deleted file mode 100644 index d967116901..0000000000 --- a/src/commands/Economy/eleaderboard.js +++ /dev/null @@ -1,94 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed } from '../../utils/embeds.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export default { - data: new SlashCommandBuilder() - .setName("eleaderboard") - .setDescription("View the server's top 10 richest users.") - .setDMPermission(false), - - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const guildId = interaction.guildId; - - logger.debug(`[ECONOMY] Leaderboard requested`, { guildId }); - - const prefix = `economy:${guildId}:`; - - let allKeys = await client.db.list(prefix); - - if (!Array.isArray(allKeys)) { - allKeys = []; - } - - if (allKeys.length === 0) { - throw createError( - "No economy data found", - ErrorTypes.VALIDATION, - "No economy data found for this server." - ); - } - - let allUserData = []; - - for (const key of allKeys) { - const userId = key.replace(prefix, ""); - const userData = await client.db.get(key); - - if (userData) { - allUserData.push({ - userId: userId, - net_worth: (userData.wallet || 0) + (userData.bank || 0), - }); - } - } - - allUserData.sort((a, b) => b.net_worth - a.net_worth); - - const topUsers = allUserData.slice(0, 10); - const userRank = - allUserData.findIndex((u) => u.userId === interaction.user.id) + - 1; - const rankEmoji = ["🥇", "🥈", "🥉"]; - const leaderboardEntries = []; - - for (let i = 0; i < topUsers.length; i++) { - const user = topUsers[i]; - const rank = i + 1; - const emoji = rankEmoji[i] || `**#${rank}**`; - - leaderboardEntries.push( - `${emoji} <@${user.userId}> - 🏦 ${user.net_worth.toLocaleString()}`, - ); - } - - logger.info(`[ECONOMY] Leaderboard generated`, { - guildId, - userCount: allUserData.length, - userRank - }); - - const description = leaderboardEntries.length > 0 - ? leaderboardEntries.join("\n") - : "No economy data is available for this server yet."; - - const embed = createEmbed({ - title: `Economy Leaderboard`, - description, - footer: `Your Rank: ${userRank > 0 ? `#${userRank}` : "No ranking data available"}`, - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'eleaderboard' }) -}; - - - - - diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js deleted file mode 100644 index 367ad453eb..0000000000 --- a/src/commands/Economy/fish.js +++ /dev/null @@ -1,135 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { MessageTemplates } from '../../utils/messageTemplates.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const FISH_COOLDOWN = 45 * 60 * 1000; -const BASE_MIN_REWARD = 300; -const BASE_MAX_REWARD = 900; -const FISHING_ROD_MULTIPLIER = 1.5; - -const FISH_TYPES = [ - { name: 'Bass', emoji: '🐟', rarity: 'common' }, - { name: 'Salmon', emoji: '🐟', rarity: 'common' }, - { name: 'Trout', emoji: '🐟', rarity: 'common' }, - { name: 'Tuna', emoji: '🐟', rarity: 'uncommon' }, - { name: 'Swordfish', emoji: '🐟', rarity: 'uncommon' }, - { name: 'Octopus', emoji: '🐙', rarity: 'rare' }, - { name: 'Lobster', emoji: '🦞', rarity: 'rare' }, - { name: 'Shark', emoji: '🦈', rarity: 'epic' }, - { name: 'Whale', emoji: '🐋', rarity: 'legendary' }, -]; - -const CATCH_MESSAGES = [ - "You cast your line into the crystal clear waters...", - "You wait patiently as your bobber floats...", - "After a few minutes of waiting, you feel a tug...", - "The water ripples as something takes your bait...", - "You reel in your catch with expert precision...", -]; - -export default { - data: new SlashCommandBuilder() - .setName('fish') - .setDescription('Go fishing to catch fish and earn money'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - const userData = await getEconomyData(client, guildId, userId); - const lastFish = userData.lastFish || 0; - const hasFishingRod = userData.inventory["fishing_rod"] || 0; - - if (now < lastFish + FISH_COOLDOWN) { - const remaining = lastFish + FISH_COOLDOWN - now; - const hours = Math.floor(remaining / (1000 * 60 * 60)); - const minutes = Math.floor( - (remaining % (1000 * 60 * 60)) / (1000 * 60), - ); - - throw createError( - "Fishing cooldown active", - ErrorTypes.RATE_LIMIT, - `You're too tired to fish right now. Rest for **${hours}h ${minutes}m** before fishing again.`, - { remaining, cooldownType: 'fish' } - ); - } - - - const rand = Math.random(); - let fishCaught; - - if (rand < 0.5) { - - fishCaught = FISH_TYPES.filter(f => f.rarity === 'common')[Math.floor(Math.random() * 3)]; - } else if (rand < 0.75) { - - fishCaught = FISH_TYPES.filter(f => f.rarity === 'uncommon')[Math.floor(Math.random() * 2)]; - } else if (rand < 0.9) { - - fishCaught = FISH_TYPES.filter(f => f.rarity === 'rare')[Math.floor(Math.random() * 2)]; - } else if (rand < 0.98) { - - fishCaught = FISH_TYPES.find(f => f.rarity === 'epic'); - } else { - - fishCaught = FISH_TYPES.find(f => f.rarity === 'legendary'); - } - - const baseEarned = Math.floor( - Math.random() * (BASE_MAX_REWARD - BASE_MIN_REWARD + 1) - ) + BASE_MIN_REWARD; - - let finalEarned = baseEarned; - let multiplierMessage = ""; - - - if (hasFishingRod > 0) { - finalEarned = Math.floor(baseEarned * FISHING_ROD_MULTIPLIER); - multiplierMessage = `\n🎣 **Fishing Rod Bonus: +50%**`; - } - - const catchMessage = CATCH_MESSAGES[Math.floor(Math.random() * CATCH_MESSAGES.length)]; - - userData.wallet += finalEarned; - userData.lastFish = now; - - await setEconomyData(client, guildId, userId, userData); - - const rarityColors = { - common: '#95A5A6', - uncommon: '#2ECC71', - rare: '#3498DB', - epic: '#9B59B6', - legendary: '#F1C40F' - }; - - const embed = createEmbed({ - title: '🎣 Fishing Success!', - description: `${catchMessage}\n\nYou caught a **${fishCaught.emoji} ${fishCaught.name}**! You sold it for **$${finalEarned.toLocaleString()}**!${multiplierMessage}`, - color: rarityColors[fishCaught.rarity] - }) - .addFields( - { - name: "💵 New Cash Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }, - { - name: "🐟 Rarity", - value: fishCaught.rarity.charAt(0).toUpperCase() + fishCaught.rarity.slice(1), - inline: true, - } - ) - .setFooter({ text: `Next fishing trip available in 45 minutes.` }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'fish' }) -}; diff --git a/src/commands/Economy/gamble.js b/src/commands/Economy/gamble.js deleted file mode 100644 index d0bb7c3bde..0000000000 --- a/src/commands/Economy/gamble.js +++ /dev/null @@ -1,136 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { MessageTemplates } from '../../utils/messageTemplates.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const BASE_WIN_CHANCE = 0.4; -const CLOVER_WIN_BONUS = 0.1; -const CHARM_WIN_BONUS = 0.08; -const PAYOUT_MULTIPLIER = 2.0; -const GAMBLE_COOLDOWN = 5 * 60 * 1000; - -export default { - data: new SlashCommandBuilder() - .setName('gamble') - .setDescription('Gamble your money for a chance to win more') - .addIntegerOption(option => - option - .setName('amount') - .setDescription('Amount of cash to gamble') - .setRequired(true) - .setMinValue(1) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const betAmount = interaction.options.getInteger("amount"); - const now = Date.now(); - - const userData = await getEconomyData(client, guildId, userId); - const lastGamble = userData.lastGamble || 0; - let cloverCount = userData.inventory["lucky_clover"] || 0; - let charmCount = userData.inventory["lucky_charm"] || 0; - - if (now < lastGamble + GAMBLE_COOLDOWN) { - const remaining = lastGamble + GAMBLE_COOLDOWN - now; - const minutes = Math.floor(remaining / (1000 * 60)); - const seconds = Math.floor((remaining % (1000 * 60)) / 1000); - - throw createError( - "Gamble cooldown active", - ErrorTypes.RATE_LIMIT, - `You need to cool down before gambling again. Wait **${minutes}m ${seconds}s**.`, - { remaining, cooldownType: 'gamble' } - ); - } - - if (userData.wallet < betAmount) { - throw createError( - "Insufficient cash for gamble", - ErrorTypes.VALIDATION, - `You only have $${userData.wallet.toLocaleString()} cash, but you are trying to bet $${betAmount.toLocaleString()}.`, - { required: betAmount, current: userData.wallet } - ); - } - - let winChance = BASE_WIN_CHANCE; - let cloverMessage = ""; - let usedClover = false; - let usedCharm = false; - - - if (cloverCount > 0) { - winChance += CLOVER_WIN_BONUS; - userData.inventory["lucky_clover"] -= 1; - cloverMessage = `\n🍀 **Lucky Clover Consumed:** Your win chance was boosted!`; - usedClover = true; - } - - else if (charmCount > 0) { - winChance += CHARM_WIN_BONUS; - userData.inventory["lucky_charm"] -= 1; - cloverMessage = `\n🍀 **Lucky Charm Used (${charmCount - 1} uses remaining):** Your win chance was boosted!`; - usedCharm = true; - } - - const win = Math.random() < winChance; - let cashChange = 0; - let resultEmbed; - - if (win) { - const amountWon = Math.floor(betAmount * PAYOUT_MULTIPLIER); -cashChange = amountWon; - - resultEmbed = successEmbed( - "🎉 You Won!", - `You successfully gambled and turned your **$${betAmount.toLocaleString()}** bet into **$${amountWon.toLocaleString()}**!${cloverMessage}`, - ); - } else { -cashChange = -betAmount; - - resultEmbed = errorEmbed( - "💔 You Lost...", - `The dice rolled against you. You lost your **$${betAmount.toLocaleString()}** bet.`, - ); - } - - userData.wallet = (userData.wallet || 0) + cashChange; -userData.lastGamble = now; - - await setEconomyData(client, guildId, userId, userData); - - const newCash = userData.wallet; - - resultEmbed.addFields({ - name: "💵 New Cash Balance", - value: `$${newCash.toLocaleString()}`, - inline: true, - }); - - if (usedClover) { - resultEmbed.setFooter({ - text: `You have ${userData.inventory["lucky_clover"]} Lucky Clovers left. Win chance was ${Math.round(winChance * 100)}%.`, - }); - } else if (usedCharm) { - resultEmbed.setFooter({ - text: `You have ${userData.inventory["lucky_charm"]} Lucky Charm uses left. Win chance was ${Math.round(winChance * 100)}%.`, - }); - } else { - resultEmbed.setFooter({ - text: `Next gamble available in 5 minutes. Base win chance: ${Math.round(BASE_WIN_CHANCE * 100)}%.`, - }); - } - - await InteractionHelper.safeEditReply(interaction, { embeds: [resultEmbed] }); - }, { command: 'gamble' }) -}; - - - - diff --git a/src/commands/Economy/inventory.js b/src/commands/Economy/inventory.js deleted file mode 100644 index 7369c83883..0000000000 --- a/src/commands/Economy/inventory.js +++ /dev/null @@ -1,74 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { shopItems } from '../../config/shop/items.js'; -import { getEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const SHOP_ITEMS = shopItems; - -export default { - data: new SlashCommandBuilder() - .setName('inventory') - .setDescription('View your economy inventory'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - - logger.debug(`[ECONOMY] Inventory requested for ${userId}`, { userId, guildId }); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data for inventory", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - const inventory = userData.inventory || {}; - - let inventoryDescription = "Your inventory is currently empty."; - - if (Object.keys(inventory).length > 0) { - inventoryDescription = Object.entries(inventory) - .filter( - ([itemId, quantity]) => { - const item = SHOP_ITEMS.find(i => i.id === itemId); - return quantity > 0 && item; - } - ) - .map( - ([itemId, quantity]) => { - const item = SHOP_ITEMS.find(i => i.id === itemId); - return `**${item.name}:** ${quantity}x`; - } - ) - .join("\n"); - } - - logger.info(`[ECONOMY] Inventory retrieved`, { - userId, - guildId, - itemCount: Object.keys(inventory).length - }); - - const embed = createEmbed({ - title: `📦 ${interaction.user.username}'s Inventory`, - description: inventoryDescription, - }).setThumbnail(interaction.user.displayAvatarURL()); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'inventory' }) -}; - - - - diff --git a/src/commands/Economy/mine.js b/src/commands/Economy/mine.js deleted file mode 100644 index f8dd54cba9..0000000000 --- a/src/commands/Economy/mine.js +++ /dev/null @@ -1,98 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { MessageTemplates } from '../../utils/messageTemplates.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const MINE_COOLDOWN = 60 * 60 * 1000; -const BASE_MIN_REWARD = 400; -const BASE_MAX_REWARD = 1200; -const PICKAXE_MULTIPLIER = 1.2; -const DIAMOND_PICKAXE_MULTIPLIER = 2.0; - -const MINE_LOCATIONS = [ - "abandoned gold mine", - "dark, damp cave", - "backyard rock quarry", - "volcanic obsidian vent", - "deep-sea mineral trench", -]; - -export default { - data: new SlashCommandBuilder() - .setName('mine') - .setDescription('Go mining to earn money'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - const userData = await getEconomyData(client, guildId, userId); - const lastMine = userData.lastMine || 0; - const hasDiamondPickaxe = userData.inventory["diamond_pickaxe"] || 0; - const hasPickaxe = userData.inventory["pickaxe"] || 0; - - if (now < lastMine + MINE_COOLDOWN) { - const remaining = lastMine + MINE_COOLDOWN - now; - const hours = Math.floor(remaining / (1000 * 60 * 60)); - const minutes = Math.floor( - (remaining % (1000 * 60 * 60)) / (1000 * 60), - ); - - throw createError( - "Mining cooldown active", - ErrorTypes.RATE_LIMIT, - `Your pickaxe is cooling down. Wait for **${hours}h ${minutes}m** before mining again.`, - { remaining, cooldownType: 'mine' } - ); - } - - const baseEarned = - Math.floor( - Math.random() * (BASE_MAX_REWARD - BASE_MIN_REWARD + 1), - ) + BASE_MIN_REWARD; - - let finalEarned = baseEarned; - let multiplierMessage = ""; - - if (hasDiamondPickaxe > 0) { - finalEarned = Math.floor(baseEarned * DIAMOND_PICKAXE_MULTIPLIER); - multiplierMessage = `\n💎 **Diamond Pickaxe Bonus: +100%**`; - } else if (hasPickaxe > 0) { - finalEarned = Math.floor(baseEarned * PICKAXE_MULTIPLIER); - multiplierMessage = `\n⛏️ **Pickaxe Bonus: +20%**`; - } - - const location = - MINE_LOCATIONS[ - Math.floor(Math.random() * MINE_LOCATIONS.length) - ]; - - userData.wallet += finalEarned; -userData.lastMine = now; - - await setEconomyData(client, guildId, userId, userData); - - const embed = successEmbed( - "💰 Mining Expedition Successful!", - `You explored a **${location}** and managed to find minerals worth **$${finalEarned.toLocaleString()}**!${multiplierMessage}`, - ) - .addFields({ - name: "💵 New Cash Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }) - .setFooter({ text: `Next mine available in 1 hour.` }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'mine' }) -}; - - - - diff --git a/src/commands/Economy/modules/shop_browse.js b/src/commands/Economy/modules/shop_browse.js deleted file mode 100644 index 53ae62d4e5..0000000000 --- a/src/commands/Economy/modules/shop_browse.js +++ /dev/null @@ -1,90 +0,0 @@ -import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, EmbedBuilder, MessageFlags } from 'discord.js'; -import { shopItems } from '../../../config/shop/items.js'; -import { getColor } from '../../../config/bot.js'; -import { logger } from '../../../utils/logger.js'; - -export default { - async execute(interaction, config, client) { - try { - const TARGET_MAX_PAGES = 3; - const ITEMS_PER_PAGE = Math.max(1, Math.ceil(shopItems.length / TARGET_MAX_PAGES)); - const totalPages = Math.ceil(shopItems.length / ITEMS_PER_PAGE); - let currentPage = 1; - - const createShopEmbed = (page) => { - const startIndex = (page - 1) * ITEMS_PER_PAGE; - const pageItems = shopItems.slice(startIndex, startIndex + ITEMS_PER_PAGE); - const embed = new EmbedBuilder() - .setTitle('🛒 Store') - .setColor(getColor('primary')) - .setDescription('Use `/buy item_id: quantity:` to purchase an item.'); - pageItems.forEach(item => { - embed.addFields({ - name: `${item.name} (${item.id})`, - value: `🏷️ **Type:** ${item.type}\n💚 **Price:** $${item.price.toLocaleString()}\n${item.description}`, - inline: false, - }); - }); - embed.setFooter({ text: `Page ${page}/${totalPages}` }); - return embed; - }; - - const createShopComponents = (page) => { - if (totalPages <= 1) return []; - return [ - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('shop_prev') - .setLabel('⬅️ Previous') - .setStyle(ButtonStyle.Secondary) - .setDisabled(page === 1), - new ButtonBuilder() - .setCustomId('shop_next') - .setLabel('Next ➡️') - .setStyle(ButtonStyle.Secondary) - .setDisabled(page === totalPages), - ), - ]; - }; - - const message = await interaction.reply({ - embeds: [createShopEmbed(currentPage)], - components: createShopComponents(currentPage), - flags: 0, - }); - - const collector = message.createMessageComponentCollector({ - componentType: ComponentType.Button, - time: 300000, - }); - - collector.on('collect', async (buttonInteraction) => { - if (buttonInteraction.user.id !== interaction.user.id) { - await buttonInteraction.reply({ content: '❌ You cannot use these buttons. Run `/shop browse` to get your own shop view.', flags: 64 }); - return; - } - const { customId } = buttonInteraction; - if (customId === 'shop_prev' || customId === 'shop_next') { - await buttonInteraction.deferUpdate(); - if (customId === 'shop_prev' && currentPage > 1) currentPage--; - else if (customId === 'shop_next' && currentPage < totalPages) currentPage++; - await buttonInteraction.editReply({ - embeds: [createShopEmbed(currentPage)], - components: createShopComponents(currentPage), - }); - } - }); - - collector.on('end', async () => { - try { - const disabledComponents = createShopComponents(currentPage); - disabledComponents.forEach(row => row.components.forEach(btn => btn.setDisabled(true))); - await message.edit({ components: disabledComponents }); - } catch (_) {} - }); - } catch (error) { - logger.error('shop_browse error:', error); - await interaction.reply({ content: '❌ An error occurred while loading the shop.', flags: MessageFlags.Ephemeral }); - } - }, -}; diff --git a/src/commands/Economy/modules/shop_config_setrole.js b/src/commands/Economy/modules/shop_config_setrole.js deleted file mode 100644 index aaf52f33fa..0000000000 --- a/src/commands/Economy/modules/shop_config_setrole.js +++ /dev/null @@ -1,36 +0,0 @@ -import { PermissionsBitField } from 'discord.js'; -import { errorEmbed, successEmbed } from '../../../utils/embeds.js'; -import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { logger } from '../../../utils/logger.js'; - -export default { - async execute(interaction, config, client) { - if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Permission Denied', 'You need **Manage Server** permissions to set the premium role.')], - ephemeral: true, - }); - } - - const role = interaction.options.getRole('role'); - const guildId = interaction.guildId; - - try { - const currentConfig = await getGuildConfig(client, guildId); - currentConfig.premiumRoleId = role.id; - await setGuildConfig(client, guildId, currentConfig); - - return InteractionHelper.safeReply(interaction, { - embeds: [successEmbed('✅ Premium Role Set', `The **Premium Shop Role** has been set to ${role.toString()}. Members who purchase the Premium Role item will be granted this role.`)], - ephemeral: true, - }); - } catch (error) { - logger.error('shop_config_setrole error:', error); - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('System Error', 'Could not save the guild configuration.')], - ephemeral: true, - }); - } - }, -}; diff --git a/src/commands/Economy/pay.js b/src/commands/Economy/pay.js deleted file mode 100644 index 40f6424beb..0000000000 --- a/src/commands/Economy/pay.js +++ /dev/null @@ -1,158 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, addMoney, removeMoney, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { MessageTemplates } from '../../utils/messageTemplates.js'; -import EconomyService from '../../services/economyService.js'; - -export default { - data: new SlashCommandBuilder() - .setName('pay') - .setDescription('Pay another user some of your cash') - .addUserOption(option => - option - .setName('user') - .setDescription('User to pay') - .setRequired(true) - ) - .addIntegerOption(option => - option - .setName('amount') - .setDescription('Amount to pay') - .setRequired(true) - .setMinValue(1) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const senderId = interaction.user.id; - const receiver = interaction.options.getUser("user"); - const amount = interaction.options.getInteger("amount"); - const guildId = interaction.guildId; - - logger.debug(`[ECONOMY] Pay command initiated`, { - senderId, - receiverId: receiver.id, - amount, - guildId - }); - - if (receiver.bot) { - throw createError( - "Cannot pay bot", - ErrorTypes.VALIDATION, - "You cannot pay a bot.", - { receiverId: receiver.id, isBot: true } - ); - } - - if (receiver.id === senderId) { - throw createError( - "Cannot pay self", - ErrorTypes.VALIDATION, - "You cannot pay yourself.", - { senderId, receiverId: receiver.id } - ); - } - - if (amount <= 0) { - throw createError( - "Invalid payment amount", - ErrorTypes.VALIDATION, - "Amount must be greater than zero.", - { amount, senderId } - ); - } - - const [senderData, receiverData] = await Promise.all([ - getEconomyData(client, guildId, senderId), - getEconomyData(client, guildId, receiver.id) - ]); - - if (!senderData) { - throw createError( - "Failed to load sender economy data", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId: senderId, guildId } - ); - } - - if (!receiverData) { - throw createError( - "Failed to load receiver economy data", - ErrorTypes.DATABASE, - "Failed to load the receiver's economy data. Please try again later.", - { userId: receiver.id, guildId } - ); - } - - - - const result = await EconomyService.transferMoney( - client, - guildId, - senderId, - receiver.id, - amount - ); - - - const updatedSenderData = await getEconomyData(client, guildId, senderId); - const updatedReceiverData = await getEconomyData(client, guildId, receiver.id); - - const embed = MessageTemplates.SUCCESS.DATA_UPDATED( - "payment", - `You successfully paid **${receiver.username}** the amount of **$${amount.toLocaleString()}**!` - ) - .addFields( - { - name: "💳 Payment Amount", - value: `$${amount.toLocaleString()}`, - inline: true, - }, - { - name: "💵 Your New Balance", - value: `$${updatedSenderData.wallet.toLocaleString()}`, - inline: true, - }, - ) - .setFooter({ - text: `Paid to ${receiver.tag}`, - iconURL: receiver.displayAvatarURL(), - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - - logger.info(`[ECONOMY] Payment sent successfully`, { - senderId, - receiverId: receiver.id, - amount, - senderBalance: updatedSenderData.wallet, - receiverBalance: updatedReceiverData.wallet - }); - - try { - const receiverEmbed = createEmbed({ - title: "💰 Incoming Payment!", - description: `${interaction.user.username} paid you **$${amount.toLocaleString()}**.` - }).addFields({ - name: "Your New Cash", - value: `$${updatedReceiverData.wallet.toLocaleString()}`, - inline: true, - }); - await receiver.send({ embeds: [receiverEmbed] }); - } catch (e) { - logger.warn(`Could not DM user ${receiver.id}: ${e.message}`); - } - }, { command: 'pay' }) -}; - - - - - diff --git a/src/commands/Economy/rob.js b/src/commands/Economy/rob.js deleted file mode 100644 index d6ae214966..0000000000 --- a/src/commands/Economy/rob.js +++ /dev/null @@ -1,156 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { MessageTemplates } from '../../utils/messageTemplates.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const ROB_COOLDOWN = 4 * 60 * 60 * 1000; -const BASE_ROB_SUCCESS_CHANCE = 0.25; -const ROB_PERCENTAGE = 0.15; -const FINE_PERCENTAGE = 0.1; - -export default { - data: new SlashCommandBuilder() - .setName('rob') - .setDescription('Attempt to rob another user (very risky)') - .addUserOption(option => - option - .setName('user') - .setDescription('User to rob') - .setRequired(true) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const robberId = interaction.user.id; - const victimUser = interaction.options.getUser("user"); - const guildId = interaction.guildId; - const now = Date.now(); - - if (robberId === victimUser.id) { - throw createError( - "Cannot rob self", - ErrorTypes.VALIDATION, - "You cannot rob yourself.", - { robberId, victimId: victimUser.id } - ); - } - - if (victimUser.bot) { - throw createError( - "Cannot rob bot", - ErrorTypes.VALIDATION, - "You cannot rob a bot.", - { victimId: victimUser.id, isBot: true } - ); - } - - const robberData = await getEconomyData(client, guildId, robberId); - const victimData = await getEconomyData(client, guildId, victimUser.id); - - if (!robberData || !victimData) { - throw createError( - "Failed to load economy data", - ErrorTypes.DATABASE, - "Failed to load economy data. Please try again later.", - { robberId: !!robberData, victimId: !!victimData, guildId } - ); - } - - const lastRob = robberData.lastRob || 0; - - if (now < lastRob + ROB_COOLDOWN) { - const remaining = lastRob + ROB_COOLDOWN - now; - const hours = Math.floor(remaining / (1000 * 60 * 60)); - const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60)); - - throw createError( - "Robbery cooldown active", - ErrorTypes.RATE_LIMIT, - `You need to lay low. Wait **${hours}h ${minutes}m** before attempting another robbery.`, - { remaining, hours, minutes, cooldownType: 'rob' } - ); - } - - if (victimData.wallet < 500) { - throw createError( - "Victim too poor", - ErrorTypes.VALIDATION, - `${victimUser.username} is too poor. They need at least $500 cash to be worth robbing.`, - { victimWallet: victimData.wallet, required: 500 } - ); - } - - const hasSafe = victimData.inventory["personal_safe"] || 0; - - if (hasSafe > 0) { - robberData.lastRob = now; - await setEconomyData(client, guildId, robberId, robberData); - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - MessageTemplates.ERRORS.CONFIGURATION_REQUIRED( - "robbery protection", - `${victimUser.username} was prepared! Your attempt failed because they own a **Personal Safe**. You got away clean but didn't gain anything.` - ) - ], - }); - } - - const isSuccessful = Math.random() < BASE_ROB_SUCCESS_CHANCE; - let resultEmbed; - - if (isSuccessful) { - const amountStolen = Math.floor(victimData.wallet * ROB_PERCENTAGE); - - robberData.wallet = (robberData.wallet || 0) + amountStolen; - victimData.wallet = (victimData.wallet || 0) - amountStolen; - - resultEmbed = MessageTemplates.SUCCESS.DATA_UPDATED( - "robbery", - `You successfully stole **$${amountStolen.toLocaleString()}** from ${victimUser.username}!` - ); - } else { - const fineAmount = Math.floor((robberData.wallet || 0) * FINE_PERCENTAGE); - - if ((robberData.wallet || 0) < fineAmount) { - robberData.wallet = 0; - } else { - robberData.wallet = (robberData.wallet || 0) - fineAmount; - } - - resultEmbed = MessageTemplates.ERRORS.INSUFFICIENT_PERMISSIONS( - "robbery failed", - `You failed the robbery and were caught! You were fined **$${fineAmount.toLocaleString()}** of your own cash.` - ); - } - - robberData.lastRob = now; - - await setEconomyData(client, guildId, robberId, robberData); - await setEconomyData(client, guildId, victimUser.id, victimData); - - resultEmbed - .addFields( - { - name: `Your New Cash (${interaction.user.username})`, - value: `$${robberData.wallet.toLocaleString()}`, - inline: true, - }, - { - name: `Victim's New Cash (${victimUser.username})`, - value: `$${victimData.wallet.toLocaleString()}`, - inline: true, - }, - ) - .setFooter({ text: `Next robbery available in 4 hours.` }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [resultEmbed] }); - }, { command: 'rob' }) -}; - - - diff --git a/src/commands/Economy/shop.js b/src/commands/Economy/shop.js deleted file mode 100644 index 6ebdfe4585..0000000000 --- a/src/commands/Economy/shop.js +++ /dev/null @@ -1,60 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { errorEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -import shopBrowse from './modules/shop_browse.js'; -import shopConfigSetrole from './modules/shop_config_setrole.js'; - -export default { - data: new SlashCommandBuilder() - .setName('shop') - .setDescription('Economy shop commands.') - .addSubcommand(subcommand => - subcommand - .setName('browse') - .setDescription('Browse the economy shop.'), - ) - .addSubcommandGroup(group => - group - .setName('config') - .setDescription('Configure shop settings. (Manage Server required)') - .addSubcommand(subcommand => - subcommand - .setName('setrole') - .setDescription('Set the Discord role granted when the Premium Role shop item is purchased.') - .addRoleOption(option => - option - .setName('role') - .setDescription('The role to grant for Premium Role purchases.') - .setRequired(true), - ), - ), - ), - - async execute(interaction, config, client) { - try { - const subcommandGroup = interaction.options.getSubcommandGroup(false); - const subcommand = interaction.options.getSubcommand(); - - if (subcommand === 'browse') { - return await shopBrowse.execute(interaction, config, client); - } - - if (subcommandGroup === 'config' && subcommand === 'setrole') { - return await shopConfigSetrole.execute(interaction, config, client); - } - - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Error', 'Unknown subcommand.')], - flags: MessageFlags.Ephemeral, - }); - } catch (error) { - logger.error('shop command error:', error); - await InteractionHelper.safeReply(interaction, { - content: '❌ An error occurred while running the shop command.', - flags: MessageFlags.Ephemeral, - }).catch(() => {}); - } - }, -}; \ No newline at end of file diff --git a/src/commands/Economy/slut.js b/src/commands/Economy/slut.js deleted file mode 100644 index 3d4791cf95..0000000000 --- a/src/commands/Economy/slut.js +++ /dev/null @@ -1,193 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const SLUT_COOLDOWN = 45 * 60 * 1000; - -const SLUT_ACTIVITIES = [ - { name: "Cam Stream", min: 120, max: 450, risk: 0.2 }, - { name: "Private Dance Session", min: 220, max: 700, risk: 0.25 }, - { name: "After-Hours Club Host", min: 320, max: 900, risk: 0.3 }, - { name: "VIP Companion Booking", min: 550, max: 1400, risk: 0.35 }, - { name: "Exclusive Livestream", min: 850, max: 2200, risk: 0.4 }, -]; - -const POSITIVE_OUTCOMES = [ - "Your stream blew up and tips poured in.", - "A VIP booking paid far above average.", - "Your after-hours shift was packed and profitable.", - "Premium requests came through and your payout jumped.", -]; - -const FINE_OUTCOMES = [ - "Venue security issued a compliance fine.", - "A moderation strike triggered a platform fee.", - "You were flagged and had to pay a penalty.", -]; - -const ROBBED_OUTCOMES = [ - "A fake buyer chargeback wiped part of your earnings.", - "A scam booking cleaned out a chunk of your cash.", - "You got baited by a fraud account and lost money.", -]; - -const LOSS_OUTCOMES = [ - "The set flopped and you had to cover operating costs.", - "You burned budget on prep and made no return.", - "The shift went sideways and left you in the red.", -]; - -function randomInt(min, max) { - return Math.floor(Math.random() * (max - min + 1)) + min; -} - -function randomChoice(items) { - return items[Math.floor(Math.random() * items.length)]; -} - -function resolveOutcome(activity, wallet) { - const successChance = Math.max(0.35, 0.55 - activity.risk * 0.2); - const fineChance = 0.22; - const robbedChance = 0.2; - const roll = Math.random(); - - if (roll < successChance) { - const amount = randomInt(activity.min, activity.max); - return { - type: 'payout', - delta: amount, - message: randomChoice(POSITIVE_OUTCOMES), - title: `💰 ${activity.name} - Payout` - }; - } - - const remainingAfterSuccess = roll - successChance; - - if (remainingAfterSuccess < fineChance) { - const maxFine = Math.min(wallet, Math.max(150, Math.floor(activity.max * 0.4))); - const minFine = Math.min(maxFine, Math.max(50, Math.floor(activity.min * 0.2))); - const amount = maxFine > 0 ? randomInt(minFine, maxFine) : 0; - return { - type: 'fine', - delta: -amount, - message: randomChoice(FINE_OUTCOMES), - title: `🚨 ${activity.name} - Fined` - }; - } - - if (remainingAfterSuccess < fineChance + robbedChance) { - const maxRobbed = Math.min(wallet, Math.max(200, Math.floor(wallet * 0.35))); - const minRobbed = Math.min(maxRobbed, Math.max(75, Math.floor(wallet * 0.1))); - const amount = maxRobbed > 0 ? randomInt(minRobbed, maxRobbed) : 0; - return { - type: 'robbed', - delta: -amount, - message: randomChoice(ROBBED_OUTCOMES), - title: `🕵️ ${activity.name} - Robbed` - }; - } - - const maxLoss = Math.min(wallet, Math.max(100, Math.floor(activity.max * 0.3))); - const minLoss = Math.min(maxLoss, Math.max(40, Math.floor(activity.min * 0.15))); - const amount = maxLoss > 0 ? randomInt(minLoss, maxLoss) : 0; - return { - type: 'loss', - delta: -amount, - message: randomChoice(LOSS_OUTCOMES), - title: `❌ ${activity.name} - Loss` - }; -} - -export default { - data: new SlashCommandBuilder() - .setName('slut') - .setDescription('Take a risky provocative job for random payout or loss'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - logger.debug(`[ECONOMY] Slut command started for ${userId}`, { userId, guildId }); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data for slut command", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - const lastSlut = userData.lastSlut || 0; - - if (now - lastSlut < SLUT_COOLDOWN) { - const remainingTime = lastSlut + SLUT_COOLDOWN - now; - throw createError( - "Slut cooldown active", - ErrorTypes.RATE_LIMIT, - `You need to wait before you can work again! Try again in **${Math.ceil(remainingTime / 60000)}** minutes.`, - { timeRemaining: remainingTime, cooldownType: 'slut' } - ); - } - - const activity = randomChoice(SLUT_ACTIVITIES); - - const outcome = resolveOutcome(activity, userData.wallet || 0); - - userData.lastSlut = now; - userData.totalSluts = (userData.totalSluts || 0) + 1; - userData.totalSlutEarnings = (userData.totalSlutEarnings || 0) + Math.max(0, outcome.delta); - userData.totalSlutLosses = (userData.totalSlutLosses || 0) + Math.max(0, -outcome.delta); - - if (outcome.type !== 'payout') { - userData.failedSluts = (userData.failedSluts || 0) + 1; - } - - userData.wallet = Math.max(0, (userData.wallet || 0) + outcome.delta); - - await setEconomyData(client, guildId, userId, userData); - - logger.info(`[ECONOMY_TRANSACTION] Slut activity resolved`, { - userId, - guildId, - activity: activity.name, - outcomeType: outcome.type, - amountDelta: outcome.delta, - newWallet: userData.wallet, - timestamp: new Date().toISOString() - }); - - const amountLabel = `${outcome.delta >= 0 ? '+' : '-'}$${Math.abs(outcome.delta).toLocaleString()}`; - const summaryLines = [ - `${outcome.message}`, - `💸 **Net Result:** ${amountLabel}`, - `💳 **Current Balance:** $${userData.wallet.toLocaleString()}`, - `📊 **Total Sessions:** ${userData.totalSluts}`, - `💵 **Total Earned:** $${(userData.totalSlutEarnings || 0).toLocaleString()}`, - `🧾 **Total Lost:** $${(userData.totalSlutLosses || 0).toLocaleString()}` - ]; - - const embed = createEmbed({ - title: outcome.title, - description: summaryLines.join('\n'), - color: outcome.delta >= 0 ? 'success' : 'error', - timestamp: true - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'slut' }) -}; - - - - - diff --git a/src/commands/Economy/withdraw.js b/src/commands/Economy/withdraw.js deleted file mode 100644 index 2fc0b46a17..0000000000 --- a/src/commands/Economy/withdraw.js +++ /dev/null @@ -1,86 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData, getMaxBankCapacity } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { MessageTemplates } from '../../utils/messageTemplates.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName('withdraw') - .setDescription('Withdraw money from your bank to your wallet') - .addIntegerOption(option => - option - .setName('amount') - .setDescription('Amount to withdraw') - .setRequired(true) - .setMinValue(1) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - await InteractionHelper.safeDefer(interaction); - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const amountInput = interaction.options.getInteger("amount"); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - let withdrawAmount = amountInput; - - if (withdrawAmount <= 0) { - throw createError( - "Invalid withdrawal amount", - ErrorTypes.VALIDATION, - "You must withdraw a positive amount.", - { amount: withdrawAmount, userId } - ); - } - - if (withdrawAmount > userData.bank) { - withdrawAmount = userData.bank; - } - - if (withdrawAmount === 0) { - throw createError( - "Empty bank account", - ErrorTypes.VALIDATION, - "Your bank account is empty.", - { userId, bankBalance: userData.bank } - ); - } - - userData.wallet += withdrawAmount; - userData.bank -= withdrawAmount; - - await setEconomyData(client, guildId, userId, userData); - - const embed = MessageTemplates.SUCCESS.DATA_UPDATED( - "withdrawal", - `You successfully withdrew **$${withdrawAmount.toLocaleString()}** from your bank.` - ) - .addFields( - { - name: "💵 New Cash Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }, - { - name: "🏦 New Bank Balance", - value: `$${userData.bank.toLocaleString()}`, - inline: true, - }, - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'withdraw' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/work.js b/src/commands/Economy/work.js deleted file mode 100644 index ca39b2077b..0000000000 --- a/src/commands/Economy/work.js +++ /dev/null @@ -1,127 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const WORK_COOLDOWN = 30 * 60 * 1000; -const MIN_WORK_AMOUNT = 50; -const MAX_WORK_AMOUNT = 300; -const LAPTOP_MULTIPLIER = 1.5; -const WORK_JOBS = [ - "Software Developer", - "Barista", - "Janitor", - "YouTuber", - "Discord Bot Developer", - "Cashier", - "Pizza Delivery Driver", - "Librarian", - "Gardener", - "Data Analyst", -]; - -export default { - data: new SlashCommandBuilder() - .setName('work') - .setDescription('Work to earn some money'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data for work", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - logger.debug(`[ECONOMY] Work command started for ${userId}`, { userId, guildId }); - - const lastWork = userData.lastWork || 0; - const inventory = userData.inventory || {}; - const extraWorkShifts = inventory["extra_work"] || 0; - const hasLaptop = inventory["laptop"] || 0; - - let cooldownActive = now < lastWork + WORK_COOLDOWN; - let usedConsumable = false; - - if (cooldownActive) { - if (extraWorkShifts > 0) { - inventory["extra_work"] = (inventory["extra_work"] || 0) - 1; - usedConsumable = true; - } else { - const remaining = lastWork + WORK_COOLDOWN - now; - throw createError( - "Work cooldown active", - ErrorTypes.RATE_LIMIT, - `You're working too fast! Wait **${Math.floor(remaining / 3600000)}h ${Math.floor((remaining % 3600000) / 60000)}m** before working again.`, - { timeRemaining: remaining, cooldownType: 'work' } - ); - } - } - - let earned = Math.floor(Math.random() * (MAX_WORK_AMOUNT - MIN_WORK_AMOUNT + 1)) + MIN_WORK_AMOUNT; - const job = WORK_JOBS[Math.floor(Math.random() * WORK_JOBS.length)]; - - - let multiplierMessage = ""; - if (hasLaptop > 0) { - earned = Math.floor(earned * LAPTOP_MULTIPLIER); - multiplierMessage = "\n💻 **Laptop Bonus:** +50% earnings!"; - } - - userData.wallet = (userData.wallet || 0) + earned; - userData.lastWork = now; - - await setEconomyData(client, guildId, userId, userData); - - logger.info(`[ECONOMY_TRANSACTION] Work completed`, { - userId, - guildId, - amount: earned, - job, - usedConsumable, - hasLaptop: hasLaptop > 0, - newWallet: userData.wallet, - timestamp: new Date().toISOString() - }); - - const embed = successEmbed( - "💼 Work Complete!", - `You worked as a **${job}** and earned **$${earned.toLocaleString()}**!${multiplierMessage}` - ) - .addFields( - { - name: "💰 New Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }, - { - name: "⏰ Next Work", - value: ``, - inline: true, - } - ) - .setFooter({ - text: `Requested by ${interaction.user.tag}`, - iconURL: interaction.user.displayAvatarURL(), - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'work' }) -}; - - - - diff --git a/src/commands/Fun/fact.js b/src/commands/Fun/fact.js deleted file mode 100644 index 1925b2331b..0000000000 --- a/src/commands/Fun/fact.js +++ /dev/null @@ -1,42 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -const facts = [ - "A day on Venus is longer than a year on Venus.", - "The shortest war in history was between Britain and Zanzibar on August 27, 1896. It lasted 38 to 45 minutes.", - "The word 'Strengths' is the longest word in the English language with only one vowel.", - "Octopuses have three hearts and blue blood.", - "There are more trees on Earth than stars in the Milky Way galaxy.", - "The total weight of all the ants on Earth is thought to be about the same as the total weight of all humans.", -]; - -export default { - data: new SlashCommandBuilder() - .setName("fact") - .setDescription("Shares a random, interesting fact."), - category: 'Fun', - - async execute(interaction, config, client) { - try { - const randomFact = facts[Math.floor(Math.random() * facts.length)]; - - const embed = successEmbed("🧠 Did You Know?", `💡 **${randomFact}**`); - - await InteractionHelper.safeReply(interaction, { embeds: [embed] }); - logger.debug(`Fact command executed by user ${interaction.user.id} in guild ${interaction.guildId}`); - } catch (error) { - logger.error('Fact command error:', error); - await handleInteractionError(interaction, error, { - commandName: 'fact', - source: 'fact_command' - }); - } - }, -}; - - - - diff --git a/src/commands/Fun/fight.js b/src/commands/Fun/fight.js deleted file mode 100644 index 23625102de..0000000000 --- a/src/commands/Fun/fight.js +++ /dev/null @@ -1,99 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { successEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -const rand = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; -const EMBED_DESCRIPTION_LIMIT = 4096; - -export default { - data: new SlashCommandBuilder() - .setName("fight") - .setDescription("Starts a simulated 1v1 text-based battle.") - .addUserOption((option) => - option - .setName("opponent") - .setDescription("The user to fight.") - .setRequired(true), - ), - category: 'Fun', - - async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction); - - const challenger = interaction.user; - const opponent = interaction.options.getUser("opponent"); - - - if (challenger.id === opponent.id) { - const embed = warningEmbed( - `**${challenger.username}**, you can't fight yourself! That's a draw before it even starts.`, - "⚔️ Invalid Challenge" - ); - return await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } - - - if (opponent.bot) { - const embed = warningEmbed( - "You can't fight bots! Challenge a real person instead.", - "⚔️ Invalid Opponent" - ); - return await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } - - const winner = rand(0, 1) === 0 ? challenger : opponent; - const loser = winner.id === challenger.id ? opponent : challenger; - const rounds = rand(3, 7); - const damage = rand(10, 50); - - const log = []; - log.push( - `💥 **${challenger.username}** challenges **${opponent.username}** to a duel! (Best of ${rounds} rounds)`, - ); - - for (let i = 1; i <= rounds; i++) { - const attacker = rand(0, 1) === 0 ? challenger : opponent; - const target = attacker.id === challenger.id ? opponent : challenger; - const action = [ - "throws a wild punch", - "lands a critical hit", - "uses a weak spell", - "parries and counterattacks", - ][rand(0, 3)]; - log.push( - `\n**Round ${i}:** ${attacker.username} ${action} on ${target.username} for ${rand(1, damage)} damage!`, - ); - } - - const outcomeText = log.join("\n"); - const winnerText = `👑 **${winner.username}** has defeated ${loser.username} and claims the victory!`; - const fullDescription = `${outcomeText}\n\n${winnerText}`; - - const description = fullDescription.length <= EMBED_DESCRIPTION_LIMIT - ? fullDescription - : `${fullDescription.slice(0, EMBED_DESCRIPTION_LIMIT - 15)}\n\n...`; - - const embed = successEmbed( - description, - "🏆 Duel Complete!" - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.debug(`Fight command executed between ${challenger.id} and ${opponent.id} in guild ${interaction.guildId}`); - } catch (error) { - logger.error('Fight command error:', error); - await handleInteractionError(interaction, error, { - commandName: 'fight', - source: 'fight_command' - }); - } - }, -}; - - - - - diff --git a/src/commands/Fun/flip.js b/src/commands/Fun/flip.js deleted file mode 100644 index 1e87ff589c..0000000000 --- a/src/commands/Fun/flip.js +++ /dev/null @@ -1,36 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("flip") - .setDescription("Flips a coin (Heads or Tails)."), - category: 'Fun', - - async execute(interaction, config, client) { - try { - const result = Math.random() < 0.5 ? "Heads" : "Tails"; - const emoji = result === "Heads" ? "🪙" : "🔮"; - - const embed = successEmbed( - "Heads or Tails?", - `The coin landed on... **${result}** ${emoji}!`, - ); - - await InteractionHelper.safeReply(interaction, { embeds: [embed] }); - logger.debug(`Flip command executed by user ${interaction.user.id} in guild ${interaction.guildId}`); - } catch (error) { - logger.error('Flip command error:', error); - await handleInteractionError(interaction, error, { - commandName: 'flip', - source: 'flip_command' - }); - } - }, -}; - - - diff --git a/src/commands/Fun/mock.js b/src/commands/Fun/mock.js deleted file mode 100644 index 4caf91f94a..0000000000 --- a/src/commands/Fun/mock.js +++ /dev/null @@ -1,61 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { sanitizeInput } from '../../utils/sanitization.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("mock") - .setDescription("cOnVeRtS yOuR tExT tO sPoNgEbOb CaSe.") - .addStringOption((option) => - option - .setName("text") - .setDescription("The text to mock.") - .setRequired(true) - .setMaxLength(1000), - ), - category: 'Fun', - - async execute(interaction, config, client) { - try { - const originalText = interaction.options.getString("text"); - - - if (!originalText || originalText.trim().length === 0) { - throw new TitanBotError( - 'Empty text provided to mock command', - ErrorTypes.USER_INPUT, - 'Please provide some text to mock!' - ); - } - - - const sanitizedText = sanitizeInput(originalText, 1000); - - let mockedText = ""; - for (let i = 0; i < sanitizedText.length; i++) { - const char = sanitizedText[i]; - if (i % 2 === 0) { - mockedText += char.toLowerCase(); - } else { - mockedText += char.toUpperCase(); - } - } - - const embed = successEmbed("sPoNgEbOb cAsE", `"${mockedText}"`); - - await InteractionHelper.safeReply(interaction, { embeds: [embed] }); - logger.debug(`Mock command executed by user ${interaction.user.id} in guild ${interaction.guildId}`); - } catch (error) { - logger.error('Mock command error:', error); - await handleInteractionError(interaction, error, { - commandName: 'mock', - source: 'mock_command' - }); - } - }, -}; - - diff --git a/src/commands/Fun/reverse.js b/src/commands/Fun/reverse.js deleted file mode 100644 index 5f6357c8a9..0000000000 --- a/src/commands/Fun/reverse.js +++ /dev/null @@ -1,55 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { sanitizeInput } from '../../utils/sanitization.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("reverse") - .setDescription("Writes your text backwards.") - .addStringOption((option) => - option - .setName("text") - .setDescription("The text to reverse.") - .setRequired(true) - .setMaxLength(1000), - ), - category: 'Fun', - - async execute(interaction, config, client) { - try { - const originalText = interaction.options.getString("text"); - - - if (!originalText || originalText.trim().length === 0) { - throw new TitanBotError( - 'Empty text provided to reverse command', - ErrorTypes.USER_INPUT, - 'Please provide some text to reverse!' - ); - } - - - const sanitizedText = sanitizeInput(originalText, 1000); - const reversedText = sanitizedText.split("").reverse().join(""); - - const embed = successEmbed( - "Backwards Text", - `Original: **${sanitizedText}**\nReversed: **${reversedText}**`, - ); - - await InteractionHelper.safeReply(interaction, { embeds: [embed] }); - logger.debug(`Reverse command executed by user ${interaction.user.id} in guild ${interaction.guildId}`); - } catch (error) { - logger.error('Reverse command error:', error); - await handleInteractionError(interaction, error, { - commandName: 'reverse', - source: 'reverse_command' - }); - } - }, -}; - - diff --git a/src/commands/Fun/roll.js b/src/commands/Fun/roll.js deleted file mode 100644 index 122cdb4eea..0000000000 --- a/src/commands/Fun/roll.js +++ /dev/null @@ -1,92 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("roll") - .setDescription("Rolls dice using standard notation (e.g., 2d20, 1d6 + 5).") - .addStringOption((option) => - option - .setName("notation") - .setDescription("The dice notation (e.g., 2d6, 1d20 + 4)") - .setRequired(true) - .setMaxLength(50), - ), - category: 'Fun', - - async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction); - - const notation = interaction.options - .getString("notation") - .toLowerCase() - .replace(/\s/g, ""); - - const match = notation.match(/^(\d*)d(\d+)([\+\-]\d+)?$/); - - if (!match) { - throw new TitanBotError( - `Invalid dice notation: ${notation}`, - ErrorTypes.USER_INPUT, - 'Invalid notation. Use format like `1d20` or `3d6+5`.' - ); - } - - const numDice = parseInt(match[1] || "1", 10); - const numSides = parseInt(match[2], 10); - const modifier = parseInt(match[3] || "0", 10); - - - if (numDice < 1 || numDice > 20) { - throw new TitanBotError( - `Too many dice requested: ${numDice}`, - ErrorTypes.VALIDATION, - 'Please keep the number of dice between 1 and 20.' - ); - } - - if (numSides < 1 || numSides > 1000) { - throw new TitanBotError( - `Invalid number of sides: ${numSides}`, - ErrorTypes.VALIDATION, - 'Please keep the number of sides between 1 and 1000.' - ); - } - - let rolls = []; - let totalRoll = 0; - - for (let i = 0; i < numDice; i++) { - const roll = Math.floor(Math.random() * numSides) + 1; - rolls.push(roll); - totalRoll += roll; - } - - const finalTotal = totalRoll + modifier; - - const resultsDetail = - numDice > 1 ? `**Rolls:** ${rolls.join(" + ")}\n` : ""; - const modifierText = modifier !== 0 ? ` + (${modifier})` : ""; - - const embed = successEmbed( - `🎲 Rolling ${numDice}d${numSides}${modifier !== 0 ? match[3] : ""}`, - `${resultsDetail}**Total Roll:** ${totalRoll}${modifierText} = **${finalTotal}**`, - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.debug(`Roll command executed by user ${interaction.user.id} with notation ${notation} in guild ${interaction.guildId}`); - } catch (error) { - await handleInteractionError(interaction, error, { - commandName: 'roll', - source: 'roll_command' - }); - } - }, -}; - - - diff --git a/src/commands/Fun/ship.js b/src/commands/Fun/ship.js deleted file mode 100644 index 5c85ec5de1..0000000000 --- a/src/commands/Fun/ship.js +++ /dev/null @@ -1,109 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { sanitizeInput } from '../../utils/sanitization.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -function stringToHash(str) { - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = (hash << 5) - hash + char; - hash |= 0; - } - return Math.abs(hash); -} - -export default { - data: new SlashCommandBuilder() - .setName("ship") - .setDescription("Calculate the compatibility score between two people.") - .addStringOption((option) => - option - .setName("name1") - .setDescription("The first name or user.") - .setRequired(true) - .setMaxLength(100), - ) - .addStringOption((option) => - option - .setName("name2") - .setDescription("The second name or user.") - .setRequired(true) - .setMaxLength(100), - ), - category: 'Fun', - - async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction); - - const name1Raw = interaction.options.getString("name1"); - const name2Raw = interaction.options.getString("name2"); - - - if (!name1Raw || name1Raw.trim().length === 0 || !name2Raw || name2Raw.trim().length === 0) { - throw new TitanBotError( - 'Empty names provided to ship command', - ErrorTypes.USER_INPUT, - 'Please provide valid names for both people!' - ); - } - - - const name1 = sanitizeInput(name1Raw.trim(), 100); - const name2 = sanitizeInput(name2Raw.trim(), 100); - - - if (name1.toLowerCase() === name2.toLowerCase()) { - const embed = warningEmbed( - "💖 Ship Score", - `**${name1}** can't be shipped with themselves! Please choose two different people.` - ); - return await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } - - const sortedNames = [name1, name2].sort(); - const combination = sortedNames.join("-").toLowerCase(); - const score = stringToHash(combination) % 101; - - let description; - if (score === 100) { - description = "Soulmates! It's destiny, they belong together!"; - } else if (score >= 80) { - description = "A perfect match! Get the wedding bells ready!"; - } else if (score >= 60) { - description = "Solid chemistry. Definitely worth exploring!"; - } else if (score >= 40) { - description = "Just friends status. Maybe with time?"; - } else if (score >= 20) { - description = "It's a struggle. They might need space."; - } else { - description = "Zero compatibility. Run for the hills!"; - } - - const progressBar = - "█".repeat(Math.floor(score / 10)) + - "░".repeat(10 - Math.floor(score / 10)); - - const embed = successEmbed( - `💖 Ship Score: ${name1} vs ${name2}`, - `Compatibility: **${score}%**\n\n\`${progressBar}\`\n\n*${description}*`, - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.debug(`Ship command executed by user ${interaction.user.id} in guild ${interaction.guildId}`); - } catch (error) { - logger.error('Ship command error:', error); - await handleInteractionError(interaction, error, { - commandName: 'ship', - source: 'ship_command' - }); - } - }, -}; - - - - diff --git a/src/commands/Fun/wanted.js b/src/commands/Fun/wanted.js deleted file mode 100644 index 83c9f68b1a..0000000000 --- a/src/commands/Fun/wanted.js +++ /dev/null @@ -1,89 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { sanitizeInput } from '../../utils/sanitization.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("wanted") - .setDescription("Create a WANTED poster for a user.") - .addUserOption((option) => - option - .setName("user") - .setDescription("The user who is wanted.") - .setRequired(true), - ) - .addStringOption((option) => - option - .setName("crime") - .setDescription("The crime they committed.") - .setRequired(false) - .setMaxLength(100), - ), - category: 'Fun', - - async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction); - - const targetUser = interaction.options.getUser("user"); - const crimeRaw = interaction.options.getString("crime"); - - - let crime = "Too adorable for this server."; - if (crimeRaw) { - const sanitizedCrime = sanitizeInput(crimeRaw.trim(), 100); - if (sanitizedCrime.length > 0) { - crime = sanitizedCrime; - } - } - - - if (!targetUser) { - throw new TitanBotError( - 'Target user not found for wanted command', - ErrorTypes.USER_INPUT, - 'Could not find the specified user.' - ); - } - - const bountyAmount = Math.floor( - Math.random() * (100000000 - 1000000) + 1000000, - ); - const bounty = `$${bountyAmount.toLocaleString()} USD`; - - const embed = createEmbed({ - color: 'primary', - title: '💥 BIG BOUNTY: WANTED! 💥', - description: `**CRIMINAL:** ${targetUser.tag}\n**CRIME:** ${crime}`, - fields: [ - { - name: "DEAD OR ALIVE", - value: `**BOUNTY:** ${bounty}`, - inline: false, - }, - ], - image: { - url: targetUser.displayAvatarURL({ size: 1024, extension: 'png' }), - }, - footer: { - text: `Last seen in ${interaction.guild.name}`, - }, - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.debug(`Wanted command executed by user ${interaction.user.id} for ${targetUser.id} in guild ${interaction.guildId}`); - } catch (error) { - logger.error('Wanted command error:', error); - await handleInteractionError(interaction, error, { - commandName: 'wanted', - source: 'wanted_command' - }); - } - }, -}; - - - diff --git a/src/commands/Giveaway/gcreate.js b/src/commands/Giveaway/gcreate.js deleted file mode 100644 index fff217ca04..0000000000 --- a/src/commands/Giveaway/gcreate.js +++ /dev/null @@ -1,199 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, MessageFlags } from 'discord.js'; -import { errorEmbed, successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { TitanBotError, ErrorTypes, handleInteractionError } from '../../utils/errorHandler.js'; -import { saveGiveaway } 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'; - -export default { - data: new SlashCommandBuilder() - .setName("gcreate") - .setDescription("Starts a new giveaway in a specified channel.") - .addStringOption((option) => - option - .setName("duration") - .setDescription( - "How long the giveaway should last (e.g., 1h, 30m, 5d).", - ) - .setRequired(true), - ) - .addIntegerOption((option) => - option - .setName("winners") - .setDescription("The number of winners to pick.") - .setMinValue(1) - .setMaxValue(10) - .setRequired(true), - ) - .addStringOption((option) => - option - .setName("prize") - .setDescription("The prize being given away.") - .setRequired(true), - ) - .addChannelOption((option) => - option - .setName("channel") - .setDescription("The channel to send the giveaway to (defaults to current channel).") - .addChannelTypes(ChannelType.GuildText) - .setRequired(false), - ) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild), - - async execute(interaction) { - try { - - if (!interaction.inGuild()) { - throw new TitanBotError( - 'Giveaway command used outside guild', - ErrorTypes.VALIDATION, - 'This command can only be used in a server.', - { userId: interaction.user.id } - ); - } - - - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { - throw new TitanBotError( - 'User lacks ManageGuild permission', - ErrorTypes.PERMISSION, - "You need the 'Manage Server' permission to start a giveaway.", - { userId: interaction.user.id, guildId: interaction.guildId } - ); - } - - logger.info(`Giveaway creation started by ${interaction.user.tag} in guild ${interaction.guildId}`); - - - const durationString = interaction.options.getString("duration"); - const winnerCount = interaction.options.getInteger("winners"); - const prize = interaction.options.getString("prize"); - const targetChannel = interaction.options.getChannel("channel") || interaction.channel; - - - const durationMs = parseDuration(durationString); - validateWinnerCount(winnerCount); - const prizeName = validatePrize(prize); - - - if (!targetChannel.isTextBased()) { - throw new TitanBotError( - 'Target channel is not text-based', - ErrorTypes.VALIDATION, - 'The channel must be a text channel.', - { channelId: targetChannel.id, channelType: targetChannel.type } - ); - } - - const endTime = Date.now() + durationMs; - - - const initialGiveawayData = { - messageId: "placeholder", - channelId: targetChannel.id, - guildId: interaction.guildId, - prize: prizeName, - hostId: interaction.user.id, - endTime: endTime, - endsAt: endTime, - winnerCount: winnerCount, - participants: [], - isEnded: false, - ended: false, - createdAt: new Date().toISOString() - }; - - - const embed = createGiveawayEmbed(initialGiveawayData, "active"); - const row = createGiveawayButtons(false); - - - const giveawayMessage = await targetChannel.send({ - content: "🎉 **NEW GIVEAWAY** 🎉", - embeds: [embed], - components: [row], - }); - - - initialGiveawayData.messageId = giveawayMessage.id; - const saved = await saveGiveaway( - interaction.client, - interaction.guildId, - initialGiveawayData, - ); - - if (!saved) { - logger.warn(`Failed to save giveaway to database: ${giveawayMessage.id}`); - } - - - try { - await logEvent({ - client: interaction.client, - guildId: interaction.guildId, - eventType: EVENT_TYPES.GIVEAWAY_CREATE, - data: { - description: `Giveaway created: ${prizeName}`, - channelId: targetChannel.id, - userId: interaction.user.id, - fields: [ - { - name: '🎁 Prize', - value: prizeName, - inline: true - }, - { - name: '🏆 Winners', - value: winnerCount.toString(), - inline: true - }, - { - name: '⏰ Duration', - value: durationString, - inline: true - }, - { - name: '📍 Channel', - value: targetChannel.toString(), - inline: true - } - ] - } - }); - } catch (logError) { - logger.debug('Error logging giveaway creation event:', logError); - } - - logger.info(`Giveaway created successfully: ${giveawayMessage.id} in ${targetChannel.name}`); - - - await InteractionHelper.safeReply(interaction, { - embeds: [ - successEmbed( - `Giveaway Started! 🎉`, - `A new giveaway for **${prizeName}** has been started in ${targetChannel} and will end in **${durationString}**.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'gcreate', - context: 'giveaway_creation' - }); - } - }, -}; - - - diff --git a/src/commands/Giveaway/gdelete.js b/src/commands/Giveaway/gdelete.js deleted file mode 100644 index 8a9ac5d635..0000000000 --- a/src/commands/Giveaway/gdelete.js +++ /dev/null @@ -1,207 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; -import { errorEmbed, successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { TitanBotError, ErrorTypes, handleInteractionError } from '../../utils/errorHandler.js'; -import { getGuildGiveaways, deleteGiveaway } from '../../utils/giveaways.js'; -import { logEvent, EVENT_TYPES } from '../../services/loggingService.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("gdelete") - .setDescription( - "Deletes a giveaway message and removes it from the database.", - ) - .addStringOption((option) => - option - .setName("messageid") - .setDescription("The message ID of the giveaway to delete.") - .setRequired(true), - ) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild), - - async execute(interaction) { - try { - - if (!interaction.inGuild()) { - throw new TitanBotError( - 'Giveaway command used outside guild', - ErrorTypes.VALIDATION, - 'This command can only be used in a server.', - { userId: interaction.user.id } - ); - } - - - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { - throw new TitanBotError( - 'User lacks ManageGuild permission', - ErrorTypes.PERMISSION, - "You need the 'Manage Server' permission to delete a giveaway.", - { userId: interaction.user.id, guildId: interaction.guildId } - ); - } - - logger.info(`Giveaway deletion started by ${interaction.user.tag} in guild ${interaction.guildId}`); - - const messageId = interaction.options.getString("messageid"); - - - if (!messageId || !/^\d+$/.test(messageId)) { - throw new TitanBotError( - 'Invalid message ID format', - ErrorTypes.VALIDATION, - 'Please provide a valid message ID.', - { providedId: messageId } - ); - } - - const giveaways = await getGuildGiveaways(interaction.client, interaction.guildId); - const giveaway = giveaways.find(g => g.messageId === messageId); - - if (!giveaway) { - throw new TitanBotError( - `Giveaway not found: ${messageId}`, - ErrorTypes.VALIDATION, - "No giveaway was found with that message ID.", - { messageId, guildId: interaction.guildId } - ); - } - - let deletedMessage = false; - let channelName = "Unknown Channel"; - - const tryDeleteFromChannel = async (channel) => { - if (!channel || !channel.isTextBased() || !channel.messages?.fetch) { - return false; - } - - const message = await channel.messages.fetch(messageId).catch(() => null); - if (!message) { - return false; - } - - await message.delete(); - channelName = channel.name || 'unknown-channel'; - deletedMessage = true; - return true; - }; - - - try { - const channel = await interaction.client.channels.fetch(giveaway.channelId).catch(() => null); - if (await tryDeleteFromChannel(channel)) { - logger.debug(`Deleted giveaway message ${messageId} from channel ${channelName}`); - } - - if (!deletedMessage && interaction.guild) { - const textChannels = interaction.guild.channels.cache.filter( - ch => ch.id !== giveaway.channelId && ch.isTextBased() && ch.messages?.fetch - ); - - for (const [, guildChannel] of textChannels) { - const foundAndDeleted = await tryDeleteFromChannel(guildChannel).catch(() => false); - if (foundAndDeleted) { - logger.debug(`Deleted giveaway message ${messageId} via fallback lookup in #${channelName}`); - break; - } - } - } - } catch (error) { - logger.warn(`Could not delete giveaway message: ${error.message}`); - } - - - const removedFromDatabase = await deleteGiveaway( - interaction.client, - interaction.guildId, - messageId, - ); - - if (!removedFromDatabase) { - throw new TitanBotError( - `Failed to delete giveaway from database: ${messageId}`, - ErrorTypes.UNKNOWN, - 'The giveaway could not be removed from the database. Please try again.', - { messageId, guildId: interaction.guildId } - ); - } - - const giveawaysAfterDelete = await getGuildGiveaways(interaction.client, interaction.guildId); - const stillExistsInDatabase = giveawaysAfterDelete.some(g => g.messageId === messageId); - - if (stillExistsInDatabase) { - throw new TitanBotError( - `Giveaway still exists after deletion: ${messageId}`, - ErrorTypes.UNKNOWN, - 'Deletion did not persist in the database. Please try again.', - { messageId, guildId: interaction.guildId } - ); - } - - const statusMsg = deletedMessage - ? `and the message was deleted from #${channelName}` - : `but the message was already deleted or the channel was inaccessible.`; - - const winnerIds = Array.isArray(giveaway.winnerIds) ? giveaway.winnerIds : []; - const hasWinners = winnerIds.length > 0; - const wasEnded = giveaway.ended === true || giveaway.isEnded === true || hasWinners; - - const winnerStatusMsg = hasWinners - ? `This giveaway already had ${winnerIds.length} winner(s) selected.` - : wasEnded - ? 'This giveaway was ended with no valid winners.' - : 'No winner was picked before deletion.'; - - logger.info(`Giveaway deleted: ${messageId} in ${channelName}`); - - - try { - await logEvent({ - client: interaction.client, - guildId: interaction.guildId, - eventType: EVENT_TYPES.GIVEAWAY_DELETE, - data: { - description: `Giveaway deleted: ${giveaway.prize}`, - channelId: giveaway.channelId, - userId: interaction.user.id, - fields: [ - { - name: '🎁 Prize', - value: giveaway.prize || 'Unknown', - inline: true - }, - { - name: '📊 Entries', - value: (giveaway.participants?.length || 0).toString(), - inline: true - } - ] - } - }); - } catch (logError) { - logger.debug('Error logging giveaway deletion:', logError); - } - - return InteractionHelper.safeReply(interaction, { - embeds: [ - successEmbed( - "Giveaway Deleted", - `Successfully deleted the giveaway for **${giveaway.prize}** ${statusMsg}. ${winnerStatusMsg}`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - } catch (error) { - logger.error('Error in gdelete command:', error); - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'gdelete', - context: 'giveaway_deletion' - }); - } - }, -}; - - diff --git a/src/commands/Giveaway/gend.js b/src/commands/Giveaway/gend.js deleted file mode 100644 index 0427b59a52..0000000000 --- a/src/commands/Giveaway/gend.js +++ /dev/null @@ -1,212 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; -import { errorEmbed, successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { TitanBotError, ErrorTypes, handleInteractionError } from '../../utils/errorHandler.js'; -import { 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'; - -export default { - data: new SlashCommandBuilder() - .setName("gend") - .setDescription( - "Ends an active giveaway immediately and picks the winner(s).", - ) - .addStringOption((option) => - option - .setName("messageid") - .setDescription("The message ID of the giveaway to end.") - .setRequired(true), - ) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild), - - async execute(interaction) { - try { - - if (!interaction.inGuild()) { - throw new TitanBotError( - 'Giveaway command used outside guild', - ErrorTypes.VALIDATION, - 'This command can only be used in a server.', - { userId: interaction.user.id } - ); - } - - - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { - throw new TitanBotError( - 'User lacks ManageGuild permission', - ErrorTypes.PERMISSION, - "You need the 'Manage Server' permission to end a giveaway.", - { userId: interaction.user.id, guildId: interaction.guildId } - ); - } - - logger.info(`Giveaway end initiated by ${interaction.user.tag} in guild ${interaction.guildId}`); - - const messageId = interaction.options.getString("messageid"); - - - if (!messageId || !/^\d+$/.test(messageId)) { - throw new TitanBotError( - 'Invalid message ID format', - ErrorTypes.VALIDATION, - 'Please provide a valid message ID.', - { providedId: messageId } - ); - } - - const giveaways = await getGuildGiveaways(interaction.client, interaction.guildId); - const giveaway = giveaways.find(g => g.messageId === messageId); - - if (!giveaway) { - throw new TitanBotError( - `Giveaway not found: ${messageId}`, - ErrorTypes.VALIDATION, - "No giveaway was found with that message ID in the database.", - { messageId, guildId: interaction.guildId } - ); - } - - - const endResult = await endGiveawayService( - interaction.client, - giveaway, - interaction.guildId, - interaction.user.id - ); - - const updatedGiveaway = endResult.giveaway; - const winners = endResult.winners; - - - const channel = await interaction.client.channels.fetch( - updatedGiveaway.channelId, - ).catch(err => { - logger.warn(`Could not fetch channel ${updatedGiveaway.channelId}:`, err.message); - return null; - }); - - if (!channel || !channel.isTextBased()) { - throw new TitanBotError( - `Channel not found: ${updatedGiveaway.channelId}`, - ErrorTypes.VALIDATION, - "Could not find the channel where the giveaway was hosted. The giveaway state has been updated.", - { channelId: updatedGiveaway.channelId, messageId } - ); - } - - const message = await channel.messages - .fetch(messageId) - .catch(err => { - logger.warn(`Could not fetch message ${messageId}:`, err.message); - return null; - }); - - if (!message) { - throw new TitanBotError( - `Message not found: ${messageId}`, - ErrorTypes.VALIDATION, - "Could not find the giveaway message. The giveaway state has been updated.", - { messageId, channelId: updatedGiveaway.channelId } - ); - } - - - await saveGiveaway( - interaction.client, - interaction.guildId, - updatedGiveaway, - ); - - - const newEmbed = createGiveawayEmbed(updatedGiveaway, "ended", winners); - const newRow = createGiveawayButtons(true); - - await message.edit({ - content: "🎉 **GIVEAWAY ENDED** 🎉", - embeds: [newEmbed], - components: [newRow], - }); - - - if (winners.length > 0) { - const winnerMentions = winners - .map((id) => `<@${id}>`) - .join(", "); - const winnerPingMsg = await channel.send({ - content: `🎉 CONGRATULATIONS ${winnerMentions}! You won the **${updatedGiveaway.prize}** giveaway! Please contact the host <@${updatedGiveaway.hostId}> to claim your prize.`, - }); - updatedGiveaway.winnerPingMessageId = winnerPingMsg.id; - await saveGiveaway(interaction.client, interaction.guildId, updatedGiveaway); - - logger.info(`Giveaway ended with ${winners.length} winner(s): ${messageId}`); - - - try { - await logEvent({ - client: interaction.client, - guildId: interaction.guildId, - eventType: EVENT_TYPES.GIVEAWAY_WINNER, - data: { - description: `Giveaway ended with ${winners.length} winner(s)`, - channelId: channel.id, - userId: interaction.user.id, - fields: [ - { - name: '🎁 Prize', - value: updatedGiveaway.prize || 'Mystery Prize!', - inline: true - }, - { - name: '🏆 Winners', - value: winnerMentions, - inline: false - }, - { - name: '👥 Entries', - value: endResult.participantCount.toString(), - inline: true - } - ] - } - }); - } catch (logError) { - logger.debug('Error logging giveaway winner event:', logError); - } - } else { - await channel.send({ - content: `The giveaway for **${updatedGiveaway.prize}** has ended with no valid entries.`, - }); - logger.info(`Giveaway ended with no winners: ${messageId}`); - } - - logger.info(`Giveaway successfully ended by ${interaction.user.tag}: ${messageId}`); - - return InteractionHelper.safeReply(interaction, { - embeds: [ - successEmbed( - "Giveaway Ended ✅", - `Successfully ended the giveaway for **${updatedGiveaway.prize}** in ${channel}. Selected ${winners.length} winner(s) from ${endResult.participantCount} entries.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'gend', - context: 'giveaway_end' - }); - } - }, -}; - - - diff --git a/src/commands/Giveaway/greroll.js b/src/commands/Giveaway/greroll.js deleted file mode 100644 index 4207c125c2..0000000000 --- a/src/commands/Giveaway/greroll.js +++ /dev/null @@ -1,314 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; -import { errorEmbed, successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { TitanBotError, ErrorTypes, handleInteractionError } from '../../utils/errorHandler.js'; -import { getGuildGiveaways, saveGiveaway } 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'; - -export default { - data: new SlashCommandBuilder() - .setName("greroll") - .setDescription("Rerolls the winner(s) for an ended giveaway.") - .addStringOption((option) => - option - .setName("messageid") - .setDescription("The message ID of the ended giveaway.") - .setRequired(true), - ) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild), - - async execute(interaction) { - try { - - if (!interaction.inGuild()) { - throw new TitanBotError( - 'Giveaway command used outside guild', - ErrorTypes.VALIDATION, - 'This command can only be used in a server.', - { userId: interaction.user.id } - ); - } - - - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { - throw new TitanBotError( - 'User lacks ManageGuild permission', - ErrorTypes.PERMISSION, - "You need the 'Manage Server' permission to reroll a giveaway.", - { userId: interaction.user.id, guildId: interaction.guildId } - ); - } - - logger.info(`Giveaway reroll initiated by ${interaction.user.tag} in guild ${interaction.guildId}`); - - const messageId = interaction.options.getString("messageid"); - - - if (!messageId || !/^\d+$/.test(messageId)) { - throw new TitanBotError( - 'Invalid message ID format', - ErrorTypes.VALIDATION, - 'Please provide a valid message ID.', - { providedId: messageId } - ); - } - - const giveaways = await getGuildGiveaways( - interaction.client, - interaction.guildId, - ); - - - const giveaway = giveaways.find(g => g.messageId === messageId); - - if (!giveaway) { - throw new TitanBotError( - `Giveaway not found: ${messageId}`, - ErrorTypes.VALIDATION, - "No giveaway was found with that message ID in the database.", - { messageId, guildId: interaction.guildId } - ); - } - - - if (!giveaway.isEnded && !giveaway.ended) { - throw new TitanBotError( - `Giveaway still active: ${messageId}`, - ErrorTypes.VALIDATION, - "This giveaway is still active. Please use `/gend` to end it first.", - { messageId, status: 'active' } - ); - } - - const participants = giveaway.participants || []; - - if (participants.length < giveaway.winnerCount) { - throw new TitanBotError( - `Insufficient participants for reroll: ${participants.length} < ${giveaway.winnerCount}`, - ErrorTypes.VALIDATION, - "Not enough entries to pick the required number of winners.", - { participantsCount: participants.length, winnersNeeded: giveaway.winnerCount } - ); - } - - - const newWinners = selectWinners( - participants, - giveaway.winnerCount, - ); - - - const updatedGiveaway = { - ...giveaway, - winnerIds: newWinners, - rerolledAt: new Date().toISOString(), - rerolledBy: interaction.user.id - }; - - - const channel = await interaction.client.channels.fetch( - giveaway.channelId, - ).catch(err => { - logger.warn(`Could not fetch channel ${giveaway.channelId}:`, err.message); - return null; - }); - - if (!channel || !channel.isTextBased()) { - - await saveGiveaway( - interaction.client, - interaction.guildId, - updatedGiveaway, - ); - - logger.warn(`Could not find channel for giveaway ${messageId}, but saved new winners to database`); - - return InteractionHelper.safeReply(interaction, { - embeds: [ - successEmbed( - "Reroll Complete", - "The new winners have been selected and saved to the database. Could not find channel to announce.", - ), - ], - flags: MessageFlags.Ephemeral, - }); - } - - - const message = await channel.messages - .fetch(messageId) - .catch(err => { - logger.warn(`Could not fetch message ${messageId}:`, err.message); - return null; - }); - - if (!message) { - - await saveGiveaway( - interaction.client, - interaction.guildId, - updatedGiveaway, - ); - - const winnerMentions = newWinners - .map((id) => `<@${id}>`) - .join(", "); - - // Edit the original winner ping if it still exists, otherwise send a new one - const existingPingMsg = giveaway.winnerPingMessageId - ? await channel.messages.fetch(giveaway.winnerPingMessageId).catch(() => null) - : null; - if (existingPingMsg) { - await existingPingMsg.edit({ - content: `🔄 **GIVEAWAY REROLL** 🔄 New winners for **${giveaway.prize}**: ${winnerMentions}!`, - }); - } else { - const newPingMsg = await channel.send({ - content: `🔄 **GIVEAWAY REROLL** 🔄 New winners for **${giveaway.prize}**: ${winnerMentions}!`, - }); - updatedGiveaway.winnerPingMessageId = newPingMsg.id; - } - - logger.info(`Giveaway rerolled (message not found, but announced): ${messageId}`); - - try { - await logEvent({ - client: interaction.client, - guildId: interaction.guildId, - eventType: EVENT_TYPES.GIVEAWAY_REROLL, - data: { - description: `Giveaway rerolled: ${giveaway.prize}`, - channelId: giveaway.channelId, - userId: interaction.user.id, - fields: [ - { - name: '🎁 Prize', - value: giveaway.prize || 'Mystery Prize!', - inline: true - }, - { - name: '🏆 New Winners', - value: winnerMentions, - inline: false - }, - { - name: '👥 Total Entries', - value: participants.length.toString(), - inline: true - } - ] - } - }); - } catch (logError) { - logger.debug('Error logging giveaway reroll:', logError); - } - - return InteractionHelper.safeReply(interaction, { - embeds: [ - successEmbed( - "Reroll Complete", - `The new winners have been announced in ${channel}. (Original message not found).`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - } - - - await saveGiveaway( - interaction.client, - interaction.guildId, - updatedGiveaway, - ); - - const newEmbed = createGiveawayEmbed(updatedGiveaway, "reroll", newWinners); - const newRow = createGiveawayButtons(true); - - await message.edit({ - content: "🔄 **GIVEAWAY REROLLED** 🔄", - embeds: [newEmbed], - components: [newRow], - }); - - const winnerMentions = newWinners - .map((id) => `<@${id}>`) - .join(", "); - - // Edit the original winner ping if it still exists, otherwise send a new one - const existingPingMsg = giveaway.winnerPingMessageId - ? await channel.messages.fetch(giveaway.winnerPingMessageId).catch(() => null) - : null; - if (existingPingMsg) { - await existingPingMsg.edit({ - content: `🔄 **REROLL WINNERS** 🔄 CONGRATULATIONS ${winnerMentions}! You are the new winner(s) for the **${giveaway.prize}** giveaway! Please contact the host <@${giveaway.hostId}> to claim your prize.`, - }); - } else { - const newPingMsg = await channel.send({ - content: `🔄 **REROLL WINNERS** 🔄 CONGRATULATIONS ${winnerMentions}! You are the new winner(s) for the **${giveaway.prize}** giveaway! Please contact the host <@${giveaway.hostId}> to claim your prize.`, - }); - updatedGiveaway.winnerPingMessageId = newPingMsg.id; - } - - logger.info(`Giveaway successfully rerolled: ${messageId} with ${newWinners.length} new winners`); - - try { - await logEvent({ - client: interaction.client, - guildId: interaction.guildId, - eventType: EVENT_TYPES.GIVEAWAY_REROLL, - data: { - description: `Giveaway rerolled: ${giveaway.prize}`, - channelId: giveaway.channelId, - userId: interaction.user.id, - fields: [ - { - name: '🎁 Prize', - value: giveaway.prize || 'Mystery Prize!', - inline: true - }, - { - name: '🏆 New Winners', - value: winnerMentions, - inline: false - }, - { - name: '👥 Total Entries', - value: participants.length.toString(), - inline: true - } - ] - } - }); - } catch (logError) { - logger.debug('Error logging giveaway reroll event:', logError); - } - - return InteractionHelper.safeReply(interaction, { - embeds: [ - successEmbed( - "Reroll Successful ✅", - `Successfully rerolled the giveaway for **${giveaway.prize}** in ${channel}. Selected ${newWinners.length} new winner(s).`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - } catch (error) { - logger.error('Error in greroll command:', error); - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'greroll', - context: 'giveaway_reroll' - }); - } - }, -}; - - - diff --git a/src/commands/JoinToCreate/jointocreate.js b/src/commands/JoinToCreate/jointocreate.js deleted file mode 100644 index b9fba2868d..0000000000 --- a/src/commands/JoinToCreate/jointocreate.js +++ /dev/null @@ -1,709 +0,0 @@ -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 { errorEmbed, successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { - initializeJoinToCreate, - getChannelConfiguration, - updateChannelConfig, - removeTriggerChannel, - hasManageGuildPermission, - logConfigurationChange, - getConfiguration -} from '../../services/joinToCreateService.js'; - - -export default { - data: new SlashCommandBuilder() - .setName("jointocreate") - .setDescription("Manage Join to Create voice channels system.") - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .setDMPermission(false) - .addSubcommand((subcommand) => - subcommand - .setName("setup") - .setDescription("Set up a new Join to Create voice channel.") - .addChannelOption((option) => - option - .setName("category") - .setDescription("Category to create the channel in.") - .addChannelTypes(ChannelType.GuildCategory) - ) - .addStringOption((option) => - option - .setName("channel_name") - .setDescription("Select a template for naming temporary voice channels.") - .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" } - ) - ) - .addIntegerOption((option) => - option - .setName("user_limit") - .setDescription("Maximum number of users in temporary channels. (0 = unlimited)") - ) - .addIntegerOption((option) => - option - .setName("bitrate") - .setDescription("Bitrate for temporary channels in kbps (8-96).") - ) - ) - .addSubcommand((subcommand) => - subcommand - .setName("dashboard") - .setDescription("Configure an existing Join to Create system.") - .addChannelOption((option) => - option - .setName("trigger_channel") - .setDescription("The Join to Create trigger channel to configure.") - .setRequired(true) - .addChannelTypes(ChannelType.GuildVoice) - ) - ), - category: "utility", - - async execute(interaction, config, client) { - try { - - if (!hasManageGuildPermission(interaction.member)) { - throw new TitanBotError( - 'User lacks ManageGuild permission', - ErrorTypes.PERMISSION, - 'You need **Manage Server** permission to use this command.' - ); - } - - const subcommand = interaction.options.getSubcommand(); - await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - - let responseEmbed; - - if (subcommand === "setup") { - await handleSetupSubcommand(interaction, client); - return; - } else if (subcommand === "dashboard") { - await handleConfigSubcommand(interaction, client); - return; - } - - } catch (error) { - try { - let errorMessage = 'An error occurred while executing this command.'; - - if (error instanceof TitanBotError) { - errorMessage = error.userMessage || 'An error occurred. Please try again.'; - logger.debug(`TitanBotError [${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.'; - } - - const errorEmbedObj = errorEmbed("⚠️ Error", errorMessage); - - if (interaction.deferred) { - return await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbedObj] }); - } else { - return await InteractionHelper.safeReply(interaction, { embeds: [errorEmbedObj], flags: MessageFlags.Ephemeral }); - } - } catch (replyError) { - logger.error('Failed to send error message:', replyError); - } - } - } -}; - -async function handleSetupSubcommand(interaction, client) { - try { - const category = interaction.options.getChannel('category'); - 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; - - logger.debug(`Setting up Join to Create in guild ${guildId} with template: ${nameTemplate}`); - - // Check if guild already has a Join to Create channel configured - const existingConfig = await getConfiguration(client, guildId); - - if (Array.isArray(existingConfig.triggerChannels) && existingConfig.triggerChannels.length > 0) { - const activeTriggerChannels = []; - const staleTriggerChannelIds = []; - - for (const existingChannelId of existingConfig.triggerChannels) { - const existingChannel = await interaction.guild.channels.fetch(existingChannelId).catch(() => null); - if (existingChannel) { - activeTriggerChannels.push(existingChannel); - } else { - staleTriggerChannelIds.push(existingChannelId); - } - } - - if (staleTriggerChannelIds.length > 0) { - for (const staleChannelId of staleTriggerChannelIds) { - logger.info(`Cleaning up stale JTC trigger ${staleChannelId} from guild ${guildId}`); - await removeTriggerChannel(client, guildId, staleChannelId); - } - } - - 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.`; - - throw new TitanBotError( - 'Guild already has a Join to Create channel', - ErrorTypes.VALIDATION, - errorMessage, - { - guildId, - activeTriggerCount: activeTriggerChannels.length, - expected: true, - suppressErrorLog: true - } - ); - } - } - - // Create the trigger channel - logger.debug('Creating Join to Create trigger channel...'); - let triggerChannel = await interaction.guild.channels.create({ - name: 'Join to Create', - type: ChannelType.GuildVoice, - parent: category?.id, - userLimit: 0, - bitrate: 64000, - permissionOverwrites: [ - { - id: interaction.guild.id, - allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect], - }, - ], - }); - - logger.debug(`Created trigger channel ${triggerChannel.id}, initializing config...`); - - // Initialize the Join to Create configuration - const config = await initializeJoinToCreate(client, guildId, triggerChannel.id, { - nameTemplate: nameTemplate, - userLimit: userLimit, - bitrate: bitrate * 1000, - categoryId: category?.id - }); - - await logConfigurationChange(client, guildId, interaction.user.id, 'Initialized Join to Create', { - channelId: triggerChannel.id, - nameTemplate, - userLimit, - bitrate - }); - - 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` + - `• Bitrate: ${bitrate} kbps\n` + - `${category ? `• Category: ${category.name}` : '• Category: Root level'}` - ); - - return await InteractionHelper.safeEditReply(interaction, { embeds: [responseEmbed] }); - - } catch (error) { - logger.error('Error in handleSetupSubcommand:', error); - if (error instanceof TitanBotError) { - throw error; - } - throw new TitanBotError( - `Setup failed: ${error.message}`, - ErrorTypes.DISCORD_API, - 'Failed to set up Join to Create system. Please check bot permissions.' - ); - } -} - -async function handleConfigSubcommand(interaction, client) { - try { - const triggerChannel = interaction.options.getChannel('trigger_channel'); - const guildId = interaction.guild.id; - - // Validate that the channel is actually a Join to Create trigger - const currentConfig = await getChannelConfiguration(client, guildId, triggerChannel.id); - const channelConfig = currentConfig.channelConfig || {}; - - - const configEmbed = new EmbedBuilder() - .setTitle('⚙️ Join to Create Configuration') - .setDescription(`Configuration for ${triggerChannel}`) - .setColor(getColor('info')) - .addFields( - { - name: '📝 Channel Name Template', - value: `\`${channelConfig.nameTemplate || currentConfig.channelNameTemplate || "{username}'s Room"}\``, - inline: false - }, - { - name: '👥 User Limit', - value: `${(channelConfig.userLimit ?? currentConfig.userLimit ?? 0) === 0 ? 'Unlimited' : (channelConfig.userLimit ?? currentConfig.userLimit ?? 0) + ' users'}`, - inline: true - }, - { - name: '🎵 Bitrate', - value: `${(channelConfig.bitrate ?? currentConfig.bitrate ?? 64000) / 1000} kbps`, - inline: true - } - ) - .setFooter({ text: 'Use the buttons below to modify settings • Only one trigger channel is supported per guild' }) - .setTimestamp(); - - - const nameButton = new ButtonBuilder() - .setCustomId(`jtc_config_name_${triggerChannel.id}`) - .setLabel('📝 Name Template') - .setStyle(ButtonStyle.Primary); - - const limitButton = new ButtonBuilder() - .setCustomId(`jtc_config_limit_${triggerChannel.id}`) - .setLabel('👥 User Limit') - .setStyle(ButtonStyle.Primary); - - const bitrateButton = new ButtonBuilder() - .setCustomId(`jtc_config_bitrate_${triggerChannel.id}`) - .setLabel('🎵 Bitrate') - .setStyle(ButtonStyle.Primary); - - const deleteButton = new ButtonBuilder() - .setCustomId(`jtc_config_delete_${triggerChannel.id}`) - .setLabel('🗑️ Remove Channel') - .setStyle(ButtonStyle.Danger); - - const row = new ActionRowBuilder().addComponents(nameButton, limitButton, bitrateButton, deleteButton); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [configEmbed], - components: [row] - }); - - const message = await interaction.fetchReply(); - - if (!message || typeof message.createMessageComponentCollector !== 'function') { - throw new TitanBotError( - 'Failed to fetch interaction reply for collector setup', - ErrorTypes.DISCORD_API, - 'Failed to open configuration controls. Please run `/jointocreate dashboard` again.' - ); - } - - - const collector = message.createMessageComponentCollector({ - componentType: ComponentType.Button, - time: 300000 - }); - - collector.on('collect', async (buttonInteraction) => { - try { - - if (!hasManageGuildPermission(buttonInteraction.member)) { - await buttonInteraction.reply({ - content: '❌ You need **Manage Server** permission to use these controls.', - flags: MessageFlags.Ephemeral - }); - return; - } - - const customId = buttonInteraction.customId; - - if (customId.includes('jtc_config_name_')) { - await handleNameTemplateModal(buttonInteraction, triggerChannel, currentConfig, client); - } else if (customId.includes('jtc_config_limit_')) { - await handleUserLimitModal(buttonInteraction, triggerChannel, currentConfig, client); - } else if (customId.includes('jtc_config_bitrate_')) { - await handleBitrateModal(buttonInteraction, triggerChannel, currentConfig, client); - } else if (customId.includes('jtc_config_delete_')) { - await handleChannelDeletion(buttonInteraction, triggerChannel, currentConfig, client); - } - } catch (error) { - const userMessage = error instanceof TitanBotError - ? error.userMessage || 'An error occurred.' - : 'An error occurred while processing your request.'; - - if (error instanceof TitanBotError) { - logger.debug(`Button interaction validation error: ${error.message}`, error.context || {}); - } else { - logger.error('Unexpected error in config button interaction:', error); - } - - await buttonInteraction.reply({ - content: `❌ ${userMessage}`, - flags: MessageFlags.Ephemeral - }).catch(() => {}); - } - }); - - collector.on('end', () => { - const disabledRow = new ActionRowBuilder().addComponents( - nameButton.setDisabled(true), - limitButton.setDisabled(true), - bitrateButton.setDisabled(true), - deleteButton.setDisabled(true) - ); - - message.edit({ - components: [disabledRow], - embeds: [configEmbed.setFooter({ text: 'Configuration session expired. Run the command again to make changes.' })] - }).catch(() => {}); - }); - - } catch (error) { - if (error instanceof TitanBotError) { - throw error; - } - throw new TitanBotError( - `Config failed: ${error.message}`, - ErrorTypes.DATABASE, - 'Failed to load configuration.' - ); - } -} - -async function handleNameTemplateModal(interaction, triggerChannel, currentConfig, client) { - 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" }, - ]; - - const currentTemplate = currentConfig.channelConfig?.nameTemplate - || currentConfig.channelNameTemplate - || "{username}'s Room"; - - const templateSelect = new StringSelectMenuBuilder() - .setCustomId('template') - .setPlaceholder('Pick a name template...') - .setOptions( - TEMPLATE_OPTIONS.map(o => ({ - label: o.label, - value: o.value, - default: o.value === currentTemplate, - })), - ); - - const templateLabel = new LabelBuilder() - .setLabel('Channel name template') - .setStringSelectMenuComponent(templateSelect); - - const modal = new ModalBuilder() - .setCustomId(`jtc_name_modal_${triggerChannel.id}`) - .setTitle('Channel Name Template') - .addLabelComponents(templateLabel); - - await interaction.showModal(modal); - - const modalSubmission = await interaction.awaitModalSubmit({ - filter: (i) => i.customId === `jtc_name_modal_${triggerChannel.id}` && i.user.id === interaction.user.id, - time: 60000 - }); - - // Recheck permissions - if (!hasManageGuildPermission(modalSubmission.member)) { - await modalSubmission.reply({ - content: '❌ You need **Manage Server** permission to modify these settings.', - flags: MessageFlags.Ephemeral - }); - return; - } - - const [newTemplate] = modalSubmission.fields.getStringSelectValues('template'); - - await updateChannelConfig(client, interaction.guild.id, triggerChannel.id, { - nameTemplate: newTemplate - }); - - await logConfigurationChange(client, interaction.guild.id, interaction.user.id, 'Updated channel name template', { - channelId: triggerChannel.id, - newTemplate - }); - - await modalSubmission.reply({ - embeds: [successEmbed('✅ Updated', `Channel name template changed to \`${newTemplate}\``)], - flags: MessageFlags.Ephemeral - }); - - } catch (error) { - if (error.code === 'INTERACTION_COLLECTOR_ERROR') { - return; - } - if (error instanceof TitanBotError) { - throw error; - } - logger.error('Unexpected error in name template modal:', error); - throw new TitanBotError( - `Modal error: ${error.message}`, - ErrorTypes.UNKNOWN, - 'An error occurred while updating the template.' - ); - } -} - -async function handleUserLimitModal(interaction, triggerChannel, currentConfig, client) { - try { - const currentLimit = currentConfig.channelConfig.userLimit ?? currentConfig.userLimit ?? 0; - - const modal = new ModalBuilder() - .setCustomId(`jtc_limit_modal_${triggerChannel.id}`) - .setTitle('Configure User Limit') - .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') - .setStyle(TextInputStyle.Short) - .setRequired(true) - .setMinLength(1) - .setMaxLength(2) - .setValue(currentLimit.toString()) - ) - ); - - await interaction.showModal(modal); - - const modalSubmission = await interaction.awaitModalSubmit({ - filter: (i) => i.customId === `jtc_limit_modal_${triggerChannel.id}` && i.user.id === interaction.user.id, - time: 60000 - }); - - // Recheck permissions - if (!hasManageGuildPermission(modalSubmission.member)) { - await modalSubmission.reply({ - content: '❌ You need **Manage Server** permission to modify these settings.', - flags: MessageFlags.Ephemeral - }); - return; - } - - const userInput = modalSubmission.fields.getTextInputValue('user_limit').trim(); - - await updateChannelConfig(client, interaction.guild.id, triggerChannel.id, { - userLimit: parseInt(userInput) - }); - - await logConfigurationChange(client, interaction.guild.id, interaction.user.id, 'Updated user limit', { - channelId: triggerChannel.id, - userLimit: parseInt(userInput) - }); - - await modalSubmission.reply({ - embeds: [successEmbed('✅ Updated', `User limit changed to ${parseInt(userInput) === 0 ? 'Unlimited' : parseInt(userInput) + ' users'}`)], - flags: MessageFlags.Ephemeral - }); - - } catch (error) { - if (error.code === 'INTERACTION_COLLECTOR_ERROR') { - return; - } - if (error instanceof TitanBotError) { - throw error; - } - logger.error('Unexpected error in user limit modal:', error); - throw new TitanBotError( - `Modal error: ${error.message}`, - ErrorTypes.UNKNOWN, - 'An error occurred while updating the user limit.' - ); - } -} - -async function handleBitrateModal(interaction, triggerChannel, currentConfig, client) { - try { - const currentBitrate = ((currentConfig.channelConfig.bitrate ?? currentConfig.bitrate ?? 64000) / 1000); - - const modal = new ModalBuilder() - .setCustomId(`jtc_bitrate_modal_${triggerChannel.id}`) - .setTitle('Configure Bitrate') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('bitrate') - .setLabel('Enter bitrate in kbps (8-384)') - .setPlaceholder('Enter a number between 8 and 384') - .setStyle(TextInputStyle.Short) - .setRequired(true) - .setMinLength(1) - .setMaxLength(3) - .setValue(currentBitrate.toString()) - ) - ); - - await interaction.showModal(modal); - - const modalSubmission = await interaction.awaitModalSubmit({ - filter: (i) => i.customId === `jtc_bitrate_modal_${triggerChannel.id}` && i.user.id === interaction.user.id, - time: 60000 - }); - - // Recheck permissions - if (!hasManageGuildPermission(modalSubmission.member)) { - await modalSubmission.reply({ - content: '❌ You need **Manage Server** permission to modify these settings.', - flags: MessageFlags.Ephemeral - }); - return; - } - - const userInput = modalSubmission.fields.getTextInputValue('bitrate').trim(); - - await updateChannelConfig(client, interaction.guild.id, triggerChannel.id, { - bitrate: parseInt(userInput) * 1000 - }); - - await logConfigurationChange(client, interaction.guild.id, interaction.user.id, 'Updated bitrate', { - channelId: triggerChannel.id, - bitrate: parseInt(userInput) - }); - - await modalSubmission.reply({ - embeds: [successEmbed('✅ Updated', `Bitrate changed to ${parseInt(userInput)} kbps`)], - flags: MessageFlags.Ephemeral - }); - - } catch (error) { - if (error.code === 'INTERACTION_COLLECTOR_ERROR') { - return; - } - if (error instanceof TitanBotError) { - throw error; - } - logger.error('Unexpected error in bitrate modal:', error); - throw new TitanBotError( - `Modal error: ${error.message}`, - ErrorTypes.UNKNOWN, - 'An error occurred while updating the bitrate.' - ); - } -} - - -async function handleChannelDeletion(interaction, triggerChannel, currentConfig, client) { - try { - const confirmRow = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`jtc_delete_confirm_${triggerChannel.id}`) - .setLabel('🗑️ Yes, Delete') - .setStyle(ButtonStyle.Danger), - new ButtonBuilder() - .setCustomId(`jtc_delete_cancel_${triggerChannel.id}`) - .setLabel('❌ Cancel') - .setStyle(ButtonStyle.Secondary) - ); - - await InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('⚠️ Confirm Deletion', `Are you sure you want to remove **${triggerChannel.name}** from the Join to Create system?\n\nThis action cannot be undone.`)], - components: [confirmRow], - flags: MessageFlags.Ephemeral - }); - - const message = await interaction.fetchReply(); - const deleteCollector = message.createMessageComponentCollector({ - componentType: ComponentType.Button, - filter: (i) => i.user.id === interaction.user.id && - (i.customId === `jtc_delete_confirm_${triggerChannel.id}` || - i.customId === `jtc_delete_cancel_${triggerChannel.id}`), - time: 600_000, - max: 1 - }); - - deleteCollector.on('collect', async (buttonInteraction) => { - try { - // Recheck permissions - if (!hasManageGuildPermission(buttonInteraction.member)) { - await buttonInteraction.reply({ - content: '❌ You need **Manage Server** permission to remove channels.', - flags: MessageFlags.Ephemeral - }); - return; - } - - if (buttonInteraction.customId === `jtc_delete_confirm_${triggerChannel.id}`) { - - await removeTriggerChannel(client, interaction.guild.id, triggerChannel.id); - - - await logConfigurationChange(client, interaction.guild.id, interaction.user.id, 'Removed Join to Create trigger', { - channelId: triggerChannel.id, - channelName: triggerChannel.name - }); - - - try { - if (triggerChannel.members.size === 0) { - await triggerChannel.delete('Join to Create trigger removed by administrator'); - } - } catch (deleteError) { - logger.warn(`Could not delete channel ${triggerChannel.id}: ${deleteError.message}`); - - } - - await buttonInteraction.update({ - embeds: [successEmbed('✅ Removed', `**${triggerChannel.name}** has been removed from the Join to Create system.`)], - components: [] - }); - - } else { - await buttonInteraction.update({ - embeds: [successEmbed('✅ Cancelled', 'Channel removal has been cancelled.')], - components: [] - }); - } - } catch (collectError) { - logger.error('Error handling delete confirmation:', collectError); - await buttonInteraction.reply({ - content: '❌ An error occurred while processing your request.', - flags: MessageFlags.Ephemeral - }).catch(() => {}); - } - }); - - deleteCollector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - message.edit({ components: [] }).catch(() => {}); - } - }); - - } catch (error) { - if (error instanceof TitanBotError) { - throw error; - } - logger.error('Unexpected error in handleChannelDeletion:', error); - throw new TitanBotError( - `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 deleted file mode 100644 index 53c5b534bd..0000000000 --- a/src/commands/JoinToCreate/modules/config_setup.js +++ /dev/null @@ -1,553 +0,0 @@ -import { getColor } from '../../../config/bot.js'; -import { - ActionRowBuilder, - StringSelectMenuBuilder, - StringSelectMenuOptionBuilder, - ChannelType, - MessageFlags, - ComponentType, - EmbedBuilder, - ButtonBuilder, - ButtonStyle -} from 'discord.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { successEmbed, errorEmbed } from '../../../utils/embeds.js'; -import { logger } from '../../../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../../../utils/errorHandler.js'; -import { - getJoinToCreateConfig, - updateJoinToCreateConfig, - removeJoinToCreateTrigger, - addJoinToCreateTrigger -} from '../../../utils/database.js'; - -export default { - async execute(interaction, config, client) { - try { - const triggerChannel = interaction.options.getChannel('trigger_channel'); - const guildId = interaction.guild.id; - - const currentConfig = await getJoinToCreateConfig(client, guildId); - - if (!currentConfig.triggerChannels.includes(triggerChannel.id)) { - throw new TitanBotError( - `Channel ${triggerChannel.id} is not a Join to Create trigger`, - ErrorTypes.VALIDATION, - `${triggerChannel} is not configured as a Join to Create trigger channel.` - ); - } - - const embed = new EmbedBuilder() - .setTitle('⚙️ Join to Create Configuration') - .setDescription(`Configure settings for ${triggerChannel}`) - .setColor(getColor('info')) - .addFields( - { - name: '📝 Current Channel Name Template', - 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'}`, - inline: true - }, - { - name: '🎵 Current Bitrate', - value: `${(currentConfig.channelOptions?.[triggerChannel.id]?.bitrate || currentConfig.bitrate) / 1000} kbps`, - inline: true - } - ) - .setFooter({ text: 'Select an option to configure below' }) - .setTimestamp(); - - const selectMenu = new StringSelectMenuBuilder() - .setCustomId(`jointocreate_config_${triggerChannel.id}`) - .setPlaceholder('Select a configuration option') - .addOptions( - new StringSelectMenuOptionBuilder() - .setLabel('Change Channel Name Template') - .setDescription('Modify the template for temporary channel names') - .setValue('name_template'), - new StringSelectMenuOptionBuilder() - .setLabel('Change User Limit') - .setDescription('Set maximum users per temporary channel') - .setValue('user_limit'), - new StringSelectMenuOptionBuilder() - .setLabel('Change Bitrate') - .setDescription('Adjust audio quality for temporary channels') - .setValue('bitrate'), - new StringSelectMenuOptionBuilder() - .setLabel('Remove This Trigger Channel') - .setDescription('Remove this channel from the Join to Create system') - .setValue('remove_trigger'), - new StringSelectMenuOptionBuilder() - .setLabel('View Current Settings') - .setDescription('Show all current configuration details') - .setValue('view_settings') - ); - - const row = new ActionRowBuilder().addComponents(selectMenu); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - components: [row], - }).catch(error => { - logger.error('Failed to edit reply in config_setup:', error); - }); - - const collector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.StringSelect, - filter: (i) => i.user.id === interaction.user.id && i.customId === `jointocreate_config_${triggerChannel.id}`, -time: 60000 - }); - - collector.on('collect', async (selectInteraction) => { - await selectInteraction.deferUpdate(); - - const selectedOption = selectInteraction.values[0]; - - try { - switch (selectedOption) { - case 'name_template': - await handleNameTemplateChange(selectInteraction, triggerChannel, currentConfig, client); - break; - case 'user_limit': - await handleUserLimitChange(selectInteraction, triggerChannel, currentConfig, client); - break; - case 'bitrate': - await handleBitrateChange(selectInteraction, triggerChannel, currentConfig, client); - break; - case 'remove_trigger': - await handleRemoveTrigger(selectInteraction, triggerChannel, currentConfig, client); - break; - case 'view_settings': - await handleViewSettings(selectInteraction, triggerChannel, currentConfig, client); - break; - } - } catch (error) { - if (error instanceof TitanBotError) { - logger.debug(`Configuration validation error: ${error.message}`, error.context || {}); - } else { - logger.error('Unexpected configuration menu error:', error); - } - - const errorMessage = error instanceof TitanBotError - ? error.userMessage || 'An error occurred while processing your selection.' - : 'An error occurred while processing your selection.'; - - await selectInteraction.followUp({ - embeds: [errorEmbed('Configuration Error', errorMessage)], - flags: MessageFlags.Ephemeral, - }).catch(() => {}); - } - }); - - collector.on('end', async (collected, reason) => { - if (reason === 'time') { - const disabledRow = new ActionRowBuilder().addComponents( - selectMenu.setDisabled(true) - ); - - await InteractionHelper.safeEditReply(interaction, { - components: [disabledRow], - }).catch(() => {}); - } - }); - } catch (error) { - if (error instanceof TitanBotError) { - throw error; - } - logger.error('Unexpected error in config_setup:', error); - throw new TitanBotError( - `Config setup failed: ${error.message}`, - ErrorTypes.UNKNOWN, - 'Failed to configure Join to Create system.' - ); - } - } -}; - -async function handleNameTemplateChange(interaction, triggerChannel, currentConfig, client) { - const embed = new EmbedBuilder() - .setTitle('📝 Channel Name Template Configuration') - .setDescription('Please enter the new channel name template.') - .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', - inline: false - }, - { - name: 'Current Template', - value: `\`${currentConfig.channelOptions?.[triggerChannel.id]?.nameTemplate || currentConfig.channelNameTemplate}\``, - inline: false - } - ) - .setColor(getColor('info')) - .setFooter({ text: 'Type your new template in the chat below' }); - - await interaction.followUp({ embeds: [embed], flags: MessageFlags.Ephemeral }); - - const collector = interaction.channel.createMessageCollector({ - filter: (m) => m.author.id === interaction.user.id, -time: 600_000, - max: 1 - }); - - collector.on('collect', async (message) => { - try { - const newTemplate = message.content.trim(); - - if (!newTemplate || newTemplate.length > 100) { - await interaction.followUp({ - embeds: [errorEmbed('Invalid Template', 'Template must be between 1 and 100 characters.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - - const channelOptions = currentConfig.channelOptions || {}; - channelOptions[triggerChannel.id] = { - ...channelOptions[triggerChannel.id], - nameTemplate: newTemplate - }; - - await updateJoinToCreateConfig(client, interaction.guild.id, { - channelOptions: channelOptions - }); - - await interaction.followUp({ - embeds: [successEmbed('✅ Template Updated', `Channel name template changed to \`${newTemplate}\``)], - flags: MessageFlags.Ephemeral, - }); - - await message.delete().catch(() => {}); - } catch (error) { - if (error instanceof TitanBotError) { - logger.debug(`Template validation error: ${error.message}`); - } else { - logger.error('Template update error:', error); - } - - const errorMessage = error instanceof TitanBotError - ? error.userMessage || 'Could not update the channel name template.' - : 'Could not update the channel name template.'; - - await interaction.followUp({ - embeds: [errorEmbed('Update Failed', errorMessage)], - flags: MessageFlags.Ephemeral, - }).catch(() => {}); - } - }); - - collector.on('end', (collected, reason) => { - if (reason === 'time') { - interaction.followUp({ - embeds: [errorEmbed('Timeout', 'No response received. Template update cancelled.')], - flags: MessageFlags.Ephemeral, - }).catch(() => {}); - } - }); -} - -async function handleUserLimitChange(interaction, triggerChannel, currentConfig, client) { - const embed = new EmbedBuilder() - .setTitle('👥 User Limit Configuration') - .setDescription('Please enter the new user limit (0-99, where 0 = no limit).') - .addFields( - { - name: 'Current Limit', - value: `${currentConfig.channelOptions?.[triggerChannel.id]?.userLimit || currentConfig.userLimit === 0 ? 'No limit' : currentConfig.userLimit + ' users'}`, - inline: false - } - ) - .setColor(getColor('info')) - .setFooter({ text: 'Type the new limit in the chat below' }); - - await interaction.followUp({ embeds: [embed], flags: MessageFlags.Ephemeral }); - - const collector = interaction.channel.createMessageCollector({ - filter: (m) => m.author.id === interaction.user.id && /^\d+$/.test(m.content.trim()), - time: 600_000, - max: 1 - }); - - collector.on('collect', async (message) => { - try { - const newLimit = parseInt(message.content.trim()); - - if (newLimit < 0 || newLimit > 99) { - await interaction.followUp({ - embeds: [errorEmbed('Invalid Limit', 'User limit must be between 0 and 99.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - - const channelOptions = currentConfig.channelOptions || {}; - channelOptions[triggerChannel.id] = { - ...channelOptions[triggerChannel.id], - userLimit: newLimit - }; - - await updateJoinToCreateConfig(client, interaction.guild.id, { - channelOptions: channelOptions - }); - - await interaction.followUp({ - embeds: [successEmbed('✅ Limit Updated', `User limit changed to ${newLimit === 0 ? 'No limit' : newLimit + ' users'}`)], - flags: MessageFlags.Ephemeral, - }); - - await message.delete().catch(() => {}); - } catch (error) { - if (error instanceof TitanBotError) { - logger.debug(`User limit validation error: ${error.message}`); - } else { - logger.error('User limit update error:', error); - } - - const errorMessage = error instanceof TitanBotError - ? error.userMessage || 'Could not update the user limit.' - : 'Could not update the user limit.'; - - await interaction.followUp({ - embeds: [errorEmbed('Update Failed', errorMessage)], - flags: MessageFlags.Ephemeral, - }).catch(() => {}); - } - }); - - collector.on('end', (collected, reason) => { - if (reason === 'time') { - interaction.followUp({ - embeds: [errorEmbed('Timeout', 'No valid response received. Update cancelled.')], - flags: MessageFlags.Ephemeral, - }).catch(() => {}); - } - }); -} - -async function handleBitrateChange(interaction, triggerChannel, currentConfig, client) { - const embed = new EmbedBuilder() - .setTitle('🎵 Bitrate Configuration') - .setDescription('Please enter the new bitrate in kbps (8-384).') - .addFields( - { - name: 'Current 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', - inline: false - } - ) - .setColor(getColor('info')) - .setFooter({ text: 'Type the new bitrate in the chat below' }); - - await interaction.followUp({ embeds: [embed], flags: MessageFlags.Ephemeral }); - - const collector = interaction.channel.createMessageCollector({ - filter: (m) => m.author.id === interaction.user.id && /^\d+$/.test(m.content.trim()), - time: 600_000, - max: 1 - }); - - collector.on('collect', async (message) => { - try { - const newBitrate = parseInt(message.content.trim()); - - if (newBitrate < 8 || newBitrate > 384) { - await interaction.followUp({ - embeds: [errorEmbed('Invalid Bitrate', 'Bitrate must be between 8 and 384 kbps.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - - const channelOptions = currentConfig.channelOptions || {}; - channelOptions[triggerChannel.id] = { - ...channelOptions[triggerChannel.id], - bitrate: newBitrate * 1000 - }; - - await updateJoinToCreateConfig(client, interaction.guild.id, { - channelOptions: channelOptions - }); - - await interaction.followUp({ - embeds: [successEmbed('✅ Bitrate Updated', `Bitrate changed to ${newBitrate} kbps`)], - flags: MessageFlags.Ephemeral, - }); - - await message.delete().catch(() => {}); - } catch (error) { - if (error instanceof TitanBotError) { - logger.debug(`Bitrate validation error: ${error.message}`); - } else { - logger.error('Bitrate update error:', error); - } - - const errorMessage = error instanceof TitanBotError - ? error.userMessage || 'Could not update the bitrate.' - : 'Could not update the bitrate.'; - - await interaction.followUp({ - embeds: [errorEmbed('Update Failed', errorMessage)], - flags: MessageFlags.Ephemeral, - }).catch(() => {}); - } - }); - - collector.on('end', (collected, reason) => { - if (reason === 'time') { - interaction.followUp({ - embeds: [errorEmbed('Timeout', 'No valid response received. Update cancelled.')], - flags: MessageFlags.Ephemeral, - }).catch(() => {}); - } - }); -} - -async function handleRemoveTrigger(interaction, triggerChannel, currentConfig, client) { - 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( - new ButtonBuilder() - .setCustomId(`confirm_remove_${triggerChannel.id}`) - .setLabel('Remove Channel') - .setStyle(ButtonStyle.Danger), - new ButtonBuilder() - .setCustomId(`cancel_remove_${triggerChannel.id}`) - .setLabel('Cancel') - .setStyle(ButtonStyle.Secondary) - ); - - await interaction.followUp({ - embeds: [embed], - components: [row], - flags: MessageFlags.Ephemeral - }); - - const collector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.Button, - filter: (i) => i.user.id === interaction.user.id && - (i.customId === `confirm_remove_${triggerChannel.id}` || i.customId === `cancel_remove_${triggerChannel.id}`), - time: 600_000, - max: 1 - }); - - collector.on('collect', async (buttonInteraction) => { - await buttonInteraction.deferUpdate(); - - if (buttonInteraction.customId === `confirm_remove_${triggerChannel.id}`) { - try { - const success = await removeJoinToCreateTrigger(client, interaction.guild.id, triggerChannel.id); - - if (success) { - await buttonInteraction.followUp({ - embeds: [successEmbed('✅ Channel Removed', `${triggerChannel} has been removed from the Join to Create system.`)], - flags: MessageFlags.Ephemeral, - }); - } else { - await buttonInteraction.followUp({ - embeds: [errorEmbed('Removal Failed', 'Could not remove the trigger channel.')], - flags: MessageFlags.Ephemeral, - }); - } - } catch (error) { - if (error instanceof TitanBotError) { - logger.debug(`Trigger removal validation error: ${error.message}`); - } else { - logger.error('Remove trigger error:', error); - } - - const errorMessage = error instanceof TitanBotError - ? error.userMessage || 'An error occurred while removing the trigger channel.' - : 'An error occurred while removing the trigger channel.'; - - await buttonInteraction.followUp({ - embeds: [errorEmbed('Removal Failed', errorMessage)], - flags: MessageFlags.Ephemeral, - }).catch(() => {}); - } - } else { - await buttonInteraction.followUp({ - embeds: [successEmbed('✅ Cancelled', 'Channel removal has been cancelled.')], - flags: MessageFlags.Ephemeral, - }); - } - }); - - collector.on('end', (collected, reason) => { - if (reason === 'time') { - interaction.followUp({ - embeds: [errorEmbed('Timeout', 'No response received. Removal cancelled.')], - flags: MessageFlags.Ephemeral, - }).catch(() => {}); - } - }); -} - -async function handleViewSettings(interaction, triggerChannel, currentConfig, client) { - const channelConfig = currentConfig.channelOptions?.[triggerChannel.id] || {}; - - const embed = new EmbedBuilder() - .setTitle('📋 Current Settings') - .setDescription(`Configuration for ${triggerChannel}`) - .setColor(getColor('info')) - .addFields( - { - name: '🎯 Trigger Channel', - value: `${triggerChannel} (${triggerChannel.id})`, - inline: false - }, - { - name: '📝 Channel Name Template', - value: `\`${channelConfig.nameTemplate || currentConfig.channelNameTemplate}\``, - inline: false - }, - { - name: '👥 User Limit', - value: `${channelConfig.userLimit || currentConfig.userLimit === 0 ? 'No limit' : (channelConfig.userLimit || currentConfig.userLimit) + ' users'}`, - inline: true - }, - { - name: '🎵 Bitrate', - value: `${(channelConfig.bitrate || currentConfig.bitrate) / 1000} kbps`, - inline: true - }, - { - name: '📁 Category', - value: currentConfig.categoryId ? `<#${currentConfig.categoryId}>` : 'Not set', - inline: true - }, - { - name: '📊 System Status', - value: currentConfig.enabled ? '✅ Enabled' : '❌ Disabled', - inline: true - }, - { - name: '🔢 Active Temporary Channels', - value: Object.keys(currentConfig.temporaryChannels || {}).length.toString(), - inline: true - } - ) - .setTimestamp(); - - await interaction.followUp({ - embeds: [embed], - flags: MessageFlags.Ephemeral - }); -} - - - - diff --git a/src/commands/JoinToCreate/modules/setup.js b/src/commands/JoinToCreate/modules/setup.js deleted file mode 100644 index 2814fb2b4a..0000000000 --- a/src/commands/JoinToCreate/modules/setup.js +++ /dev/null @@ -1,81 +0,0 @@ -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 { addJoinToCreateTrigger, getJoinToCreateConfig } from '../../../utils/database.js'; - -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -export default { - async execute(interaction, config, client) { - const category = interaction.options.getChannel('category'); - 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; - - try { - const triggerChannel = await interaction.guild.channels.create({ - name: 'Join to Create', - type: ChannelType.GuildVoice, - parent: category?.id, - userLimit: userLimit, - bitrate: bitrate * 1000, - permissionOverwrites: [ - { - id: interaction.guild.id, - allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect], - }, - ], - }); - - await addJoinToCreateTrigger(client, guildId, triggerChannel.id, { - nameTemplate: nameTemplate, - userLimit: userLimit, - bitrate: bitrate * 1000, - categoryId: category?.id - }); - - const embed = successEmbed( - `Created trigger channel: ${triggerChannel}\n\n` + - `**Settings:**\n` + - `• Temporary Channel Name Template: \`${nameTemplate}\`\n` + - `• User Limit: ${userLimit === 0 ? 'No limit' : userLimit + ' users'}\n` + - `• Bitrate: ${bitrate} kbps\n` + - `${category ? `• Category: ${category.name}` : '• Category: None (root level)'}\n\n` + - `When users join this channel, a temporary voice channel will be created for them.`, - '✅ Join to Create Setup Complete' - ); - - try { - if (interaction.deferred) { - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } else { - await InteractionHelper.safeReply(interaction, { embeds: [embed], flags: MessageFlags.Ephemeral }); - } - } catch (responseError) { - logger.error('Error responding to interaction:', responseError); - - try { - if (!interaction.replied) { - await InteractionHelper.safeReply(interaction, { embeds: [embed], flags: MessageFlags.Ephemeral }); - } - } catch (e) { - logger.error('All response attempts failed:', e); - } - } - } catch (error) { - if (error instanceof TitanBotError) { - throw error; - } - logger.error('Error in JoinToCreate setup:', error); - throw new TitanBotError( - `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 deleted file mode 100644 index a0b0e6afa0..0000000000 --- a/src/commands/Leveling/leaderboard.js +++ /dev/null @@ -1,97 +0,0 @@ - - - - - -import { SlashCommandBuilder, EmbedBuilder, MessageFlags } from 'discord.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { getLeaderboard, getLevelingConfig, getXpForLevel } from '../../services/leveling.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName('leaderboard') - .setDescription("Shows the server's level leaderboard") - .setDMPermission(false), - category: 'Leveling', - - - - - - - - async execute(interaction, config, client) { - try { - 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 leaderboard = await getLeaderboard(client, interaction.guildId, 10); - - if (leaderboard.length === 0) { - throw new TitanBotError( - 'No leaderboard data found', - ErrorTypes.DATABASE, - 'No level data found yet. Start chatting to gain XP!' - ); - } - - const embed = new EmbedBuilder() - .setTitle('🏆 Level Leaderboard') - .setColor('#2ecc71') - .setDescription("Top 10 most active members in this server:") - .setTimestamp(); - - const leaderboardText = await Promise.all( - leaderboard.map(async (user, index) => { - try { - const member = await interaction.guild.members.fetch(user.userId).catch(() => null); - const userMention = member?.user.toString() || `<@${user.userId}>`; - const xpForNextLevel = getXpForLevel(user.level + 1); - - let rankPrefix = `${index + 1}.`; - if (index === 0) rankPrefix = '🥇'; - else if (index === 1) rankPrefix = '🥈'; - else if (index === 2) rankPrefix = '🥉'; - else rankPrefix = `**${index + 1}.**`; - - return `${rankPrefix} ${userMention} - Level ${user.level} (${user.xp}/${xpForNextLevel} XP)`; - } catch { - return `**${index + 1}.** Error loading user ${user.userId}`; - } - }) - ); - - embed.addFields({ - name: 'Rankings', - value: leaderboardText.join('\n') - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.debug(`Leaderboard displayed for guild ${interaction.guildId}`); - } catch (error) { - logger.error('Leaderboard command error:', error); - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'leaderboard' - }); - } - } -}; - - - diff --git a/src/commands/Leveling/level.js b/src/commands/Leveling/level.js deleted file mode 100644 index df7235b1bd..0000000000 --- a/src/commands/Leveling/level.js +++ /dev/null @@ -1,179 +0,0 @@ -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed } from '../../utils/embeds.js'; -import { getLevelingConfig, saveLevelingConfig } from '../../services/leveling.js'; -import { botHasPermission } from '../../utils/permissionGuard.js'; -import { TitanBotError, ErrorTypes, handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { logger } from '../../utils/logger.js'; -import levelDashboard from './modules/level_dashboard.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) { - try { - const deferred = await InteractionHelper.safeDefer(interaction, { - flags: MessageFlags.Ephemeral, - }); - if (!deferred) return; - - if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - 'Missing Permissions', - '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'); - 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 InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - 'Invalid XP Range', - `Minimum XP (**${xpMin}**) cannot be greater than maximum XP (**${xpMax}**).`, - ), - ], - }); - } - - if (!botHasPermission(channel, ['SendMessages', 'EmbedLinks'])) { - throw new TitanBotError( - 'Bot missing permissions in the specified channel', - ErrorTypes.PERMISSION, - `I need **SendMessages** and **EmbedLinks** permissions in ${channel} to send level-up notifications.`, - ); - } - - const existingConfig = await getLevelingConfig(client, interaction.guildId); - - if (existingConfig.configured) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - 'Leveling System Already Active', - `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', - }), - ], - }); - } - } catch (error) { - logger.error('Level command error:', error); - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'level', - }); - } - }, -}; diff --git a/src/commands/Leveling/leveladd.js b/src/commands/Leveling/leveladd.js deleted file mode 100644 index e513b421ee..0000000000 --- a/src/commands/Leveling/leveladd.js +++ /dev/null @@ -1,105 +0,0 @@ - - - - - -import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, MessageFlags } from 'discord.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { checkUserPermissions } from '../../utils/permissionGuard.js'; -import { addLevels, getLevelingConfig } from '../../services/leveling.js'; -import { createEmbed } from '../../utils/embeds.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName('leveladd') - .setDescription('Add levels to a user') - .addUserOption((option) => - option - .setName('user') - .setDescription('The user to add levels to') - .setRequired(true) - ) - .addIntegerOption((option) => - option - .setName('levels') - .setDescription('Number of levels to add') - .setRequired(true) - .setMinValue(1) - ) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .setDMPermission(false), - category: 'Leveling', - - - - - - - - async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction); - - - const hasPermission = await checkUserPermissions( - interaction, - PermissionFlagsBits.ManageGuild, - 'You need ManageGuild permission to use this command.' - ); - if (!hasPermission) return; - - const levelingConfig = await getLevelingConfig(client, interaction.guildId); - if (!levelingConfig?.enabled) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - new EmbedBuilder() - .setColor('#f1c40f') - .setDescription('The leveling system is currently disabled on this server.') - ], - flags: MessageFlags.Ephemeral - }); - return; - } - - const targetUser = interaction.options.getUser('user'); - const levelsToAdd = interaction.options.getInteger('levels'); - - - const member = await interaction.guild.members.fetch(targetUser.id).catch(() => null); - if (!member) { - throw new TitanBotError( - `User ${targetUser.id} not found in this guild`, - ErrorTypes.USER_INPUT, - 'The specified user is not in this server.' - ); - } - - - const userData = await addLevels(client, interaction.guildId, targetUser.id, levelsToAdd); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - createEmbed({ - title: '✅ Levels Added', - description: `Successfully added ${levelsToAdd} levels to ${targetUser.tag}.\n**New Level:** ${userData.level}`, - color: 'success' - }) - ] - }); - - logger.info( - `[ADMIN] User ${interaction.user.tag} added ${levelsToAdd} levels to ${targetUser.tag} in guild ${interaction.guildId}` - ); - } catch (error) { - logger.error('LevelAdd command error:', error); - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'leveladd' - }); - } - } -}; - - diff --git a/src/commands/Leveling/levelremove.js b/src/commands/Leveling/levelremove.js deleted file mode 100644 index b75434be26..0000000000 --- a/src/commands/Leveling/levelremove.js +++ /dev/null @@ -1,115 +0,0 @@ - - - - - -import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, MessageFlags } from 'discord.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { checkUserPermissions } from '../../utils/permissionGuard.js'; -import { removeLevels, getUserLevelData, getLevelingConfig } from '../../services/leveling.js'; -import { createEmbed } from '../../utils/embeds.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName('levelremove') - .setDescription('Remove levels from a user') - .addUserOption((option) => - option - .setName('user') - .setDescription('The user to remove levels from') - .setRequired(true) - ) - .addIntegerOption((option) => - option - .setName('levels') - .setDescription('Number of levels to remove') - .setRequired(true) - .setMinValue(1) - ) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .setDMPermission(false), - category: 'Leveling', - - - - - - - - async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction); - - - const hasPermission = await checkUserPermissions( - interaction, - PermissionFlagsBits.ManageGuild, - 'You need ManageGuild permission to use this command.' - ); - if (!hasPermission) return; - - const levelingConfig = await getLevelingConfig(client, interaction.guildId); - if (!levelingConfig?.enabled) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - new EmbedBuilder() - .setColor('#f1c40f') - .setDescription('The leveling system is currently disabled on this server.') - ], - flags: MessageFlags.Ephemeral - }); - return; - } - - const targetUser = interaction.options.getUser('user'); - const levelsToRemove = interaction.options.getInteger('levels'); - - - const member = await interaction.guild.members.fetch(targetUser.id).catch(() => null); - if (!member) { - throw new TitanBotError( - `User ${targetUser.id} not found in this guild`, - ErrorTypes.USER_INPUT, - 'The specified user is not in this server.' - ); - } - - - const userData = await getUserLevelData(client, interaction.guildId, targetUser.id); - if (userData.level === 0) { - throw new TitanBotError( - `User ${targetUser.id} is already at minimum level`, - ErrorTypes.VALIDATION, - `${targetUser.tag} is already at level 0 and cannot have levels removed.` - ); - } - - - const updatedData = await removeLevels(client, interaction.guildId, targetUser.id, levelsToRemove); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - createEmbed({ - title: '✅ Levels Removed', - description: `Successfully removed ${levelsToRemove} levels from ${targetUser.tag}.\n**New Level:** ${updatedData.level}`, - color: 'success' - }) - ] - }); - - logger.info( - `[ADMIN] User ${interaction.user.tag} removed ${levelsToRemove} levels from ${targetUser.tag} in guild ${interaction.guildId}` - ); - } catch (error) { - logger.error('LevelRemove command error:', error); - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'levelremove' - }); - } - } -}; - - diff --git a/src/commands/Leveling/levelset.js b/src/commands/Leveling/levelset.js deleted file mode 100644 index e8d4c7c8f1..0000000000 --- a/src/commands/Leveling/levelset.js +++ /dev/null @@ -1,105 +0,0 @@ - - - - - -import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, MessageFlags } from 'discord.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { checkUserPermissions } from '../../utils/permissionGuard.js'; -import { setUserLevel, getLevelingConfig } from '../../services/leveling.js'; -import { createEmbed } from '../../utils/embeds.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName('levelset') - .setDescription("Set a user's level to a specific value") - .addUserOption((option) => - option - .setName('user') - .setDescription('The user to set the level for') - .setRequired(true) - ) - .addIntegerOption((option) => - option - .setName('level') - .setDescription('The level to set') - .setRequired(true) - .setMinValue(0) - ) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .setDMPermission(false), - category: 'Leveling', - - - - - - - - async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction); - - - const hasPermission = await checkUserPermissions( - interaction, - PermissionFlagsBits.ManageGuild, - 'You need ManageGuild permission to use this command.' - ); - if (!hasPermission) return; - - const levelingConfig = await getLevelingConfig(client, interaction.guildId); - if (!levelingConfig?.enabled) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - new EmbedBuilder() - .setColor('#f1c40f') - .setDescription('The leveling system is currently disabled on this server.') - ], - flags: MessageFlags.Ephemeral - }); - return; - } - - const targetUser = interaction.options.getUser('user'); - const newLevel = interaction.options.getInteger('level'); - - - const member = await interaction.guild.members.fetch(targetUser.id).catch(() => null); - if (!member) { - throw new TitanBotError( - `User ${targetUser.id} not found in this guild`, - ErrorTypes.USER_INPUT, - 'The specified user is not in this server.' - ); - } - - - const userData = await setUserLevel(client, interaction.guildId, targetUser.id, newLevel); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - createEmbed({ - title: '✅ Level Set', - description: `Successfully set ${targetUser.tag}'s level to **${newLevel}**.\n**Total XP:** ${userData.totalXp}`, - color: 'success' - }) - ] - }); - - logger.info( - `[ADMIN] User ${interaction.user.tag} set ${targetUser.tag}'s level to ${newLevel} in guild ${interaction.guildId}` - ); - } catch (error) { - logger.error('LevelSet command error:', error); - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'levelset' - }); - } - } -}; - - diff --git a/src/commands/Leveling/modules/level_dashboard.js b/src/commands/Leveling/modules/level_dashboard.js deleted file mode 100644 index 30b58c2c2e..0000000000 --- a/src/commands/Leveling/modules/level_dashboard.js +++ /dev/null @@ -1,562 +0,0 @@ -import { getColor } from '../../../config/bot.js'; -import { - ActionRowBuilder, - StringSelectMenuBuilder, - StringSelectMenuOptionBuilder, - ModalBuilder, - TextInputBuilder, - TextInputStyle, - ChannelSelectMenuBuilder, - ButtonBuilder, - ButtonStyle, - ChannelType, - MessageFlags, - ComponentType, - EmbedBuilder, -} from 'discord.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { successEmbed, errorEmbed } from '../../../utils/embeds.js'; -import { logger } from '../../../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../../../utils/errorHandler.js'; -import { getLevelingConfig, saveLevelingConfig } from '../../../services/leveling.js'; -import { botHasPermission } from '../../../utils/permissionGuard.js'; - -// ─── Embed & Menu Builders ──────────────────────────────────────────────────── - -function buildDashboardEmbed(cfg, 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; - const cooldown = cfg.xpCooldown ?? 60; - const rawMsg = cfg.levelUpMessage || '{user} has leveled up to level {level}!'; - const msgPreview = `\`${rawMsg.length > 60 ? rawMsg.substring(0, 60) + '…' : rawMsg}\``; - - return new EmbedBuilder() - .setTitle('📊 Leveling System Dashboard') - .setDescription(`Manage leveling settings for **${guild.name}**.\nSelect an option below to modify a setting.`) - .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: '\u200B', value: '\u200B', inline: true }, - { name: '💬 Level-up Message', value: msgPreview, inline: false }, - ) - .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) - .setTimestamp(); -} - -function buildSelectMenu(guildId) { - return new StringSelectMenuBuilder() - .setCustomId(`level_cfg_${guildId}`) - .setPlaceholder('Select a setting to configure...') - .addOptions( - new StringSelectMenuOptionBuilder() - .setLabel('Change Level-up Channel') - .setDescription('Set the channel where level-up notifications are sent') - .setValue('channel') - .setEmoji('📢'), - new StringSelectMenuOptionBuilder() - .setLabel('Edit Level-up Message') - .setDescription('Customise the message shown when a user levels up') - .setValue('message') - .setEmoji('💬'), - new StringSelectMenuOptionBuilder() - .setLabel('Set XP Range') - .setDescription('Set the minimum and maximum XP rewarded per message') - .setValue('xp_range') - .setEmoji('🎲'), - new StringSelectMenuOptionBuilder() - .setLabel('Set XP Cooldown') - .setDescription('Seconds between XP grants for the same user') - .setValue('xp_cooldown') - .setEmoji('⏱️'), - ); -} - -function buildButtonRow(cfg, guildId, disabled = false) { - const announceOn = cfg.announceLevelUp !== false; - const systemOn = cfg.enabled !== false; - return new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`level_cfg_toggle_announce_${guildId}`) - .setLabel('Announcements') - .setStyle(announceOn ? ButtonStyle.Success : ButtonStyle.Danger) - .setEmoji('📣') - .setDisabled(disabled), - new ButtonBuilder() - .setCustomId(`level_cfg_toggle_system_${guildId}`) - .setLabel('Leveling') - .setStyle(systemOn ? ButtonStyle.Success : ButtonStyle.Danger) - .setEmoji('⚡') - .setDisabled(disabled), - ); -} - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -async function refreshDashboard(rootInteraction, cfg, guildId) { - const selectMenu = buildSelectMenu(guildId); - await InteractionHelper.safeEditReply(rootInteraction, { - embeds: [buildDashboardEmbed(cfg, rootInteraction.guild)], - components: [ - buildButtonRow(cfg, guildId), - new ActionRowBuilder().addComponents(selectMenu), - ], - }).catch(() => {}); -} - -// ─── Main Export ────────────────────────────────────────────────────────────── - -export default { - async execute(interaction, config, client) { - try { - const guildId = interaction.guild.id; - const cfg = await getLevelingConfig(client, guildId); - - if (!cfg.configured) { - throw new TitanBotError( - 'Leveling system not configured', - ErrorTypes.CONFIGURATION, - 'The leveling system has not been set up yet. Run `/level setup` first to configure it.', - ); - } - - const selectMenu = buildSelectMenu(guildId); - const selectRow = new ActionRowBuilder().addComponents(selectMenu); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [buildDashboardEmbed(cfg, interaction.guild)], - components: [buildButtonRow(cfg, guildId), selectRow], - }); - - const collector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.StringSelect, - filter: i => - i.user.id === interaction.user.id && i.customId === `level_cfg_${guildId}`, - time: 600_000, - }); - - collector.on('collect', async selectInteraction => { - const selectedOption = selectInteraction.values[0]; - try { - switch (selectedOption) { - case 'channel': - await handleChannel(selectInteraction, interaction, cfg, guildId, client); - break; - case 'message': - await handleMessage(selectInteraction, interaction, cfg, guildId, client); - break; - case 'xp_range': - await handleXpRange(selectInteraction, interaction, cfg, guildId, client); - break; - case 'xp_cooldown': - await handleXpCooldown(selectInteraction, interaction, cfg, guildId, client); - break; - } - } catch (error) { - if (error instanceof TitanBotError) { - logger.debug(`Leveling config validation error: ${error.message}`); - } else { - logger.error('Unexpected leveling dashboard error:', error); - } - - const errorMessage = - error instanceof TitanBotError - ? error.userMessage || 'An error occurred while processing your selection.' - : 'An unexpected error occurred while updating the configuration.'; - - if (!selectInteraction.replied && !selectInteraction.deferred) { - await selectInteraction.deferUpdate().catch(() => {}); - } - - await selectInteraction - .followUp({ - embeds: [errorEmbed('Configuration Error', errorMessage)], - flags: MessageFlags.Ephemeral, - }) - .catch(() => {}); - } - }); - - // ── Button collector for the two toggle buttons ────────────────── - const btnCollector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.Button, - filter: i => - i.user.id === interaction.user.id && - (i.customId === `level_cfg_toggle_announce_${guildId}` || - i.customId === `level_cfg_toggle_system_${guildId}`), - time: 600_000, - }); - - btnCollector.on('collect', async btnInteraction => { - try { - await btnInteraction.deferUpdate().catch(() => null); - } catch (err) { - logger.debug('Button interaction already expired:', err.message); - return; - } - const isAnnounce = btnInteraction.customId === `level_cfg_toggle_announce_${guildId}`; - - if (isAnnounce) { - cfg.announceLevelUp = cfg.announceLevelUp === false; - await saveLevelingConfig(client, guildId, cfg); - await btnInteraction.followUp({ - embeds: [ - successEmbed( - '✅ Announcements Updated', - `Level-up announcements are now **${cfg.announceLevelUp ? 'enabled' : 'disabled'}**.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - } else { - const wasEnabled = cfg.enabled !== false; - cfg.enabled = !wasEnabled; - await saveLevelingConfig(client, guildId, cfg); - 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.' : ''}`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - } - - await refreshDashboard(interaction, cfg, guildId); - }); - - collector.on('end', async (collected, reason) => { - if (reason === 'time') { - btnCollector.stop(); - const timeoutEmbed = new EmbedBuilder() - .setTitle('⏰ Dashboard Timed Out') - .setDescription('This dashboard has been closed due to inactivity. Please run the command again to continue.') - .setColor(getColor('error')); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [timeoutEmbed], - components: [], - }).catch(() => {}); - } - }); - - btnCollector.on('end', async (collected, reason) => { - if (reason === 'time') { - const timeoutEmbed = new EmbedBuilder() - .setTitle('⏰ Dashboard Timed Out') - .setDescription('This dashboard has been closed due to inactivity. Please run the command again to continue.') - .setColor(getColor('error')); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [timeoutEmbed], - components: [], - }).catch(() => {}); - } - }); - } catch (error) { - if (error instanceof TitanBotError) throw error; - logger.error('Unexpected error in level_dashboard:', error); - throw new TitanBotError( - `Level dashboard failed: ${error.message}`, - ErrorTypes.UNKNOWN, - 'Failed to open the leveling dashboard.', - ); - } - }, -}; - -// ─── Change Level-up Channel ────────────────────────────────────────────────── - -async function handleChannel(selectInteraction, rootInteraction, cfg, guildId, client) { - await selectInteraction.deferUpdate(); - - const channelSelect = new ChannelSelectMenuBuilder() - .setCustomId('level_cfg_channel') - .setPlaceholder('Select a text channel...') - .addChannelTypes(ChannelType.GuildText) - .setMaxValues(1); - - const row = new ActionRowBuilder().addComponents(channelSelect); - - await selectInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('📢 Change Level-up Channel') - .setDescription( - `**Current:** ${cfg.levelUpChannel ? `<#${cfg.levelUpChannel}>` : '`Not set`'}\n\nSelect the channel where level-up notifications will be sent.`, - ) - .setColor(getColor('info')), - ], - components: [row], - flags: MessageFlags.Ephemeral, - }); - - const chanCollector = rootInteraction.channel.createMessageComponentCollector({ - componentType: ComponentType.ChannelSelect, - filter: i => - i.user.id === selectInteraction.user.id && i.customId === 'level_cfg_channel', - time: 60_000, - max: 1, - }); - - chanCollector.on('collect', async chanInteraction => { - await chanInteraction.deferUpdate(); - const channel = chanInteraction.channels.first(); - - if (!botHasPermission(channel, ['SendMessages', 'EmbedLinks'])) { - await chanInteraction.followUp({ - embeds: [ - errorEmbed( - 'Missing Permissions', - `I need **SendMessages** and **EmbedLinks** permissions in ${channel} to send level-up notifications.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - return; - } - - cfg.levelUpChannel = channel.id; - await saveLevelingConfig(client, guildId, cfg); - - await chanInteraction.followUp({ - embeds: [ - successEmbed( - '✅ Channel Updated', - `Level-up notifications will now be sent in ${channel}.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, cfg, guildId); - }); - - chanCollector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - selectInteraction - .followUp({ - embeds: [ - errorEmbed('Timed Out', 'No channel was selected. The setting was not changed.'), - ], - flags: MessageFlags.Ephemeral, - }) - .catch(() => {}); - } - }); -} - -// ─── Edit Level-up Message ──────────────────────────────────────────────────── - -async function handleMessage(selectInteraction, rootInteraction, cfg, guildId, client) { - const modal = new ModalBuilder() - .setCustomId('level_cfg_message') - .setTitle('Edit Level-up Message') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('message_input') - .setLabel('Message ({user} and {level} are available)') - .setStyle(TextInputStyle.Paragraph) - .setValue(cfg.levelUpMessage || '{user} has leveled up to level {level}!') - .setMaxLength(500) - .setMinLength(1) - .setRequired(true) - .setPlaceholder('{user} has leveled up to level {level}!'), - ), - ); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => - i.customId === 'level_cfg_message' && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - const newMessage = submitted.fields.getTextInputValue('message_input').trim(); - - if (!newMessage.includes('{user}') && !newMessage.includes('{level}')) { - logger.warn( - `Level-up message set without {user} or {level} placeholders in guild ${guildId}`, - ); - } - - cfg.levelUpMessage = newMessage; - await saveLevelingConfig(client, guildId, cfg); - - const preview = newMessage.replace('{user}', '@User').replace('{level}', '5'); - - await submitted.reply({ - embeds: [ - successEmbed( - '✅ Message Updated', - `Level-up message saved.\n**Preview:** ${preview}`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, cfg, guildId); -} - -// ─── Set XP Range ───────────────────────────────────────────────────────────── - -async function handleXpRange(selectInteraction, rootInteraction, cfg, guildId, client) { - const currentMin = cfg.xpRange?.min ?? cfg.xpPerMessage?.min ?? 15; - const currentMax = cfg.xpRange?.max ?? cfg.xpPerMessage?.max ?? 25; - - const modal = new ModalBuilder() - .setCustomId('level_cfg_xp_range') - .setTitle('Set XP Range per Message') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('xp_min_input') - .setLabel('Minimum XP (1–500)') - .setStyle(TextInputStyle.Short) - .setValue(String(currentMin)) - .setMaxLength(3) - .setMinLength(1) - .setRequired(true) - .setPlaceholder('15'), - ), - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('xp_max_input') - .setLabel('Maximum XP (1–500)') - .setStyle(TextInputStyle.Short) - .setValue(String(currentMax)) - .setMaxLength(3) - .setMinLength(1) - .setRequired(true) - .setPlaceholder('25'), - ), - ); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => - i.customId === 'level_cfg_xp_range' && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - const rawMin = submitted.fields.getTextInputValue('xp_min_input').trim(); - const rawMax = submitted.fields.getTextInputValue('xp_max_input').trim(); - const newMin = parseInt(rawMin, 10); - const newMax = parseInt(rawMax, 10); - - if (isNaN(newMin) || isNaN(newMax) || newMin < 1 || newMax < 1 || newMin > 500 || newMax > 500) { - await submitted.reply({ - embeds: [ - errorEmbed('Invalid Values', 'Both XP values must be whole numbers between **1** and **500**.'), - ], - flags: MessageFlags.Ephemeral, - }); - return; - } - - if (newMin > newMax) { - await submitted.reply({ - embeds: [ - errorEmbed('Invalid Range', 'Minimum XP cannot be greater than maximum XP.'), - ], - flags: MessageFlags.Ephemeral, - }); - return; - } - - cfg.xpRange = { min: newMin, max: newMax }; - await saveLevelingConfig(client, guildId, cfg); - - await submitted.reply({ - embeds: [ - successEmbed( - '✅ XP Range Updated', - `Users will now earn between **${newMin}** and **${newMax}** XP per message.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, cfg, guildId); -} - -// ─── Set XP Cooldown ────────────────────────────────────────────────────────── - -async function handleXpCooldown(selectInteraction, rootInteraction, cfg, guildId, client) { - const modal = new ModalBuilder() - .setCustomId('level_cfg_cooldown') - .setTitle('Set XP Cooldown') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('cooldown_input') - .setLabel('Cooldown in seconds (0–3600)') - .setStyle(TextInputStyle.Short) - .setValue(String(cfg.xpCooldown ?? 60)) - .setMaxLength(4) - .setMinLength(1) - .setRequired(true) - .setPlaceholder('60'), - ), - ); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => - i.customId === 'level_cfg_cooldown' && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - const raw = submitted.fields.getTextInputValue('cooldown_input').trim(); - const newCooldown = parseInt(raw, 10); - - if (isNaN(newCooldown) || newCooldown < 0 || newCooldown > 3600) { - await submitted.reply({ - embeds: [ - errorEmbed( - 'Invalid Value', - 'Cooldown must be a whole number between **0** and **3600** seconds.', - ), - ], - flags: MessageFlags.Ephemeral, - }); - return; - } - - cfg.xpCooldown = newCooldown; - await saveLevelingConfig(client, guildId, cfg); - - 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.' : ''}`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, cfg, guildId); -} - diff --git a/src/commands/Leveling/rank.js b/src/commands/Leveling/rank.js deleted file mode 100644 index 8a43e44683..0000000000 --- a/src/commands/Leveling/rank.js +++ /dev/null @@ -1,127 +0,0 @@ - - - - - -import { SlashCommandBuilder, EmbedBuilder, MessageFlags } from 'discord.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { getUserLevelData, getLevelingConfig, getXpForLevel } from '../../services/leveling.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.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) { - try { - 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 TitanBotError( - `User ${targetUser.id} not found in guild`, - ErrorTypes.USER_INPUT, - 'Could not find the specified user in this server.' - ); - } - - const userData = await getUserLevelData(client, interaction.guildId, targetUser.id); - - const 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({ dynamic: true })) - .addFields( - { - name: '📊 Level', - value: safeUserData.level.toString(), - inline: true - }, - { - name: '⭐ XP', - value: `${safeUserData.xp}/${xpNeeded}`, - inline: true - }, - { - name: '✨ Total XP', - value: safeUserData.totalXp.toString(), - inline: true - }, - { - name: `Progress to Level ${safeUserData.level + 1}`, - value: `${progressBar} ${progress}%` - } - ) - .setColor('#2ecc71') - .setTimestamp(); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.debug(`Rank checked for user ${targetUser.id} in guild ${interaction.guildId}`); - } catch (error) { - logger.error('Rank command error:', error); - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'rank' - }); - } - } -}; - - - - - - - -function createProgressBar(percentage, length = 10) { - 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); -} - - - diff --git a/src/commands/Logging/logging.js b/src/commands/Logging/logging.js deleted file mode 100644 index 6009f6b8e7..0000000000 --- a/src/commands/Logging/logging.js +++ /dev/null @@ -1,118 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, ChannelType } from 'discord.js'; -import { errorEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -import dashboard from './modules/logging_dashboard.js'; -import setchannel from './modules/logging_setchannel.js'; -import filter from './modules/logging_filter.js'; - -export default { - data: new SlashCommandBuilder() - .setName('logging') - .setDescription('Manage audit logging for this server.') - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .setDMPermission(false) - .addSubcommand((subcommand) => - subcommand - .setName('dashboard') - .setDescription('Open the interactive logging dashboard — view status and toggle event categories.'), - ) - .addSubcommand((subcommand) => - subcommand - .setName('setchannel') - .setDescription('Set the audit log channel for this server.') - .addChannelOption((option) => - option - .setName('channel') - .setDescription('The text channel for audit logs.') - .addChannelTypes(ChannelType.GuildText) - .setRequired(false), - ) - .addBooleanOption((option) => - option - .setName('disable') - .setDescription('Set to True to disable audit logging entirely.') - .setRequired(false), - ), - ) - .addSubcommandGroup((group) => - group - .setName('filter') - .setDescription('Manage the log ignore list (users and channels to skip).') - .addSubcommand((subcommand) => - subcommand - .setName('add') - .setDescription('Add a user or channel to the log ignore list.') - .addStringOption((option) => - option - .setName('type') - .setDescription('Whether to ignore a user or channel.') - .setRequired(true) - .addChoices( - { name: 'User', value: 'user' }, - { name: 'Channel', value: 'channel' }, - ), - ) - .addStringOption((option) => - option - .setName('id') - .setDescription('The ID of the user or channel to ignore.') - .setRequired(true), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName('remove') - .setDescription('Remove a user or channel from the log ignore list.') - .addStringOption((option) => - option - .setName('type') - .setDescription('Whether this is a user or channel.') - .setRequired(true) - .addChoices( - { name: 'User', value: 'user' }, - { name: 'Channel', value: 'channel' }, - ), - ) - .addStringOption((option) => - option - .setName('id') - .setDescription('The ID of the user or channel to remove from the ignore list.') - .setRequired(true), - ), - ), - ), - - async execute(interaction, config, client) { - try { - // setchannel and filter both need a reply deferred before their logic runs - const subcommandGroup = interaction.options.getSubcommandGroup(false); - const subcommand = interaction.options.getSubcommand(); - - if (subcommand === 'dashboard') { - return await dashboard.execute(interaction, config, client); - } - - await InteractionHelper.safeDefer(interaction); - - if (subcommand === 'setchannel') { - return await setchannel.execute(interaction, config, client); - } - - if (subcommandGroup === 'filter') { - return await filter.execute(interaction, config, client); - } - - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Unknown Subcommand', 'This subcommand is not recognised.')], - }); - } catch (error) { - logger.error('logging command error:', error); - await InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Error', 'An unexpected error occurred.')], - ephemeral: true, - }).catch(() => {}); - } - }, -}; diff --git a/src/commands/Logging/modules/logging_dashboard.js b/src/commands/Logging/modules/logging_dashboard.js deleted file mode 100644 index a2d8e18dc1..0000000000 --- a/src/commands/Logging/modules/logging_dashboard.js +++ /dev/null @@ -1,135 +0,0 @@ -import { EmbedBuilder, PermissionsBitField } from 'discord.js'; -import { getColor } from '../../../config/bot.js'; -import { getGuildConfig } from '../../../services/guildConfig.js'; -import { getLoggingStatus, EVENT_TYPES } from '../../../services/loggingService.js'; -import { createLoggingDashboardComponents } from '../../../utils/loggingUi.js'; -import { errorEmbed } from '../../../utils/embeds.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { logger } from '../../../utils/logger.js'; - -const EVENT_TYPES_BY_CATEGORY = Object.values(EVENT_TYPES).reduce((acc, eventType) => { - const [category] = eventType.split('.'); - if (!acc[category]) acc[category] = []; - acc[category].push(eventType); - return acc; -}, {}); - -const CATEGORY_MAP = [ - ['moderation', '🔨 Moderation'], - ['ticket', '🎫 Ticket Events'], - ['message', '✉️ Message Events'], - ['role', '🏷️ Role Events'], - ['member', '👥 Member Events'], - ['leveling', '📈 Leveling Events'], - ['reactionrole', '🎭 Reaction Role Events'], - ['giveaway', '🎁 Giveaway Events'], - ['counter', '📊 Counter Events'], -]; - -function getCategoryStatus(enabledEvents, category, auditEnabled) { - if (!auditEnabled) return false; - const events = enabledEvents || {}; - if (events[`${category}.*`] === false) return false; - const categoryEvents = EVENT_TYPES_BY_CATEGORY[category] || []; - if (categoryEvents.length === 0) return true; - return categoryEvents.every((eventType) => events[eventType] !== false); -} - -async function formatChannelMention(guild, id) { - 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})`; -} - -export async function buildLoggingDashboardView(interaction, client) { - const guildConfig = await getGuildConfig(client, interaction.guildId); - const loggingStatus = await getLoggingStatus(client, interaction.guildId); - - const auditEnabled = Boolean(loggingStatus.enabled); - const auditChannel = await formatChannelMention( - interaction.guild, - loggingStatus.channelId || guildConfig.logging?.channelId || guildConfig.logChannelId, - ); - const lifecycleChannel = await formatChannelMention(interaction.guild, guildConfig.ticketLogsChannelId); - const transcriptChannel = await formatChannelMention(interaction.guild, guildConfig.ticketTranscriptChannelId); - - const ignoredUsers = guildConfig.logIgnore?.users || []; - const ignoredChannels = guildConfig.logIgnore?.channels || []; - - const categoryLines = CATEGORY_MAP.map(([key, label]) => { - const on = getCategoryStatus(loggingStatus.enabledEvents, key, auditEnabled); - return `${on ? '✅' : '❌'} ${label}`; - }).join('\n'); - - const embed = new EmbedBuilder() - .setTitle('📋 Logging Dashboard') - .setDescription(`Manage audit logging for **${interaction.guild.name}**. Category buttons toggle logging instantly.`) - .setColor(auditEnabled ? getColor('success') : getColor('warning')) - .addFields( - { - name: '🧾 Audit Logging', - value: auditEnabled ? '✅ Enabled' : '❌ Disabled', - inline: true, - }, - { - name: '\u200B', - value: '\u200B', - inline: true, - }, - { - name: '\u200B', - value: '\u200B', - inline: true, - }, - { - name: '📡 Log Channels', - value: [ - `**Audit:** ${auditChannel}`, - `**Ticket Logs:** ${lifecycleChannel}`, - `**Ticket Transcripts:** ${transcriptChannel}`, - ].join('\n'), - inline: false, - }, - { - name: '📋 Event Categories', - value: categoryLines, - inline: false, - }, - { - name: '🧹 Ignore Filters', - value: `Users: **${ignoredUsers.length}**\nChannels: **${ignoredChannels.length}**`, - inline: true, - }, - { - name: '🕒 Last Refresh', - value: ``, - inline: true, - }, - ) - .setFooter({ text: 'Use /logging setchannel to configure the audit channel • /ticket setup or /ticket dashboard to configure ticket channels' }) - .setTimestamp(); - - const components = createLoggingDashboardComponents(loggingStatus.enabledEvents, auditEnabled); - return { embed, components }; -} - -export default { - async execute(interaction, config, client) { - try { - if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Permission Denied', 'You need **Manage Server** permissions to view the logging dashboard.')], - }); - } - - await InteractionHelper.safeDefer(interaction); - const { embed, components } = await buildLoggingDashboardView(interaction, client); - await InteractionHelper.safeEditReply(interaction, { embeds: [embed], components }); - } catch (error) { - logger.error('logging_dashboard error:', error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Dashboard Error', 'Failed to load the logging dashboard.')], - }); - } - }, -}; diff --git a/src/commands/Logging/modules/logging_filter.js b/src/commands/Logging/modules/logging_filter.js deleted file mode 100644 index a75aa9c9bf..0000000000 --- a/src/commands/Logging/modules/logging_filter.js +++ /dev/null @@ -1,99 +0,0 @@ -import { PermissionsBitField } from 'discord.js'; -import { errorEmbed, successEmbed } from '../../../utils/embeds.js'; -import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; -import { logEvent } from '../../../utils/moderation.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { logger } from '../../../utils/logger.js'; - -export default { - async execute(interaction, config, client) { - if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Permission Denied', 'You need **Administrator** permissions to manage log filters.')], - }); - } - - if (!client.db) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Database Error', 'Database not initialized.')], - }); - } - - const subcommand = interaction.options.getSubcommand(); - const type = interaction.options.getString('type'); - const entityId = interaction.options.getString('id'); - const guildId = interaction.guildId; - - const currentConfig = await getGuildConfig(client, guildId); - if (!currentConfig.logIgnore) { - currentConfig.logIgnore = { users: [], channels: [] }; - } - - let targetArray; - let entityType; - let entityName; - - if (type === 'user') { - targetArray = currentConfig.logIgnore.users; - entityType = 'User'; - const member = await interaction.guild.members.fetch(entityId).catch(() => null); - entityName = member ? member.user.tag : `ID: ${entityId}`; - } else if (type === 'channel') { - targetArray = currentConfig.logIgnore.channels; - entityType = 'Channel'; - const channel = interaction.guild.channels.cache.get(entityId); - entityName = channel ? `#${channel.name}` : `ID: ${entityId}`; - } else { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Invalid Type', "Choose `user` or `channel`.")], - }); - } - - let successMessage; - - if (subcommand === 'add') { - if (targetArray.includes(entityId)) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Already Filtered', `${entityType} **${entityName}** is already on the ignore list.`)], - }); - } - targetArray.push(entityId); - successMessage = `${entityType} **${entityName}** added to the log ignore list. Events from them will not be logged.`; - } else if (subcommand === 'remove') { - const index = targetArray.indexOf(entityId); - if (index === -1) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Not Filtered', `${entityType} **${entityName}** was not on the ignore list.`)], - }); - } - targetArray.splice(index, 1); - successMessage = `${entityType} **${entityName}** removed from the log ignore list. Events will now be logged.`; - } else { - return; - } - - try { - await setGuildConfig(client, guildId, currentConfig); - - await logEvent({ - client, - guild: interaction.guild, - event: { - action: 'Log Filter Updated', - target: `Filter ${subcommand}`, - executor: `${interaction.user.tag} (${interaction.user.id})`, - metadata: { entityType, loggingEnabled: currentConfig.enableLogging }, - }, - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed('Filter Updated', successMessage)], - }); - } catch (error) { - logger.error('logging filter error:', error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Database Error', 'Failed to save the filter change.')], - }); - } - }, -}; diff --git a/src/commands/Logging/modules/logging_setchannel.js b/src/commands/Logging/modules/logging_setchannel.js deleted file mode 100644 index 5ce85c97d7..0000000000 --- a/src/commands/Logging/modules/logging_setchannel.js +++ /dev/null @@ -1,88 +0,0 @@ -import { PermissionsBitField, ChannelType } from 'discord.js'; -import { errorEmbed, successEmbed } from '../../../utils/embeds.js'; -import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; -import { logEvent } from '../../../utils/moderation.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { logger } from '../../../utils/logger.js'; - -export default { - async execute(interaction, config, client) { - if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Permission Denied', 'You need **Administrator** permissions to change log channels.')], - }); - } - - if (!client.db) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Database Error', 'Database not initialized.')], - }); - } - - const guildId = interaction.guildId; - const currentConfig = await getGuildConfig(client, guildId); - - const logChannel = interaction.options.getChannel('channel'); - const disableLogging = interaction.options.getBoolean('disable'); - - try { - if (disableLogging) { - currentConfig.logChannelId = null; - currentConfig.enableLogging = false; - currentConfig.logging = { - ...(currentConfig.logging || {}), - enabled: false, - channelId: null, - }; - await setGuildConfig(client, guildId, currentConfig); - return InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed('Logging Disabled 🚫', 'Audit logging has been disabled for this server.')], - }); - } - - if (logChannel) { - const perms = logChannel.permissionsFor(interaction.guild.members.me); - if (!perms.has(PermissionsBitField.Flags.SendMessages) || !perms.has(PermissionsBitField.Flags.EmbedLinks)) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Bot Permission Error', `I need **Send Messages** and **Embed Links** permissions in ${logChannel}.`)], - }); - } - - currentConfig.logChannelId = logChannel.id; - currentConfig.enableLogging = true; - currentConfig.logging = { - ...(currentConfig.logging || {}), - enabled: true, - channelId: logChannel.id, - }; - await setGuildConfig(client, guildId, currentConfig); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed('Log Channel Set 📝', `Audit logs will be sent to ${logChannel}.`)], - }); - - await logEvent({ - client, - guild: interaction.guild, - event: { - action: 'Log Channel Activated', - target: logChannel.toString(), - executor: `${interaction.user.tag} (${interaction.user.id})`, - reason: `Logging channel set by ${interaction.user}`, - metadata: { channelId: logChannel.id, moderatorId: interaction.user.id, loggingEnabled: true }, - }, - }); - return; - } - - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('No Option Provided', 'Provide one of: `channel` or `disable: True`.\n\n> Ticket transcript and logs channels are managed via `/ticket setup` or `/ticket dashboard`.')], - }); - } catch (error) { - logger.error('logging setchannel error:', error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Configuration Error', 'Could not save the configuration.')], - }); - } - }, -}; diff --git a/src/commands/Moderation/ban.js b/src/commands/Moderation/ban.js deleted file mode 100644 index 6573592334..0000000000 --- a/src/commands/Moderation/ban.js +++ /dev/null @@ -1,60 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logModerationAction } from '../../utils/moderation.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { ModerationService } from '../../services/moderationService.js'; -import { handleInteractionError } 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) { - try { - const user = interaction.options.getUser("target"); - const reason = interaction.options.getString("reason") || "No reason provided"; - - if (user.id === interaction.user.id) { - throw new Error("You cannot ban yourself."); - } - if (user.id === client.user.id) { - throw new Error("You cannot ban the bot."); - } - - - const result = await ModerationService.banUser({ - guild: interaction.guild, - user, - moderator: interaction.member, - reason - }); - - await InteractionHelper.universalReply(interaction, { - embeds: [ - successEmbed( - `🚫 **Banned** ${user.tag}`, - `**Reason:** ${reason}\n**Case ID:** #${result.caseId}`, - ), - ], - }); - } catch (error) { - logger.error('Ban command error:', error); - await handleInteractionError(interaction, error, { subtype: 'ban_failed' }); - } - }, -}; - - - diff --git a/src/commands/Moderation/cases.js b/src/commands/Moderation/cases.js deleted file mode 100644 index ad16592497..0000000000 --- a/src/commands/Moderation/cases.js +++ /dev/null @@ -1,186 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; -import { getModerationCases } from '../../utils/moderation.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName('cases') - .setDescription('View moderation cases and audit logs') - .setDefaultMemberPermissions(PermissionFlagsBits.ViewAuditLog) - .setDMPermission(false) - .addStringOption(option => - option.setName('filter') - .setDescription('Filter cases by type or user') - .addChoices( - { name: 'All Cases', value: 'all' }, - { name: 'Bans', value: 'Member Banned' }, - { name: 'Kicks', value: 'Member Kicked' }, - { name: 'Timeouts', value: 'Member Timed Out' }, - { name: 'Warnings', value: 'User Warned' } - ) - ) - .addUserOption(option => - option.setName('user') - .setDescription('Filter cases by specific user') - ) - .addIntegerOption(option => - option.setName('limit') - .setDescription('Number of cases to show (default: 10)') - .setMinValue(1) - .setMaxValue(50) - ), - - async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Cases interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'cases' - }); - return; - } - - try { - const filterType = interaction.options.getString('filter') || 'all'; - const targetUser = interaction.options.getUser('user'); - const limit = interaction.options.getInteger('limit') || 10; - - const filters = { - limit, - action: filterType === 'all' ? undefined : filterType, - userId: targetUser?.id - }; - - const cases = await getModerationCases(interaction.guild.id, filters); - - if (cases.length === 0) { - throw new Error(targetUser - ? `No moderation cases found for ${targetUser.tag}` - : `No ${filterType === 'all' ? '' : filterType} cases found in this server.` - ); - } - - const CASES_PER_PAGE = 5; - const totalPages = Math.ceil(cases.length / CASES_PER_PAGE); - let currentPage = 1; - - const createCasesEmbed = (page) => { - 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}**` - }); - - pageCases.forEach(case_ => { - const date = new Date(case_.createdAt).toLocaleDateString(); - 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'}`, - inline: false - }); - }); - - embed.setFooter({ - text: `Total cases: ${cases.length} | Filter: ${filterType}${targetUser ? ` | User: ${targetUser.tag}` : ''}` - }); - - return embed; - }; - - const createNavigationRow = (page) => { - const row = new ActionRowBuilder(); - - const prevButton = new ButtonBuilder() - .setCustomId('prev_page') - .setLabel('⬅️ Previous') - .setStyle(ButtonStyle.Secondary) - .setDisabled(page === 1); - - const pageInfoButton = new ButtonBuilder() - .setCustomId('page_info') - .setLabel(`Page ${page}/${totalPages}`) - .setStyle(ButtonStyle.Primary) - .setDisabled(true); - - const nextButton = new ButtonBuilder() - .setCustomId('next_page') - .setLabel('Next ➡️') - .setStyle(ButtonStyle.Secondary) - .setDisabled(page === totalPages); - - row.addComponents(prevButton, pageInfoButton, nextButton); - return row; - }; - - const message = await interaction.editReply({ - embeds: [createCasesEmbed(currentPage)], - components: [createNavigationRow(currentPage)] - }); - - const collector = message.createMessageComponentCollector({ - componentType: ComponentType.Button, -time: 120000 - }); - - collector.on('collect', async (buttonInteraction) => { - await buttonInteraction.deferUpdate(); - - if (buttonInteraction.user.id !== interaction.user.id) { - await buttonInteraction.followUp({ - content: 'You cannot use these buttons. Run `/cases` to get your own case view.', - flags: MessageFlags.Ephemeral - }); - return; - } - - const { customId } = buttonInteraction; - - if (customId === 'prev_page' && currentPage > 1) { - currentPage--; - } else if (customId === 'next_page' && currentPage < totalPages) { - currentPage++; - } - - await buttonInteraction.editReply({ - embeds: [createCasesEmbed(currentPage)], - components: [createNavigationRow(currentPage)] - }); - }); - - collector.on('end', async () => { - const disabledRow = createNavigationRow(currentPage); - disabledRow.components.forEach(button => button.setDisabled(true)); - - try { - await message.edit({ - components: [disabledRow] - }); - } catch (error) { - } - }); - - } catch (error) { - logger.error('Error in cases command:', error); - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - 'System Error', - 'An error occurred while retrieving moderation cases. Please try again later.' - ) - ], - flags: MessageFlags.Ephemeral - }); - } - } -}; - - - - diff --git a/src/commands/Moderation/dm.js b/src/commands/Moderation/dm.js deleted file mode 100644 index b092f672de..0000000000 --- a/src/commands/Moderation/dm.js +++ /dev/null @@ -1,137 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logEvent } from '../../utils/moderation.js'; -import { logger } from '../../utils/logger.js'; -import { sanitizeMarkdown } from '../../utils/sanitization.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("dm") - .setDescription("Send a direct message to a user (Staff only)") - .addUserOption(option => - option - .setName("user") - .setDescription("The user to send a DM to") - .setRequired(true) - ) - .addStringOption(option => - option - .setName("message") - .setDescription("The message to send") - .setRequired(true) - ) - .addBooleanOption(option => - option - .setName("anonymous") - .setDescription("Send the message anonymously (default: false)") - .setRequired(false) - ) - .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers) - .setDMPermission(false), - category: "Moderation", - - async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`DM interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'dm' - }); - return; - } - - const targetUser = interaction.options.getUser("user"); - const message = interaction.options.getString("message"); - const anonymous = interaction.options.getBoolean("anonymous") || false; - - try { - - if (message.length > 2000) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Message Too Long", - "Messages must be under 2000 characters." - ), - ], - flags: MessageFlags.Ephemeral, - }); - } - - - if (targetUser.bot) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Cannot DM Bot", - "You cannot send DMs to bot accounts." - ), - ], - flags: MessageFlags.Ephemeral, - }); - } - - - const sanitized = sanitizeMarkdown(message); - - const dmChannel = await targetUser.createDM(); - - await dmChannel.send({ - embeds: [ - successEmbed( - anonymous ? "Message from the Staff Team" : `Message from ${interaction.user.tag}`, - sanitized - ).setFooter({ - text: `You cannot reply to this message. | Logger ID: ${interaction.id}` - }) - ] - }); - - await logEvent({ - client: interaction.client, - guild: interaction.guild, - event: { - action: "DM Sent", - target: `${targetUser.tag} (${targetUser.id})`, - executor: `${interaction.user.tag} (${interaction.user.id})`, - reason: `Anonymous: ${anonymous ? 'Yes' : 'No'}`, - metadata: { - userId: targetUser.id, - moderatorId: interaction.user.id, - anonymous, - messageLength: sanitized.length - } - } - }); - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "DM Sent", - `Successfully sent a message to ${targetUser.tag}` - ), - ], - }); - } catch (error) { - logger.error('DM command error:', error); - -if (error.code === 50007) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed("Error", `Could not send a DM to ${targetUser.tag}. They may have DMs disabled.`), - ], - }); - } - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed("Error", `Failed to send DM: ${error.message}`), - ], - }); - } - } -}; - - diff --git a/src/commands/Moderation/kick.js b/src/commands/Moderation/kick.js deleted file mode 100644 index d1902f9068..0000000000 --- a/src/commands/Moderation/kick.js +++ /dev/null @@ -1,125 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logModerationAction } from '../../utils/moderation.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; - -export default { - data: new SlashCommandBuilder() - .setName("kick") - .setDescription("Kick a user from the server") - .addUserOption((option) => - option - .setName("target") - .setDescription("The user to kick") - .setRequired(true), - ) - .addStringOption((option) => - option.setName("reason").setDescription("Reason for the kick"), - ) -.setDefaultMemberPermissions(PermissionFlagsBits.KickMembers), - category: "moderation", - - async execute(interaction, config, client) { - try { - - if (!interaction.member.permissions.has(PermissionFlagsBits.KickMembers)) { - throw new TitanBotError( - "User lacks permission", - ErrorTypes.PERMISSION, - "You do not have permission to kick members." - ); - } - - const targetUser = interaction.options.getUser("target"); - const member = interaction.options.getMember("target"); - const reason = interaction.options.getString("reason") || "No reason provided"; - - - if (targetUser.id === interaction.user.id) { - throw new TitanBotError( - "Cannot kick self", - ErrorTypes.VALIDATION, - "You cannot kick yourself." - ); - } - - - if (targetUser.id === client.user.id) { - throw new TitanBotError( - "Cannot kick bot", - ErrorTypes.VALIDATION, - "You cannot kick the bot." - ); - } - - - if (!member) { - throw new TitanBotError( - "Target not found", - ErrorTypes.USER_INPUT, - "The target user is not currently in this server.", - { subtype: 'user_not_found' } - ); - } - - - if (interaction.member.roles.highest.position <= member.roles.highest.position) { - throw new TitanBotError( - "Cannot kick user", - ErrorTypes.PERMISSION, - "You cannot kick a user with an equal or higher role than you." - ); - } - - - if (!member.kickable) { - throw new TitanBotError( - "Bot cannot kick", - ErrorTypes.PERMISSION, - "I cannot kick this user. Please check my role position relative to the target user." - ); - } - - - await member.kick(reason); - - - const caseId = await logModerationAction({ - client, - guild: interaction.guild, - event: { - action: "Member Kicked", - target: `${targetUser.tag} (${targetUser.id})`, - executor: `${interaction.user.tag} (${interaction.user.id})`, - reason, - metadata: { - userId: targetUser.id, - moderatorId: interaction.user.id - } - } - }); - - - await InteractionHelper.universalReply(interaction, { - embeds: [ - successEmbed( - `👢 **Kicked** ${targetUser.tag}`, - `**Reason:** ${reason}\n**Case ID:** #${caseId}`, - ), - ], - }); - } catch (error) { - logger.error('Kick command error:', error); - const errorEmbed_default = errorEmbed( - "An unexpected error occurred while trying to kick the user.", - error.message || "Could not kick the user" - ); - await InteractionHelper.universalReply(interaction, { embeds: [errorEmbed_default] }); - } - } -}; - - - diff --git a/src/commands/Moderation/lock.js b/src/commands/Moderation/lock.js deleted file mode 100644 index 7705db393f..0000000000 --- a/src/commands/Moderation/lock.js +++ /dev/null @@ -1,111 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; -import { createEmbed, errorEmbed, 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'; -export default { - data: new SlashCommandBuilder() - .setName("lock") - .setDescription( - "Locks the current channel (prevents @everyone from sending messages).", - ) -.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), - category: "moderation", - - async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Lock interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'lock' - }); - return; - } - - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Permission Denied", - "You need the `Manage Channels` permission to lock channels.", - ), - ], - }); - - const channel = interaction.channel; - const everyoneRole = interaction.guild.roles.everyone; - - try { - const currentPermissions = channel.permissionsFor(everyoneRole); - if (currentPermissions.has(PermissionFlagsBits.SendMessages) === false) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Channel Already Locked", - `${channel} is already locked.`, - ), - ], - }); - } - - await channel.permissionOverwrites.edit( - everyoneRole, - { SendMessages: false }, -{ type: 0, reason: `Channel locked by ${interaction.user.tag}` }, - ); - - const lockEmbed = createEmbed( - "🔒 Channel Locked (Action Log)", - `${channel} has been locked down by ${interaction.user}.`, - ) -.setColor(getColor('moderation')) - .addFields( - { name: "Channel", value: channel.toString(), inline: true }, - { - name: "Moderator", - value: `${interaction.user.tag} (${interaction.user.id})`, - inline: true, - }, - ); - - await logEvent({ - client, - guild: interaction.guild, - event: { - action: "Channel Locked", - target: channel.toString(), - executor: `${interaction.user.tag} (${interaction.user.id})`, - metadata: { - channelId: channel.id, - category: channel.parent?.name || 'None', - moderatorId: interaction.user.id - } - } - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - `🔒 **Channel Locked**`, - `${channel} is now locked down. No one can speak here now.`, - ), - ], - }); - } catch (error) { - logger.error('Lock command error:', error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "An unexpected error occurred while trying to lock the channel. Check my permissions (I need 'Manage Channels').", - ), - ], - }); - } - } -}; - - - diff --git a/src/commands/Moderation/massban.js b/src/commands/Moderation/massban.js deleted file mode 100644 index b3e6f6ef1d..0000000000 --- a/src/commands/Moderation/massban.js +++ /dev/null @@ -1,231 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } 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 { checkRateLimit } from '../../utils/rateLimiter.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("massban") - .setDescription("Ban multiple users from the server at once") - .addStringOption(option => - option - .setName("users") - .setDescription("User IDs or mentions to ban (separated by spaces or commas)") - .setRequired(true) - ) - .addStringOption(option => - option.setName("reason") - .setDescription("Reason for the mass ban") - .setRequired(false) - ) - .addIntegerOption(option => - option - .setName("delete_days") - .setDescription("Number of days of messages to delete (0-7)") - .setMinValue(0) - .setMaxValue(7) - .setRequired(false) - ) - .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers), - category: "moderation", - - async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Massban interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'massban' - }); - return; - } - - if (!interaction.member.permissions.has(PermissionFlagsBits.BanMembers)) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Permission Denied", - "You do not have permission to ban members." - ), - ], - }); - } - - const usersInput = interaction.options.getString("users"); - const reason = interaction.options.getString("reason") || "Mass ban - No reason provided"; - const deleteDays = interaction.options.getInteger("delete_days") || 0; - - try { - - const rateLimitKey = `massban_${interaction.user.id}`; - const isAllowed = await checkRateLimit(rateLimitKey, 3, 60000); - if (!isAllowed) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - warningEmbed( - "You're performing mass bans too fast. Please wait a minute before trying again.", - "⏳ Rate Limited" - ), - ], - flags: MessageFlags.Ephemeral, - }); - } - - const userIds = usersInput -.replace(/<@!?(\d+)>/g, '$1') -.split(/[\s,]+/) -.filter(id => id && /^\d+$/.test(id)) -.slice(0, 20); - - if (userIds.length === 0) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Invalid Users", - "Please provide valid user IDs or mentions. Maximum 20 users at once." - ), - ], - }); - } - - if (userIds.includes(interaction.user.id)) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Cannot Ban Self", - "You cannot include yourself in a mass ban." - ), - ], - }); - } - - if (userIds.includes(client.user.id)) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Cannot Ban Bot", - "You cannot include the bot in a mass ban." - ), - ], - }); - } - - const results = { - successful: [], - failed: [], - skipped: [] - }; - - for (const userId of userIds) { - try { - const user = await client.users.fetch(userId).catch(() => null); - - if (!user) { - results.failed.push({ userId, reason: "User not found" }); - continue; - } - - const member = await interaction.guild.members.fetch(userId).catch(() => null); - - if (member) { - if (member.roles.highest.position >= interaction.member.roles.highest.position && - interaction.guild.ownerId !== interaction.user.id) { - results.skipped.push({ - user: user.tag, - userId, - reason: "Cannot ban user with equal or higher role" - }); - continue; - } - } - - await interaction.guild.members.ban(userId, { - reason: reason, - deleteMessageDays: deleteDays - }); - - results.successful.push({ - user: user.tag, - userId - }); - - await logModerationAction({ - client, - guild: interaction.guild, - event: { - action: "Member Banned", - target: `${user.tag} (${user.id})`, - executor: `${interaction.user.tag} (${interaction.user.id})`, - reason: `${reason} (Mass Ban)`, - metadata: { - userId: user.id, - moderatorId: interaction.user.id, - massBan: true, - permanent: true - } - } - }); - - } catch (error) { - logger.error(`Failed to ban user ${userId}:`, error); - results.failed.push({ - userId, - reason: error.message || "Unknown error" - }); - } - } - - let description = `**Mass Ban Results:**\n\n`; - - if (results.successful.length > 0) { - description += `✅ **Successfully Banned (${results.successful.length}):**\n`; - results.successful.forEach(result => { - description += `• ${result.user} (${result.userId})\n`; - }); - description += '\n'; - } - - if (results.skipped.length > 0) { - description += `⚠️ **Skipped (${results.skipped.length}):**\n`; - results.skipped.forEach(result => { - description += `• ${result.user} - ${result.reason}\n`; - }); - description += '\n'; - } - - if (results.failed.length > 0) { - description += `❌ **Failed (${results.failed.length}):**\n`; - results.failed.forEach(result => { - description += `• ${result.userId} - ${result.reason}\n`; - }); - } - - const embed = results.successful.length > 0 ? successEmbed : warningEmbed; - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - embed( - `🔨 Mass Ban Completed`, - description - ) - ] - }); - - } catch (error) { - logger.error("Error in massban command:", error); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "System Error", - "An error occurred while processing the mass ban. Please try again later." - ), - ], - }); - } - } -}; - - - diff --git a/src/commands/Moderation/masskick.js b/src/commands/Moderation/masskick.js deleted file mode 100644 index b6471bdcaf..0000000000 --- a/src/commands/Moderation/masskick.js +++ /dev/null @@ -1,214 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } 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 { checkRateLimit } from '../../utils/rateLimiter.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("masskick") - .setDescription("Kick multiple users from the server at once") - .addStringOption(option => - option - .setName("users") - .setDescription("User IDs or mentions to kick (separated by spaces or commas)") - .setRequired(true) - ) - .addStringOption(option => - option.setName("reason") - .setDescription("Reason for the mass kick") - .setRequired(false) - ) - .setDefaultMemberPermissions(PermissionFlagsBits.KickMembers), - category: "moderation", - - async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Masskick interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'masskick' - }); - return; - } - - if (!interaction.member.permissions.has(PermissionFlagsBits.KickMembers)) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Permission Denied", - "You do not have permission to kick members." - ), - ], - }); - } - - const usersInput = interaction.options.getString("users"); - const reason = interaction.options.getString("reason") || "Mass kick - No reason provided"; - - try { - - const rateLimitKey = `masskick_${interaction.user.id}`; - const isAllowed = await checkRateLimit(rateLimitKey, 3, 60000); - if (!isAllowed) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - warningEmbed( - "You're performing mass kicks too fast. Please wait a minute before trying again.", - "⏳ Rate Limited" - ), - ], - flags: MessageFlags.Ephemeral, - }); - } - - const userIds = usersInput -.replace(/<@!?(\d+)>/g, '$1') -.split(/[\s,]+/) -.filter(id => id && /^\d+$/.test(id)) -.slice(0, 20); - - if (userIds.length === 0) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Invalid Users", - "Please provide valid user IDs or mentions. Maximum 20 users at once." - ), - ], - }); - } - - if (userIds.includes(interaction.user.id)) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Cannot Kick Self", - "You cannot include yourself in a mass kick." - ), - ], - }); - } - - if (userIds.includes(client.user.id)) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Cannot Kick Bot", - "You cannot include the bot in a mass kick." - ), - ], - }); - } - - const results = { - successful: [], - failed: [], - skipped: [] - }; - - for (const userId of userIds) { - try { - const member = await interaction.guild.members.fetch(userId).catch(() => null); - - if (!member) { - results.failed.push({ userId, reason: "User not in server" }); - continue; - } - - if (member.roles.highest.position >= interaction.member.roles.highest.position && - interaction.guild.ownerId !== interaction.user.id) { - results.skipped.push({ - user: member.user.tag, - userId, - reason: "Cannot kick user with equal or higher role" - }); - continue; - } - - await member.kick(reason); - - results.successful.push({ - user: member.user.tag, - userId - }); - - await logModerationAction({ - client, - guild: interaction.guild, - event: { - action: "Member Kicked", - target: `${member.user.tag} (${member.user.id})`, - executor: `${interaction.user.tag} (${interaction.user.id})`, - reason: `${reason} (Mass Kick)`, - metadata: { - userId: member.user.id, - moderatorId: interaction.user.id, - massKick: true - } - } - }); - - } catch (error) { - logger.error(`Failed to kick user ${userId}:`, error); - results.failed.push({ - userId, - reason: error.message || "Unknown error" - }); - } - } - - let description = `**Mass Kick Results:**\n\n`; - - if (results.successful.length > 0) { - description += `✅ **Successfully Kicked (${results.successful.length}):**\n`; - results.successful.forEach(result => { - description += `• ${result.user} (${result.userId})\n`; - }); - description += '\n'; - } - - if (results.skipped.length > 0) { - description += `⚠️ **Skipped (${results.skipped.length}):**\n`; - results.skipped.forEach(result => { - description += `• ${result.user} - ${result.reason}\n`; - }); - description += '\n'; - } - - if (results.failed.length > 0) { - description += `❌ **Failed (${results.failed.length}):**\n`; - results.failed.forEach(result => { - description += `• ${result.userId} - ${result.reason}\n`; - }); - } - - const embed = results.successful.length > 0 ? successEmbed : warningEmbed; - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - embed( - `👢 Mass Kick Completed`, - description - ) - ] - }); - - } catch (error) { - logger.error("Error in masskick command:", error); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "System Error", - "An error occurred while processing the mass kick. Please try again later." - ), - ], - }); - } - } -}; - - - diff --git a/src/commands/Moderation/purge.js b/src/commands/Moderation/purge.js deleted file mode 100644 index 1420ea75fc..0000000000 --- a/src/commands/Moderation/purge.js +++ /dev/null @@ -1,135 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logEvent } from '../../utils/moderation.js'; -import { logger } from '../../utils/logger.js'; -import { checkRateLimit } from '../../utils/rateLimiter.js'; -import { getColor } from '../../config/bot.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("purge") - .setDescription("Delete a specific amount of messages") - .addIntegerOption((option) => - option - .setName("amount") - .setDescription("Number of messages (1-100)") - .setRequired(true), - ) -.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages), - category: "moderation", - - async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Purge interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'purge' - }); - return; - } - - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageMessages)) - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Permission Denied", - "You need the `Manage Messages` permission to purge messages.", - ), - ], - }); - - const amount = interaction.options.getInteger("amount"); - const channel = interaction.channel; - - if (amount < 1 || amount > 100) - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Invalid Amount", - "Please specify a number between 1 and 100.", - ), - ], - }); - - try { - - const rateLimitKey = `purge_${interaction.user.id}`; - const isAllowed = await checkRateLimit(rateLimitKey, 5, 60000); - if (!isAllowed) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - warningEmbed( - "You're purging messages too fast. Please wait a minute before trying again.", - "⏳ Rate Limited" - ), - ], - flags: MessageFlags.Ephemeral, - }); - } - - const fetched = await channel.messages.fetch({ limit: amount }); - const deleted = await channel.bulkDelete(fetched, true); - const deletedCount = deleted.size; - - const purgeEmbed = createEmbed( - "🗑️ Messages Purged (Action Log)", - `${deletedCount} messages were deleted by ${interaction.user}.`, - ) -.setColor(getColor('moderation')) - .addFields( - { name: "Channel", value: channel.toString(), inline: true }, - { - name: "Moderator", - value: `${interaction.user.tag} (${interaction.user.id})`, - inline: true, - }, - { name: "Count", value: `${deletedCount} messages`, inline: false }, - ); - - await logEvent({ - client, - guild: interaction.guild, - event: { - action: "Messages Purged", - target: `${channel} (${deletedCount} messages)`, - executor: `${interaction.user.tag} (${interaction.user.id})`, - reason: `Deleted ${deletedCount} messages`, - metadata: { - channelId: channel.id, - messageCount: deletedCount, - requestedAmount: amount, - moderatorId: interaction.user.id - } - } - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed(`🗑️ Deleted ${deletedCount} messages in ${channel}.`), - ], -flags: MessageFlags.Ephemeral, - }); - - setTimeout(() => { - interaction.deleteReply().catch(err => - logger.debug('Failed to auto-delete purge response:', err) - ); - }, 3000); - } catch (error) { - logger.error('Purge command error:', error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "An unexpected error occurred during message deletion. Note: Messages older than 14 days cannot be bulk deleted.", - ), - ], - flags: MessageFlags.Ephemeral, - }); - } - } -}; - - - diff --git a/src/commands/Moderation/timeout.js b/src/commands/Moderation/timeout.js deleted file mode 100644 index 04f205a3f4..0000000000 --- a/src/commands/Moderation/timeout.js +++ /dev/null @@ -1,144 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logModerationAction } from '../../utils/moderation.js'; -import { logger } from '../../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; - - -import { InteractionHelper } from '../../utils/interactionHelper.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 }, -]; -export default { - data: new SlashCommandBuilder() - .setName("timeout") - .setDescription("Timeout a user for a specific duration.") - .addUserOption((option) => - option - .setName("target") - .setDescription("User to timeout") - .setRequired(true), - ) - .addIntegerOption( - (option) => - option - .setName("duration") - .setDescription("Duration of the timeout") - .setRequired(true) -.addChoices(...durationChoices), - ) - .addStringOption((option) => - option.setName("reason").setDescription("Reason for the timeout"), - ) -.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), - category: "moderation", - - async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Timeout interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'timeout' - }); - return; - } - - try { - if (!interaction.member.permissions.has(PermissionFlagsBits.ModerateMembers)) { - throw new TitanBotError( - "User lacks permission", - ErrorTypes.PERMISSION, - "You need the `Moderate Members` permission to set a timeout." - ); - } - - const targetUser = interaction.options.getUser("target"); - const member = interaction.options.getMember("target"); - const durationMinutes = interaction.options.getInteger("duration"); - const reason = interaction.options.getString("reason") || "No reason provided"; - - if (targetUser.id === interaction.user.id) { - throw new TitanBotError( - "Cannot timeout self", - ErrorTypes.VALIDATION, - "You cannot timeout yourself." - ); - } - if (targetUser.id === client.user.id) { - throw new TitanBotError( - "Cannot timeout bot", - ErrorTypes.VALIDATION, - "You cannot timeout the bot." - ); - } - if (!member) { - throw new TitanBotError( - "Target not found", - ErrorTypes.USER_INPUT, - "The target user is not currently in this server." - ); - } - - if (!member.moderatable) { - throw new TitanBotError( - "Cannot timeout member", - ErrorTypes.PERMISSION, - "I cannot timeout this user. They might have a higher role than me or you." - ); - } - - const durationMs = durationMinutes * 60 * 1000; - await member.timeout(durationMs, reason); - - const durationDisplay = - durationChoices.find((c) => c.value === durationMinutes) - ?.name || `${durationMinutes} minutes`; - - const caseId = await logModerationAction({ - client, - guild: interaction.guild, - event: { - action: "Member Timed Out", - target: `${targetUser.tag} (${targetUser.id})`, - executor: `${interaction.user.tag} (${interaction.user.id})`, - reason: `${reason}\nDuration: ${durationDisplay}`, - duration: durationDisplay, - metadata: { - userId: targetUser.id, - moderatorId: interaction.user.id, - durationMinutes, - timeoutEnds: new Date(Date.now() + durationMs).toISOString() - } - } - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - `⏳ **Timed out** ${targetUser.tag} for ${durationDisplay}.`, - `**Reason:** ${reason}\n**Case ID:** #${caseId}`, - ), - ], - }); - } catch (error) { - logger.error('Timeout command error:', error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - error.userMessage || "An unexpected error occurred during the timeout action. Please check my role permissions.", - ), - ], - }); - } - } -}; - - - diff --git a/src/commands/Moderation/unban.js b/src/commands/Moderation/unban.js deleted file mode 100644 index d321969451..0000000000 --- a/src/commands/Moderation/unban.js +++ /dev/null @@ -1,65 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits } 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 { ModerationService } from '../../services/moderationService.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("unban") - .setDescription("Unban a user from the server") - .addUserOption(option => - option - .setName("target") - .setDescription("The user to unban (can be ID or mention)") - .setRequired(true) - ) - .addStringOption(option => - option.setName("reason") - .setDescription("Reason for the unban") - .setRequired(false) - ) - .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers), - category: "moderation", - - async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Unban interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'unban' - }); - return; - } - - try { - const targetUser = interaction.options.getUser("target"); - const reason = interaction.options.getString("reason") || "No reason provided"; - - - const result = await ModerationService.unbanUser({ - guild: interaction.guild, - user: targetUser, - moderator: interaction.member, - reason - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "✅ User Unbanned", - `Successfully unbanned **${targetUser.tag}** from the server.\n\n**Reason:** ${reason}\n**Case ID:** #${result.caseId}` - ) - ] - }); - } catch (error) { - logger.error('Unban command error:', error); - await handleInteractionError(interaction, error, { subtype: 'unban_failed' }); - } - } -}; - - - diff --git a/src/commands/Moderation/unlock.js b/src/commands/Moderation/unlock.js deleted file mode 100644 index 876dc3e563..0000000000 --- a/src/commands/Moderation/unlock.js +++ /dev/null @@ -1,126 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; -import { createEmbed, errorEmbed, 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'; -export default { - data: new SlashCommandBuilder() - .setName("unlock") - .setDescription( - "Unlocks the current channel (allows @everyone to send messages again).", - ) -.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), - category: "moderation", - - async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Unlock interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'unlock' - }); - return; - } - - if ( - !interaction.member.permissions.has( - PermissionFlagsBits.ManageChannels, - ) - ) - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Permission Denied", - "You need the `Manage Channels` permission to unlock channels.", - ), - ], - }); - - const channel = interaction.channel; - const everyoneRole = interaction.guild.roles.everyone; - - try { - const currentPermissions = channel.permissionsFor(everyoneRole); - if ( - currentPermissions.has(PermissionFlagsBits.SendMessages) === - true || - currentPermissions.has(PermissionFlagsBits.SendMessages) === - null - ) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Channel Already Unlocked", - `${channel} is not explicitly locked (everyone can already send messages).`, - ), - ], - }); - } - - await channel.permissionOverwrites.edit( - everyoneRole, - { SendMessages: true }, - { - type: 0, - reason: `Channel unlocked by ${interaction.user.tag}`, -}, - ); - - const unlockEmbed = createEmbed( - "🔓 Channel Unlocked (Action Log)", - `${channel} has been unlocked by ${interaction.user}.`, - ) -.setColor(getColor('success')) - .addFields( - { - name: "Channel", - value: channel.toString(), - inline: true, - }, - { - name: "Moderator", - value: `${interaction.user.tag} (${interaction.user.id})`, - inline: true, - }, - ); - - await logEvent({ - client, - guild: interaction.guild, - event: { - action: "Channel Unlocked", - target: channel.toString(), - executor: `${interaction.user.tag} (${interaction.user.id})`, - metadata: { - channelId: channel.id, - category: channel.parent?.name || 'None' - } - } - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - `🔓 **Channel Unlocked**`, - `${channel} is now unlocked. You may speak now.`, - ), - ], - }); - } catch (error) { - logger.error('Unlock command error:', error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "An unexpected error occurred while trying to unlock the channel. Check my permissions (I need 'Manage Channels').", - ), - ], - }); - } - } -}; - - - diff --git a/src/commands/Moderation/untimeout.js b/src/commands/Moderation/untimeout.js deleted file mode 100644 index a55882953e..0000000000 --- a/src/commands/Moderation/untimeout.js +++ /dev/null @@ -1,58 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logEvent } from '../../utils/moderation.js'; -import { logger } from '../../utils/logger.js'; -import { ModerationService } from '../../services/moderationService.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("untimeout") - .setDescription("Remove timeout from a user") - .addUserOption((option) => - option - .setName("target") - .setDescription("User to untimeout") - .setRequired(true), - ) -.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), - category: "moderation", - - async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Untimeout interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'untimeout' - }); - return; - } - - try { - const targetUser = interaction.options.getUser("target"); - const member = interaction.options.getMember("target"); - - - const result = await ModerationService.removeTimeoutUser({ - guild: interaction.guild, - member, - moderator: interaction.member - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - `🔓 **Removed timeout** from ${targetUser.tag}`, - ), - ], - }); - } catch (error) { - logger.error('Untimeout command error:', error); - await handleInteractionError(interaction, error, { subtype: 'untimeout_failed' }); - } - } -}; - - - diff --git a/src/commands/Moderation/usernotes.js b/src/commands/Moderation/usernotes.js deleted file mode 100644 index c1db5f7cca..0000000000 --- a/src/commands/Moderation/usernotes.js +++ /dev/null @@ -1,343 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { getFromDb, setInDb, deleteFromDb } from '../../utils/database.js'; -import { sanitizeInput } from '../../utils/sanitization.js'; - - - - - - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -function getUserNotesKey(guildId, userId) { - return `moderation_user_notes_${guildId}_${userId}`; -} - - - - - - -function getGuildNotesListKey(guildId) { - return `moderation_user_notes_list_${guildId}`; -} - -export default { - data: new SlashCommandBuilder() - .setName("usernotes") - .setDescription("Manage user notes for moderation purposes") - .addSubcommand(subcommand => - subcommand - .setName("add") - .setDescription("Add a note to a user") - .addUserOption(option => - option - .setName("target") - .setDescription("The user to add a note for") - .setRequired(true) - ) - .addStringOption(option => - option - .setName("note") - .setDescription("The note to add") - .setRequired(true) - ) - .addStringOption(option => - option - .setName("type") - .setDescription("Type of note") - .addChoices( - { name: "Warning", value: "warning" }, - { name: "Positive", value: "positive" }, - { name: "Neutral", value: "neutral" }, - { name: "Alert", value: "alert" } - ) - .setRequired(false) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName("view") - .setDescription("View notes for a user") - .addUserOption(option => - option - .setName("target") - .setDescription("The user to view notes for") - .setRequired(true) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName("remove") - .setDescription("Remove a specific note from a user") - .addUserOption(option => - option - .setName("target") - .setDescription("The user to remove a note from") - .setRequired(true) - ) - .addIntegerOption(option => - option - .setName("index") - .setDescription("The index of the note to remove") - .setRequired(true) - .setMinValue(1) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName("clear") - .setDescription("Clear all notes for a user") - .addUserOption(option => - option - .setName("target") - .setDescription("The user to clear notes for") - .setRequired(true) - ) - ) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages), - category: "moderation", - - async execute(interaction, config, client) { - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageMessages)) { - return InteractionHelper.safeReply(interaction, { - embeds: [ - errorEmbed( - "Permission Denied", - "You do not have permission to manage user notes." - ), - ], - }); - } - - const subcommand = interaction.options.getSubcommand(); - const targetUser = interaction.options.getUser("target"); - const guildId = interaction.guild.id; - - if (subcommand !== "view" && subcommand !== "remove" && subcommand !== "clear" && subcommand !== "add") { - return InteractionHelper.safeReply(interaction, { - embeds: [ - errorEmbed( - "Invalid Subcommand", - "Please select a valid subcommand." - ), - ], - }); - } - - let notes = []; - if (targetUser) { - const notesKey = getUserNotesKey(guildId, targetUser.id); - notes = await getFromDb(notesKey, []); - } - - try { - switch (subcommand) { - case "add": - return await handleAddNote(interaction, targetUser, notes, guildId); - case "view": - return await handleViewNotes(interaction, targetUser, notes); - case "remove": - return await handleRemoveNote(interaction, targetUser, notes, guildId); - case "clear": - return await handleClearNotes(interaction, targetUser, notes, guildId); - default: - return InteractionHelper.safeReply(interaction, { - embeds: [ - errorEmbed( - "Invalid Subcommand", - "Please select a valid subcommand." - ), - ], - }); - } - } catch (error) { - logger.error(`Error in usernotes command (${subcommand}):`, error); - return InteractionHelper.safeReply(interaction, { - embeds: [ - errorEmbed( - "System Error", - "An error occurred while processing your request. Please try again later." - ), - ], - flags: MessageFlags.Ephemeral - }); - } - } -}; - -async function handleAddNote(interaction, targetUser, notes, guildId) { - let note = interaction.options.getString("note").trim(); - const type = interaction.options.getString("type") || "neutral"; - - if (note.length > 1000) { - return InteractionHelper.safeReply(interaction, { - embeds: [ - errorEmbed( - "Note Too Long", - "Notes must be 1000 characters or less." - ), - ], - }); - } - - if (note.length === 0) { - return InteractionHelper.safeReply(interaction, { - embeds: [ - errorEmbed( - "Empty Note", - "Note cannot be empty." - ), - ], - }); - } - - - note = sanitizeInput(note); - - const noteData = { - id: Date.now(), - content: note, - type: type, - author: interaction.user.tag, - authorId: interaction.user.id, - timestamp: new Date().toISOString() - }; - - notes.push(noteData); - - const notesKey = getUserNotesKey(guildId, targetUser.id); - await setInDb(notesKey, notes); - - const typeInfo = getNoteTypeInfo(type); - - return InteractionHelper.safeReply(interaction, { - embeds: [ - successEmbed( - `${typeInfo.emoji} Note Added`, - `Added a **${type}** note for **${targetUser.tag}**:\n\n` + - `> ${note}\n\n` + - `**Moderator:** ${interaction.user.tag}\n` + - `**Total Notes:** ${notes.length}` - ) - ] - }); -} - -async function handleViewNotes(interaction, targetUser, notes) { - if (notes.length === 0) { - return InteractionHelper.safeReply(interaction, { - embeds: [ - infoEmbed( - "📝 No Notes", - `There are no notes for **${targetUser.tag}**.` - ), - ], - }); - } - - const sortedNotes = [...notes].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); - - let description = `**Notes for ${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 += `> ${note.content}\n`; - description += `*Added by ${note.author}*\n\n`; - }); - - if (description.length > 4000) { - description = description.substring(0, 3900) + "\n... *(truncated)*"; - } - - return InteractionHelper.safeReply(interaction, { - embeds: [ - infoEmbed( - `📝 User Notes (${notes.length})`, - description - ) - ] - }); -} - -async function handleRemoveNote(interaction, targetUser, notes, guildId) { -const index = interaction.options.getInteger("index") - 1; - - if (index < 0 || index >= notes.length) { - return InteractionHelper.safeReply(interaction, { - embeds: [ - errorEmbed( - "Invalid Index", - `Please provide a valid note index (1-${notes.length}).` - ), - ], - }); - } - - const removedNote = notes[index]; - notes.splice(index, 1); - - const notesKey = getUserNotesKey(guildId, targetUser.id); - await setInDb(notesKey, notes); - - const typeInfo = getNoteTypeInfo(removedNote.type); - - return InteractionHelper.safeReply(interaction, { - embeds: [ - successEmbed( - `${typeInfo.emoji} Note Removed`, - `Removed note #${index + 1} from **${targetUser.tag}**:\n\n` + - `> ${removedNote.content}\n\n` + - `**Remaining Notes:** ${notes.length}` - ) - ] - }); -} - -async function handleClearNotes(interaction, targetUser, notes, guildId) { - const noteCount = notes.length; - - if (noteCount === 0) { - return InteractionHelper.safeReply(interaction, { - embeds: [ - infoEmbed( - "No Notes to Clear", - `There are no notes for **${targetUser.tag}** to clear.` - ), - ], - }); - } - - notes.length = 0; - - const notesKey = getUserNotesKey(guildId, targetUser.id); - await setInDb(notesKey, notes); - - return InteractionHelper.safeReply(interaction, { - embeds: [ - successEmbed( - "🗑️ Notes Cleared", - `Cleared **${noteCount}** notes from **${targetUser.tag}**.` - ) - ] - }); -} - -function getNoteTypeInfo(type) { - const types = { - warning: { emoji: "⚠️", color: "#FF6B6B" }, - positive: { emoji: "✅", color: "#51CF66" }, - neutral: { emoji: "📝", color: "#74C0FC" }, - alert: { emoji: "🚨", color: "#FFD43B" } - }; - - return types[type] || types.neutral; -} - - - - - diff --git a/src/commands/Moderation/warn.js b/src/commands/Moderation/warn.js deleted file mode 100644 index 571214ece0..0000000000 --- a/src/commands/Moderation/warn.js +++ /dev/null @@ -1,102 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, MessageFlags } 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/warningService.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("warn") - .setDescription("Warn a user") - .addUserOption((o) => - o - .setName("target") - .setRequired(true) - .setDescription("User to warn"), - ) - .addStringOption((o) => - o - .setName("reason") - .setRequired(true) - .setDescription("Reason for the warning"), - ) - .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), - category: "moderation", - - async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Warn interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'warn' - }); - return; - } - - try { - if (!interaction.member.permissions.has(PermissionFlagsBits.ModerateMembers)) { - throw new Error("You need the `Moderate Members` permission to issue warnings."); - } - - const target = interaction.options.getUser("target"); - const member = interaction.options.getMember("target"); - const reason = interaction.options.getString("reason"); - const moderator = interaction.user; - const guildId = interaction.guildId; - - if (!member) { - throw new Error("The target user is not currently in this server."); - } - - - const result = await WarningService.addWarning({ - guildId, - userId: target.id, - moderatorId: moderator.id, - reason, - timestamp: Date.now() - }); - - if (!result.success) { - throw new Error("Failed to store warning in database"); - } - - const totalWarns = result.totalCount; - - await logModerationAction({ - client, - guild: interaction.guild, - event: { - action: "User Warned", - target: `${target.tag} (${target.id})`, - executor: `${moderator.tag} (${moderator.id})`, - reason, - metadata: { - userId: target.id, - moderatorId: moderator.id, - totalWarns, - warningNumber: totalWarns, - warningId: result.id - } - } - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - `⚠️ **Warned** ${target.tag}`, - `**Reason:** ${reason}\n**Total Warns:** ${totalWarns}`, - ), - ], - }); - } catch (error) { - logger.error('Warn command error:', error); - await handleInteractionError(interaction, error, { subtype: 'warn_failed' }); - } - } -}; - - - diff --git a/src/commands/Moderation/warnings.js b/src/commands/Moderation/warnings.js deleted file mode 100644 index 834bdd1da4..0000000000 --- a/src/commands/Moderation/warnings.js +++ /dev/null @@ -1,96 +0,0 @@ -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logEvent } from '../../utils/moderation.js'; -import { logger } from '../../utils/logger.js'; -import { WarningService } from '../../services/warningService.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("warnings") - .setDescription("View all warnings for a user") - .addUserOption((o) => - o - .setName("target") - .setRequired(true) - .setDescription("User to check warnings for"), - ) - .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), - category: "moderation", - - async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Warnings interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'warnings' - }); - return; - } - - try { - const target = interaction.options.getUser("target"); - const guildId = interaction.guildId; - - - const validWarnings = await WarningService.getWarnings(guildId, target.id); - const totalWarns = validWarnings.length; - - if (totalWarns === 0) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - createEmbed({ - title: `Warnings: ${target.tag}`, - description: "✅ This user has no recorded warnings." - }).setColor(getColor('success')), - ], - }); - return; - } - - const embed = createEmbed({ - title: `Warnings: ${target.tag}`, - description: `Total Warnings: **${totalWarns}**` - }).setColor(getColor('warning')); - - const warningFields = validWarnings - .map((w, i) => { - const discordTimestamp = Math.floor(w.timestamp / 1000); - return { - name: `[#${i + 1}] Reason: ${w.reason.substring(0, 100)}`, - value: `**Moderator:** <@${w.moderatorId}>\n**Date:** ()`, - inline: false, - }; - }) - .slice(0, 25); - - embed.addFields(warningFields); - - await logEvent({ - client, - guild: interaction.guild, - event: { - action: "Warnings Viewed", - target: `${target.tag} (${target.id})`, - executor: `${interaction.user.tag} (${interaction.user.id})`, - reason: `Viewed ${totalWarns} warnings`, - metadata: { - userId: target.id, - moderatorId: interaction.user.id, - totalWarnings: totalWarns - } - } - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } catch (error) { - logger.error('Warnings command error:', error); - await handleInteractionError(interaction, error, { subtype: 'warnings_view_failed' }); - } - } -}; - - - diff --git a/src/commands/Reaction_roles/reactroles.js b/src/commands/Reaction_roles/reactroles.js deleted file mode 100644 index bc762be8a5..0000000000 --- a/src/commands/Reaction_roles/reactroles.js +++ /dev/null @@ -1,1099 +0,0 @@ -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, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, createError, TitanBotError, ErrorTypes } 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'; - -export default { - data: new SlashCommandBuilder() - .setName('reactroles') - .setDescription('Manage reaction role assignments') - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) - .addSubcommand(subcommand => - subcommand - .setName('setup') - .setDescription('Set up a new reaction role panel') - .addChannelOption(option => - option.setName('channel') - .setDescription('The channel to send the reaction role message to') - .setRequired(true) - ) - .addStringOption(option => - option.setName('title') - .setDescription('Title for the reaction role panel') - .setRequired(true) - ) - .addStringOption(option => - option.setName('description') - .setDescription('Description for the reaction role panel') - .setRequired(true) - ) - .addRoleOption(option => - option.setName('role1') - .setDescription('First role to add') - .setRequired(true) - ) - .addRoleOption(option => - option.setName('role2') - .setDescription('Second role to add') - .setRequired(false) - ) - .addRoleOption(option => - option.setName('role3') - .setDescription('Third role to add') - .setRequired(false) - ) - .addRoleOption(option => - option.setName('role4') - .setDescription('Fourth role to add') - .setRequired(false) - ) - .addRoleOption(option => - option.setName('role5') - .setDescription('Fifth role to add') - .setRequired(false) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName('dashboard') - .setDescription('Manage and configure your reaction role panels') - .addStringOption(option => - option - .setName('panel') - .setDescription('Select a reaction role panel to manage') - .setRequired(false) - .setAutocomplete(true) - ) - ), - - async execute(interaction) { - const subcommand = interaction.options.getSubcommand(); - - try { - if (subcommand === 'setup') { - await handleSetup(interaction); - } else if (subcommand === 'dashboard') { - const selectedPanelId = interaction.options.getString('panel'); - await handleDashboard(interaction, selectedPanelId); - } - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'reactroles', - subcommand: subcommand - }); - } - }, - - async autocomplete(interaction) { - if (interaction.commandName !== 'reactroles') return; - if (interaction.options.getSubcommand() !== 'dashboard') return; - - try { - const guildId = interaction.guild.id; - const client = interaction.client; - - let panels; - try { - panels = await getAllReactionRoleMessages(client, guildId); - } catch (dbError) { - // If database query fails, just respond with empty - await interaction.respond([]).catch(() => {}); - return; - } - - if (!panels || panels.length === 0) { - await interaction.respond([]).catch(() => {}); - return; - } - - const guild = interaction.guild; - - // Filter out panels whose messages no longer exist and clean up stale data - const validPanels = []; - for (const panel of panels) { - // Validate panel structure - if (!panel.messageId || !panel.channelId) { - continue; - } - - const channel = guild.channels.cache.get(panel.channelId); - if (!channel) { - await deleteReactionRoleMessage(client, guildId, panel.messageId).catch(() => {}); - continue; - } - - const msg = await channel.messages.fetch(panel.messageId).catch(() => null); - if (!msg) { - await deleteReactionRoleMessage(client, guildId, panel.messageId).catch(() => {}); - continue; - } - validPanels.push(panel); - } - - if (validPanels.length === 0) { - await interaction.respond([]).catch(() => {}); - return; - } - - const choices = await Promise.all( - validPanels.slice(0, 25).map(async panel => { - try { - const channel = guild.channels.cache.get(panel.channelId); - if (!channel) return null; - - const msg = await channel.messages.fetch(panel.messageId).catch(() => null); - if (!msg) return null; - - const title = msg?.embeds?.[0]?.title ?? 'Untitled Panel'; - const channelName = channel?.name ?? 'unknown'; - - return { - name: `${title} (${channelName})`.substring(0, 100), - value: panel.messageId - }; - } catch (e) { - return null; - } - }) - ); - - const validChoices = choices.filter(c => c !== null); - await interaction.respond(validChoices).catch(() => {}); - } catch (error) { - await interaction.respond([]).catch(() => {}); - } - } -}; - -// ─── Setup Subcommand ───────────────────────────────────────────────────────── - -async function handleSetup(interaction) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) return; - - 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'); - - // Validate channel type - if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) { - throw createError( - `Invalid channel type: ${channel.type}`, - ErrorTypes.VALIDATION, - 'Please select a text or announcement channel.', - { channelType: channel.type } - ); - } - - // Check bot permissions - if (!interaction.guild.members.me.permissions.has(PermissionFlagsBits.ManageRoles)) { - throw createError( - 'Bot missing ManageRoles permission', - ErrorTypes.PERMISSION, - 'I need the "Manage Roles" permission to set up reaction roles.', - { permission: 'ManageRoles' } - ); - } - - if (!channel.permissionsFor(interaction.guild.members.me).has(PermissionFlagsBits.SendMessages)) { - throw createError( - `Bot cannot send messages in ${channel.name}`, - ErrorTypes.PERMISSION, - `I don't have permission to send messages in ${channel}.`, - { channelId: channel.id } - ); - } - - // Check if guild has reached max of 5 panels - const existingPanels = await getAllReactionRoleMessages(interaction.client, interaction.guildId); - if (existingPanels && existingPanels.length >= 5) { - 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.', - { maxPanels: 5, currentPanels: existingPanels.length } - ); - } - - // Collect and validate roles - const roles = []; - const roleValidationErrors = []; - - for (let i = 1; i <= 5; i++) { - const role = interaction.options.getRole(`role${i}`); - if (role) { - 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; - } - - if (hasDangerousPermissions(role)) { - roleValidationErrors.push(`**${role.name}** - This role has dangerous permissions (Administrator, Manage Server, etc.)`); - continue; - } - - if (role.managed) { - roleValidationErrors.push(`**${role.name}** - This is a managed role (integration/bot role)`); - continue; - } - - if (role.id === interaction.guild.id) { - roleValidationErrors.push(`**${role.name}** - Cannot use the @everyone role`); - continue; - } - - roles.push(role); - } - } - - if (roleValidationErrors.length > 0) { - const errorMsg = `The following roles cannot be added:\n${roleValidationErrors.join('\n')}`; - - if (roles.length === 0) { - throw createError( - 'No valid roles provided', - ErrorTypes.VALIDATION, - errorMsg, - { errors: roleValidationErrors } - ); - } - - await interaction.followUp({ - embeds: [warningEmbed('Role Validation Warning', errorMsg)], - ephemeral: true - }); - } - - if (roles.length < 1) { - throw createError( - 'No roles provided', - ErrorTypes.VALIDATION, - 'You must provide at least one valid role.', - {} - ); - } - - // Create the reaction role message - const row = new ActionRowBuilder().addComponents( - new StringSelectMenuBuilder() - .setCustomId('reaction_roles') - .setPlaceholder('Select your roles') - .setMinValues(0) - .setMaxValues(roles.length) - .addOptions( - roles.map(role => ({ - label: role.name, - description: `Add/remove the ${role.name} role`, - value: role.id, - emoji: '🎭' - })) - ) - ); - - const panelEmbed = new EmbedBuilder() - .setTitle(title) - .setDescription(description) - .setColor(getColor('info')) - .addFields({ - name: 'Available Roles', - value: roles.map(role => `• ${role}`).join('\n') - }) - .setFooter({ text: 'Select roles from the dropdown menu below' }); - - const message = await channel.send({ - embeds: [panelEmbed], - components: [row] - }); - - const roleIds = roles.map(role => role.id); - await createReactionRoleMessage( - interaction.client, - interaction.guildId, - channel.id, - message.id, - roleIds - ); - - logger.info(`Reaction role message created: ${message.id} with ${roles.length} roles by ${interaction.user.tag}`); - - try { - await logEvent({ - client: interaction.client, - guildId: interaction.guildId, - eventType: EVENT_TYPES.REACTION_ROLE_CREATE, - data: { - description: `Reaction role panel created by ${interaction.user.tag}`, - userId: interaction.user.id, - channelId: channel.id, - fields: [ - { - name: '📝 Title', - value: title, - inline: false - }, - { - name: '📍 Channel', - value: channel.toString(), - inline: true - }, - { - name: '📊 Roles', - value: `${roles.length} roles`, - inline: true - }, - { - name: '🏷️ Role List', - value: roles.map(r => r.toString()).join(', '), - inline: false - }, - { - name: '🔗 Message Link', - value: message.url, - inline: false - } - ] - } - }); - } catch (logError) { - logger.warn('Failed to log reaction role creation:', logError); - } - - await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed('Success', `✅ Reaction role panel created in ${channel}!\n\n${message.url}`)] - }); -} - -// ─── Dashboard Subcommand ───────────────────────────────────────────────────── - -async function handleDashboard(interaction, selectedPanelId) { - const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: ['Ephemeral'] }); - if (!deferSuccess) return; - - const guildId = interaction.guild.id; - const guild = interaction.guild; - const client = interaction.client; - - let panels = await getAllReactionRoleMessages(client, guildId); - - if (!panels || panels.length === 0) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - 'No Panels Found', - 'No reaction role panels exist yet. Use `/reactroles setup` to create one.', - ), - ], - }); - } - - // Filter out panels whose messages no longer exist - const validPanels = []; - for (const panel of panels) { - const channel = guild.channels.cache.get(panel.channelId); - if (!channel) { - await deleteReactionRoleMessage(client, guildId, panel.messageId).catch(() => {}); - continue; - } - - const msg = await channel.messages.fetch(panel.messageId).catch(() => null); - if (!msg) { - await deleteReactionRoleMessage(client, guildId, panel.messageId).catch(() => {}); - continue; - } - validPanels.push(panel); - } - - if (validPanels.length === 0) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - 'No Valid Panels Found', - 'No reaction role panels exist yet. Use `/reactroles setup` to create one.', - ), - ], - }); - } - - // If a panel was selected, use it. Otherwise, pick a random one. - let activePanelData = null; - if (selectedPanelId) { - activePanelData = validPanels.find(p => p.messageId === selectedPanelId); - if (!activePanelData) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - 'Panel Not Found', - 'That panel no longer exists or has been deleted.', - ), - ], - }); - } - } else { - // Pick a random panel from valid panels - activePanelData = validPanels[Math.floor(Math.random() * validPanels.length)]; - } - - const discordMsg = await fetchPanelDiscordMessage(guild, activePanelData); - await showPanelDashboard(interaction, activePanelData, discordMsg, guildId, guild); - - let rootInteraction = interaction; - const collector = interaction.channel.createMessageComponentCollector({ - filter: i => - i.user.id === interaction.user.id && - (i.customId === `rr_opts_${guildId}`), - time: 600_000, - }); - - const buttonCollector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.Button, - filter: i => - i.user.id === interaction.user.id && - (i.customId === `rr_edit_text_${guildId}` || - i.customId === `rr_delete_${guildId}`), - time: 600_000, - }); - - collector.on('collect', async ci => { - try { - if (ci.customId === `rr_opts_${guildId}`) { - const option = ci.values[0]; - switch (option) { - case 'add_role': - await handleAddRole(ci, rootInteraction, activePanelData, guildId, guild, client); - break; - case 'remove_role': - await handleRemoveRole(ci, rootInteraction, activePanelData, validPanels, guildId, guild, client); - break; - } - } - } catch (error) { - logger.error('Error in reactroles dashboard collector:', error); - const msg = - error instanceof TitanBotError - ? error.userMessage || 'An error occurred.' - : 'An unexpected error occurred.'; - if (!ci.replied && !ci.deferred) await ci.deferUpdate().catch(() => {}); - await ci - .followUp({ embeds: [errorEmbed('Error', msg)], flags: MessageFlags.Ephemeral }) - .catch(() => {}); - } - }); - - buttonCollector.on('collect', async btnInteraction => { - try { - if (btnInteraction.customId === `rr_edit_text_${guildId}`) { - await handleEditText(btnInteraction, rootInteraction, activePanelData, guildId, guild, client); - } else if (btnInteraction.customId === `rr_delete_${guildId}`) { - await handleDeletePanel(btnInteraction, rootInteraction, activePanelData, validPanels, guildId, guild, client, collector, buttonCollector); - } - } catch (error) { - logger.error('Error in reactroles button collector:', error); - const msg = - error instanceof TitanBotError - ? error.userMessage || 'An error occurred.' - : 'An unexpected error occurred.'; - if (!btnInteraction.replied && !btnInteraction.deferred) await btnInteraction.deferUpdate().catch(() => {}); - await btnInteraction - .followUp({ embeds: [errorEmbed('Error', msg)], flags: MessageFlags.Ephemeral }) - .catch(() => {}); - } - }); - - collector.on('end', async (_, reason) => { - buttonCollector.stop(); - if (reason === 'time') { - const timeoutEmbed = new EmbedBuilder() - .setTitle('⏱️ Dashboard Timeout') - .setDescription('This dashboard session has timed out due to inactivity (10 minutes).\n\nTo continue managing your reaction roles, please run `/reactroles dashboard` again.') - .setColor(getColor('warning')); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [timeoutEmbed], - components: [] - }).catch(() => {}); - } - }); -} - -// ─── Discord Message Helpers ────────────────────────────────────────────────── - -async function fetchPanelDiscordMessage(guild, panelData) { - try { - const channel = guild.channels.cache.get(panelData.channelId); - if (!channel) return null; - return await channel.messages.fetch(panelData.messageId).catch(() => null); - } catch { - return null; - } -} - -async function rebuildLivePanelMessage(guild, panelData) { - try { - const channel = guild.channels.cache.get(panelData.channelId); - 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); - - if (roleObjects.length === 0) return; - - 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 newRoleValue = roleObjects.map(r => `• ${r}`).join('\n'); - if (roleFieldIdx !== -1) { - fields[roleFieldIdx] = { name: 'Available Roles', value: newRoleValue, inline: false }; - } else { - fields.push({ name: 'Available Roles', value: newRoleValue, inline: false }); - } - updatedEmbed.setFields(fields); - - const selectRow = new ActionRowBuilder().addComponents( - new StringSelectMenuBuilder() - .setCustomId('reaction_roles') - .setPlaceholder('Select your roles') - .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), - value: r.id, - emoji: '🎭', - })), - ), - ); - - await msg.edit({ embeds: [updatedEmbed], components: [selectRow] }); - } catch (error) { - logger.warn('Could not rebuild live reaction role panel:', error.message); - } -} - -// ─── View Builders ──────────────────────────────────────────────────────────── - -async function showPanelDashboard(interaction, panelData, discordMsg, guildId, guild) { - const channel = guild.channels.cache.get(panelData.channelId); - const title = discordMsg?.embeds?.[0]?.title ?? 'Untitled Panel'; - const roleList = - panelData.roles.length > 0 - ? panelData.roles.map(id => `<@&${id}>`).join(', ') - : '`None`'; - - const embed = new EmbedBuilder() - .setTitle('🎭 Reaction Roles Dashboard') - .setDescription( - `**Title:** ${title}\n\nSelect an option below to modify a setting.${discordMsg ? `\n[Click Here to View Panel](${discordMsg.url})` : ''}`, - ) - .setColor(getColor('info')) - .addFields( - { name: '📍 Channel', value: channel ? `<#${channel.id}>` : '`Not found`', inline: true }, - { name: '🎭 Roles', value: `\`${panelData.roles.length} / 25\``, inline: true }, - { name: '\u200B', value: '\u200B', inline: true }, - { name: '🏷️ Role List', value: roleList, inline: false }, - ) - .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) - .setTimestamp(); - - const editTextButton = new ButtonBuilder() - .setCustomId(`rr_edit_text_${guildId}`) - .setLabel('Edit Panel Text') - .setStyle(ButtonStyle.Primary) - .setEmoji('✏️'); - - const deleteButton = new ButtonBuilder() - .setCustomId(`rr_delete_${guildId}`) - .setLabel('Delete Panel') - .setStyle(ButtonStyle.Danger) - .setEmoji('🗑️'); - - const optionsSelect = new StringSelectMenuBuilder() - .setCustomId(`rr_opts_${guildId}`) - .setPlaceholder('Select an action...') - .addOptions( - new StringSelectMenuOptionBuilder() - .setLabel('Add Role') - .setDescription('Add a role to this panel (up to 25 total)') - .setValue('add_role') - .setEmoji('➕'), - ...(panelData.roles.length > 0 ? [ - new StringSelectMenuOptionBuilder() - .setLabel('Remove Role') - .setDescription('Remove a role from this panel') - .setValue('remove_role') - .setEmoji('➖') - ] : []) - ); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - components: [ - new ActionRowBuilder().addComponents(editTextButton, deleteButton), - new ActionRowBuilder().addComponents(optionsSelect), - ], - }); -} - -// ─── Edit Panel Text ────────────────────────────────────────────────────────── - -async function handleEditText(buttonInteraction, rootInteraction, panelData, guildId, guild, client) { - const channel = guild.channels.cache.get(panelData.channelId); - const discordMsg = channel - ? await channel.messages.fetch(panelData.messageId).catch(() => null) - : null; - - const currentTitle = discordMsg?.embeds?.[0]?.title ?? ''; - const currentDesc = discordMsg?.embeds?.[0]?.description ?? ''; - - const modal = new ModalBuilder() - .setCustomId('rr_edit_text') - .setTitle('Edit Panel Text') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('panel_title') - .setLabel('Title') - .setStyle(TextInputStyle.Short) - .setValue(currentTitle) - .setMaxLength(256) - .setMinLength(1) - .setRequired(true), - ), - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('panel_description') - .setLabel('Description') - .setStyle(TextInputStyle.Paragraph) - .setValue(currentDesc) - .setMaxLength(2048) - .setMinLength(1) - .setRequired(true), - ), - ); - - try { - await buttonInteraction.showModal(modal); - } catch (error) { - logger.error('Error showing edit text modal:', error); - await buttonInteraction.followUp({ - embeds: [errorEmbed('Error', 'Failed to show the edit panel text modal. Please try again.')], - flags: MessageFlags.Ephemeral, - }).catch(() => {}); - return; - } - - const submitted = await buttonInteraction - .awaitModalSubmit({ - filter: i => - i.customId === 'rr_edit_text' && i.user.id === buttonInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - const newTitle = submitted.fields.getTextInputValue('panel_title').trim(); - const newDesc = submitted.fields.getTextInputValue('panel_description').trim(); - - if (discordMsg) { - const updatedEmbed = EmbedBuilder.from(discordMsg.embeds[0]).setTitle(newTitle).setDescription(newDesc); - await discordMsg.edit({ embeds: [updatedEmbed] }).catch(err => { - logger.warn('Could not edit live panel message:', err.message); - }); - } - - await submitted.reply({ - embeds: [successEmbed('✅ Panel Updated', 'The title and description have been updated.')], - flags: MessageFlags.Ephemeral, - }); - - const refreshedMsg = channel - ? await channel.messages.fetch(panelData.messageId).catch(() => null) - : null; - await showPanelDashboard(rootInteraction, panelData, refreshedMsg, guildId, guild); -} - -// ─── Add Role ───────────────────────────────────────────────────────────────── - -async function handleAddRole(selectInteraction, rootInteraction, panelData, guildId, guild, client) { - await selectInteraction.deferUpdate(); - - if (panelData.roles.length >= 25) { - await selectInteraction.followUp({ - embeds: [errorEmbed('Panel Full', 'This panel already has the maximum of 25 roles.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - - const roleSelect = new RoleSelectMenuBuilder() - .setCustomId('rr_add_role_pick') - .setPlaceholder('Select a role to add...') - .setMaxValues(1); - - await selectInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('➕ Add Role') - .setDescription( - `**Current roles:** ${panelData.roles.length}/25\n\nSelect a role to add to this panel.`, - ) - .setColor(getColor('info')), - ], - components: [new ActionRowBuilder().addComponents(roleSelect)], - flags: MessageFlags.Ephemeral, - }); - - const roleCollector = rootInteraction.channel.createMessageComponentCollector({ - componentType: ComponentType.RoleSelect, - filter: i => - i.user.id === selectInteraction.user.id && i.customId === 'rr_add_role_pick', - time: 60_000, - max: 1, - }); - - roleCollector.on('collect', async roleInteraction => { - await roleInteraction.deferUpdate(); - const role = roleInteraction.roles.first(); - - if (panelData.roles.includes(role.id)) { - await roleInteraction.followUp({ - embeds: [errorEmbed('Already Added', `${role} is already in this panel.`)], - flags: MessageFlags.Ephemeral, - }); - return; - } - if (role.id === guild.id) { - await roleInteraction.followUp({ - embeds: [errorEmbed('Invalid Role', 'You cannot use @everyone.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - if (role.managed) { - await roleInteraction.followUp({ - embeds: [errorEmbed('Invalid Role', 'Managed/bot roles cannot be used.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - if (hasDangerousPermissions(role)) { - await roleInteraction.followUp({ - embeds: [ - errorEmbed( - 'Dangerous Permissions', - 'That role has sensitive permissions (Administrator, Manage Server, etc.) and cannot be used.', - ), - ], - flags: MessageFlags.Ephemeral, - }); - return; - } - if (role.position >= guild.members.me.roles.highest.position) { - await roleInteraction.followUp({ - embeds: [ - errorEmbed( - 'Role Too High', - "That role is above my highest role in the hierarchy. Move my role above it first.", - ), - ], - flags: MessageFlags.Ephemeral, - }); - return; - } - - panelData.roles.push(role.id); - const key = `reaction_roles:${guildId}:${panelData.messageId}`; - await client.db.set(key, panelData); - - await rebuildLivePanelMessage(guild, panelData); - - await roleInteraction.followUp({ - embeds: [successEmbed('✅ Role Added', `${role} has been added to the panel.`)], - flags: MessageFlags.Ephemeral, - }); - - const channel = guild.channels.cache.get(panelData.channelId); - const discordMsg = channel - ? await channel.messages.fetch(panelData.messageId).catch(() => null) - : null; - await showPanelDashboard(rootInteraction, panelData, discordMsg, guildId, guild); - }); - - roleCollector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - selectInteraction - .followUp({ - embeds: [errorEmbed('Timed Out', 'No role selected. Nothing was changed.')], - flags: MessageFlags.Ephemeral, - }) - .catch(() => {}); - } - }); -} - -// ─── Remove Role ────────────────────────────────────────────────────────────── - -async function handleRemoveRole(selectInteraction, rootInteraction, panelData, panels, guildId, guild, client) { - await selectInteraction.deferUpdate(); - - const roleOptions = panelData.roles - .map(id => { - const role = guild.roles.cache.get(id); - return role ? { label: role.name.substring(0, 100), value: id } : null; - }) - .filter(Boolean); - - if (roleOptions.length === 0) { - await selectInteraction.followUp({ - embeds: [errorEmbed('No Valid Roles', 'The roles on this panel no longer exist in the server.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - - const removeSelect = new StringSelectMenuBuilder() - .setCustomId('rr_remove_role_pick') - .setPlaceholder('Select a role to remove...') - .setMaxValues(1) - .addOptions( - roleOptions.map(r => - new StringSelectMenuOptionBuilder().setLabel(r.label).setValue(r.value).setEmoji('🎭'), - ), - ); - - await selectInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('➖ Remove Role') - .setDescription('Select the role you want to remove from this panel.') - .setColor(getColor('info')), - ], - components: [new ActionRowBuilder().addComponents(removeSelect)], - flags: MessageFlags.Ephemeral, - }); - - const removeCollector = rootInteraction.channel.createMessageComponentCollector({ - componentType: ComponentType.StringSelect, - filter: i => - i.user.id === selectInteraction.user.id && i.customId === 'rr_remove_role_pick', - time: 60_000, - max: 1, - }); - - removeCollector.on('collect', async removeInteraction => { - await removeInteraction.deferUpdate(); - const roleId = removeInteraction.values[0]; - const role = guild.roles.cache.get(roleId); - - panelData.roles = panelData.roles.filter(id => id !== roleId); - - if (panelData.roles.length === 0) { - const channel = guild.channels.cache.get(panelData.channelId); - if (channel) { - const msg = await channel.messages.fetch(panelData.messageId).catch(() => null); - if (msg) await msg.delete().catch(() => {}); - } - await deleteReactionRoleMessage(client, guildId, panelData.messageId); - - await removeInteraction.followUp({ - embeds: [ - successEmbed( - '✅ Role Removed', - 'That was the last role on the panel. The panel has been deleted.', - ), - ], - flags: MessageFlags.Ephemeral, - }); - - // Remove the deleted panel from the array - const panelIndex = panels.findIndex(p => p.messageId === panelData.messageId); - if (panelIndex > -1) { - panels.splice(panelIndex, 1); - } - - if (panels.length === 0) { - await InteractionHelper.safeEditReply(rootInteraction, { - embeds: [ - new EmbedBuilder() - .setTitle('📋 Reaction Roles Dashboard') - .setDescription('No panels remain. Use `/reactroles setup` to create one.') - .setColor(getColor('info')), - ], - components: [], - }); - } else { - // Dashboard closed after last role removed - await InteractionHelper.safeEditReply(rootInteraction, { - embeds: [ - new EmbedBuilder() - .setTitle('📋 Reaction Roles Dashboard') - .setDescription('Panel deleted. Run `/reactroles dashboard` to manage another panel.') - .setColor(getColor('success')), - ], - components: [], - }); - } - } else { - const key = `reaction_roles:${guildId}:${panelData.messageId}`; - await client.db.set(key, panelData); - await rebuildLivePanelMessage(guild, panelData); - - await removeInteraction.followUp({ - embeds: [ - successEmbed( - '✅ Role Removed', - `${role ? role.toString() : `<@&${roleId}>`} has been removed from the panel.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - const channel = guild.channels.cache.get(panelData.channelId); - const discordMsg = channel - ? await channel.messages.fetch(panelData.messageId).catch(() => null) - : null; - await showPanelDashboard(rootInteraction, panelData, discordMsg, guildId, guild); - } - }); - - removeCollector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - selectInteraction - .followUp({ - embeds: [errorEmbed('Timed Out', 'No role selected. Nothing was changed.')], - flags: MessageFlags.Ephemeral, - }) - .catch(() => {}); - } - }); -} - -// ─── Delete Panel ───────────────────────────────────────────────────────────── - -async function handleDeletePanel(btnInteraction, rootInteraction, panelData, panels, guildId, guild, client, collector, buttonCollector) { - const channel = guild.channels.cache.get(panelData.channelId); - const discordMsg = channel - ? await channel.messages.fetch(panelData.messageId).catch(() => null) - : null; - const title = discordMsg?.embeds?.[0]?.title ?? 'this panel'; - - const deleteModal = new ModalBuilder() - .setCustomId('rr_delete_confirm_modal') - .setTitle('Delete Reaction Role Panel'); - - const deleteWarningText = new TextDisplayBuilder() - .setContent(`⚠️ You are about to permanently delete the panel **${title}**. This will remove the Discord message and all associated reaction role assignments.`); - - const deleteCheckbox = new CheckboxBuilder() - .setCustomId('delete_confirmation') - .setDefault(false); - - const deleteCheckboxLabel = new LabelBuilder() - .setLabel('I confirm — this cannot be undone') - .setCheckboxComponent(deleteCheckbox); - - deleteModal - .addTextDisplayComponents(deleteWarningText) - .addLabelComponents(deleteCheckboxLabel); - - await btnInteraction.showModal(deleteModal); - - const submitted = await btnInteraction - .awaitModalSubmit({ - filter: i => i.customId === 'rr_delete_confirm_modal' && i.user.id === btnInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) { - await showPanelDashboard(rootInteraction, panelData, discordMsg, guildId, guild); - return; - } - - const confirmed = submitted.fields.getCheckbox('delete_confirmation'); - - if (!confirmed) { - await submitted.reply({ - embeds: [errorEmbed('Not Confirmed', 'You must tick the confirmation checkbox to delete the panel.')], - flags: MessageFlags.Ephemeral, - }); - await showPanelDashboard(rootInteraction, panelData, discordMsg, guildId, guild); - return; - } - - await submitted.deferUpdate(); - - if (discordMsg) { - await discordMsg.delete().catch(() => {}); - } - await deleteReactionRoleMessage(client, guildId, panelData.messageId); - - try { - await logEvent({ - client, - guildId, - eventType: EVENT_TYPES.REACTION_ROLE_DELETE, - data: { - description: `Reaction role panel deleted by ${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 }, - ], - }, - }); - } catch (logErr) { - logger.warn('Failed to log reaction role deletion:', logErr); - } - - await submitted.followUp({ - embeds: [successEmbed('✅ Panel Deleted', `**${title}** has been deleted.`)], - flags: MessageFlags.Ephemeral, - }); - - // Remove the deleted panel from the array - const panelIndex = panels.findIndex(p => p.messageId === panelData.messageId); - if (panelIndex > -1) { - panels.splice(panelIndex, 1); - } - - if (panels.length === 0) { - collector.stop(); - buttonCollector.stop(); - await InteractionHelper.safeEditReply(rootInteraction, { - embeds: [ - new EmbedBuilder() - .setTitle('📋 Reaction Roles Dashboard') - .setDescription('No panels remain. Use `/reactroles setup` to create one.') - .setColor(getColor('info')), - ], - components: [], - }); - } else { - // Close the dashboard after deletion - collector.stop(); - buttonCollector.stop(); - await InteractionHelper.safeEditReply(rootInteraction, { - embeds: [ - new EmbedBuilder() - .setTitle('📋 Reaction Roles Dashboard') - .setDescription('Panel deleted. Run `/reactroles dashboard` to manage another panel.') - .setColor(getColor('success')), - ], - components: [], - }); - } -} \ No newline at end of file diff --git a/src/commands/Search/define.js b/src/commands/Search/define.js deleted file mode 100644 index 97dd195da6..0000000000 --- a/src/commands/Search/define.js +++ /dev/null @@ -1,114 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import axios from 'axios'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getColor } from '../../config/bot.js'; - -export default { - data: new SlashCommandBuilder() - .setName('define') - .setDescription('Look up a word definition') - .addStringOption(option => - option.setName('word') - .setDescription('The word to look up') - .setRequired(true)), - async execute(interaction) { - try { - - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) { - return; - } - - const word = interaction.options.getString('word'); - - if (word.length < 2) { - logger.warn('Define command - word too short', { - userId: interaction.user.id, - word: word, - guildId: interaction.guildId - }); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Error', 'Please enter a word with at least 2 characters.')], - flags: MessageFlags.Ephemeral - }); - } - - const response = await axios.get( - `https://api.dictionaryapi.dev/api/v2/entries/en/${encodeURIComponent(word)}`, - { timeout: 5000 } - ); - - if (!response.data || response.data.length === 0) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Not Found', `No definitions found for "${word}".`)] - }); - } - - const data = response.data[0]; - const embed = createEmbed({ - title: data.word, - description: data.phonetic ? `*${data.phonetic}*` : '', - color: 'success' - }); - - data.meanings.slice(0, 5).forEach(meaning => { - const definitions = meaning.definitions - .slice(0, 3) - .map((def, idx) => { - let text = `${idx + 1}. ${def.definition}`; - if (def.example) { - text += `\n *Example: ${def.example}*`; - } - return text; - }) - .join('\n\n'); - - if (definitions) { - embed.addFields({ - name: `**${meaning.partOfSpeech || 'Definition'}**`, - value: definitions, - inline: false - }); - } - }); - - embed.setFooter({ text: 'Powered by Free Dictionary API' }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - - logger.info('Dictionary definition retrieved', { - userId: interaction.user.id, - word: word, - guildId: interaction.guildId, - commandName: 'define' - }); - - } catch (error) { - logger.error('Dictionary lookup error', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - word: interaction.options.getString('word'), - guildId: interaction.guildId, - commandName: 'define' - }); - - - if (error.response?.status === 404) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Not Found', `No definitions found for "${interaction.options.getString('word')}".`)] - }); - } else { - await handleInteractionError(interaction, error, { - commandName: 'define', - source: 'dictionary_api' - }); - } - } - }, -}; - - diff --git a/src/commands/Search/google.js b/src/commands/Search/google.js deleted file mode 100644 index 81844faca4..0000000000 --- a/src/commands/Search/google.js +++ /dev/null @@ -1,52 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { getColor } from '../../config/bot.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName('google') - .setDescription('Search Google') - .addStringOption(option => - option.setName('query') - .setDescription('What would you like to search for?') - .setRequired(true)), - async execute(interaction) { - try { - const query = interaction.options.getString('query'); - const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}`; - - const embed = createEmbed({ - title: 'Google Search', - description: `[Search for "${query}"](${searchUrl})`, - color: 'info' - }) - .setFooter({ text: 'Google Search Results' }); - - await InteractionHelper.safeReply(interaction, { embeds: [embed] }); - - logger.info('Google search link generated', { - userId: interaction.user.id, - query: query, - guildId: interaction.guildId, - commandName: 'google' - }); - } catch (error) { - logger.error('Error in google command', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'google' - }); - await handleInteractionError(interaction, error, { - commandName: 'google', - source: 'google_search' - }); - } - }, -}; - - diff --git a/src/commands/Search/movie.js b/src/commands/Search/movie.js deleted file mode 100644 index a4e72ebc0e..0000000000 --- a/src/commands/Search/movie.js +++ /dev/null @@ -1,261 +0,0 @@ -import axios from 'axios'; -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getGuildConfig } from '../../services/guildConfig.js'; -import { getColor } from '../../config/bot.js'; - -const TMDB_API_KEY = process.env.TMDB_API_KEY || '4e44d9029b1270a757cddc766a1bcb63'; - "4e44d9029b1270a757cddc766a1bcb63"; -const IMAGE_BASE_URL = "https://image.tmdb.org/t/p/w500"; -const MAX_RESULTS = 5; - -export default { - data: new SlashCommandBuilder() - .setName("movie") - .setDescription("Search for a movie or TV show") - .addStringOption((option) => - option - .setName("title") - .setDescription("The title of the movie or TV show") - .setRequired(true) - .setMaxLength(100), - ) - .addStringOption((option) => - option - .setName("type") - .setDescription("Type of content to search for") - .addChoices( - { name: "Movie", value: "movie" }, - { name: "TV Show", value: "tv" }, - ) - .setRequired(false), - ), - async execute(interaction) { - try { - - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) { - return; - } - - const guildConfig = await getGuildConfig( - interaction.client, - interaction.guild?.id, - ); - if (guildConfig?.disabledCommands?.includes("movie")) { - logger.warn('Movie command disabled in guild', { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'movie' - }); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Command Disabled", - "The movie/TV show search command is disabled in this server.", - ), - ], - flags: MessageFlags.Ephemeral, - }); - } - - if (!TMDB_API_KEY) { - logger.error('TMDB API key not configured', { - guildId: interaction.guildId, - commandName: 'movie' - }); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Configuration Error", - "Movie/TV show search is not properly configured.", - ), - ], - flags: MessageFlags.Ephemeral, - }); - } - - const title = interaction.options.getString("title"); - const type = interaction.options.getString("type") || "movie"; - - logger.debug('Movie search initiated', { - userId: interaction.user.id, - title: title, - type: type, - guildId: interaction.guildId - }); - - const searchResponse = await axios.get( - `https://api.themoviedb.org/3/search/${type}`, - { - params: { - api_key: TMDB_API_KEY, - query: title, - include_adult: guildConfig?.allowNsfwContent - ? undefined - : false, - language: guildConfig?.language || "en-US", - page: 1, - region: guildConfig?.region || "US", - }, -timeout: 8000, - }, - ); - - if (!searchResponse.data?.results?.length) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Not Found", - `No ${type === "movie" ? "movies" : "TV shows"} found for "${title}".`, - ), - ], - }); - } - - const result = searchResponse.data.results[0]; - const mediaType = type === "movie" ? "Movie" : "TV Show"; - const mediaTitle = result.title || result.name || "Unknown Title"; - const releaseDate = result.release_date || result.first_air_date; - const year = releaseDate - ? new Date(releaseDate).getFullYear() - : "N/A"; - - const detailsResponse = await axios.get( - `https://api.themoviedb.org/3/${type}/${result.id}`, - { - params: { - api_key: TMDB_API_KEY, - language: guildConfig?.language || "en-US", - append_to_response: - "credits,release_dates,content_ratings", - }, - timeout: 8000, - }, - ); - - const details = detailsResponse.data; - const runtime = details.runtime - ? `${Math.floor(details.runtime / 60)}h ${details.runtime % 60}m` - : details.episode_run_time?.[0] - ? `${details.episode_run_time[0]}m per episode` - : "N/A"; - - let contentRating = "N/A"; - if (type === "movie") { - const usCert = details.release_dates?.results?.find( - (r) => r.iso_3166_1 === "US", - ); - if (usCert?.release_dates?.[0]?.certification) { - contentRating = usCert.release_dates[0].certification; - } - } else { - const usCert = details.content_ratings?.results?.find( - (r) => r.iso_3166_1 === "US", - ); - if (usCert?.rating) { - contentRating = usCert.rating; - } - } - - const genres = - details.genres?.map((g) => g.name).join(", ") || "N/A"; - - const cast = - details.credits?.cast - ?.slice(0, 3) - .map((p) => p.name) - .join(", ") || "N/A"; - - const embed = createEmbed({ - title: `${mediaTitle} (${year})`, - description: details.overview || "No overview available.", - color: 'info' - }) - .setURL(`https://www.themoviedb.org/${type}/${result.id}`) - .setThumbnail( - result.poster_path - ? `${IMAGE_BASE_URL}${result.poster_path}` - : null, - ) - .addFields( - { name: "Type", value: mediaType, inline: true }, - { - name: "Rating", - value: result.vote_average - ? `⭐ ${result.vote_average.toFixed(1)}/10 (${result.vote_count.toLocaleString()} votes)` - : "N/A", - inline: true, - }, - { - name: "Content Rating", - value: contentRating, - inline: true, - }, - { name: "Runtime", value: runtime, inline: true }, - { - name: "Release Date", - value: releaseDate - ? new Date(releaseDate).toLocaleDateString() - : "N/A", - inline: true, - }, - { name: "Genres", value: genres, inline: true }, - { name: "Cast", value: cast, inline: false }, - ) - .setFooter({ - text: "Powered by The Movie Database", - iconURL: - "https://www.themoviedb.org/assets/2/v4/logos/v2/blue_square_1-5bdc75aaebeb75dc7ae79426ddd9be3b2be1e342510f8202baf6bffa71d7f5c4.svg", - }); - - if (result.backdrop_path) { - embed.setImage( - `https://image.tmdb.org/t/p/w1280${result.backdrop_path}`, - ); - } - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - - logger.info('Movie information retrieved', { - userId: interaction.user.id, - title: title, - type: type, - resultTitle: mediaTitle, - guildId: interaction.guildId, - commandName: 'movie' - }); - } catch (error) { - logger.error('Movie/TV show search error', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - apiStatus: error.response?.status, - commandName: 'movie' - }); - - - if (error.response?.status === 404) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Not Found', 'The requested movie/TV show could not be found.')] - }); - } else if (error.response?.status === 401) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Configuration Error', 'Invalid TMDB API key. Please contact the bot administrator.')], - flags: MessageFlags.Ephemeral - }); - } else { - await handleInteractionError(interaction, error, { - commandName: 'movie', - source: 'tmdb_api' - }); - } - } - }, -}; - - diff --git a/src/commands/Search/urban.js b/src/commands/Search/urban.js deleted file mode 100644 index e2020025b8..0000000000 --- a/src/commands/Search/urban.js +++ /dev/null @@ -1,159 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import axios from 'axios'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getGuildConfig } from '../../services/guildConfig.js'; -import { getColor } from '../../config/bot.js'; - -export default { - data: new SlashCommandBuilder() - .setName('urban') - .setDescription('Search Urban Dictionary for definitions') - .addStringOption(option => - option.setName('term') - .setDescription('The term to look up on Urban Dictionary') - .setRequired(true)), - - async execute(interaction) { - try { - const term = interaction.options.getString('term'); - - if (term.length < 2) { - logger.warn('Urban command - term too short', { - userId: interaction.user.id, - term: term, - guildId: interaction.guildId - }); - return await InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Error', 'Please enter a term with at least 2 characters.')], - flags: MessageFlags.Ephemeral - }); - } - - const guildConfig = await getGuildConfig(interaction.client, interaction.guild?.id); - if (guildConfig?.disabledCommands?.includes('urban')) { - logger.warn('Urban command disabled in guild', { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'urban' - }); - return await InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Command Disabled', 'The Urban Dictionary command is disabled in this server.')], - flags: MessageFlags.Ephemeral - }); - } - - let deferTimer = null; - const clearDeferTimer = () => { - if (deferTimer) { - clearTimeout(deferTimer); - deferTimer = null; - } - }; - - deferTimer = setTimeout(() => { - InteractionHelper.safeDefer(interaction).catch((deferError) => { - logger.debug('Urban command defer fallback failed', { - error: deferError?.message, - interactionId: interaction.id, - commandName: 'urban' - }); - }); - }, 1500); - - const response = await axios.get( - `https://api.urbandictionary.com/v0/define?term=${encodeURIComponent(term)}`, - { timeout: 5000 } - ); - clearDeferTimer(); - - if (!response.data?.list?.length) { - return await InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Not Found', `No definitions found for "${term}" on Urban Dictionary.`)] - }); - } - - const definition = response.data.list[0]; - const cleanDefinition = definition.definition.replace(/\[|\]/g, ''); - const cleanExample = definition.example.replace(/\[|\]/g, ''); - - const formattedDefinition = cleanDefinition -.replace(/\n\s*\n/g, '\n\n') - .slice(0, 2000); - - const formattedExample = cleanExample - ? `*"${cleanExample.replace(/\n/g, ' ').slice(0, 500)}..."*` - : '*No example provided*'; - - const embed = createEmbed({ - title: definition.word, - description: formattedDefinition, - color: 'info' - }) - .setURL(definition.permalink) - .addFields( - { - name: 'Example', - value: formattedExample, - inline: false - }, - { - name: 'Stats', - value: `👍 ${definition.thumbs_up.toLocaleString()} • 👎 ${definition.thumbs_down.toLocaleString()}`, - inline: true - }, - { - name: 'Author', - value: definition.author || 'Anonymous', - inline: true - } - ) - .setFooter({ - text: 'Urban Dictionary', - iconURL: 'https://i.imgur.com/8aQrX3a.png' - }); - - await InteractionHelper.safeReply(interaction, { embeds: [embed] }); - - logger.info('Urban Dictionary definition retrieved', { - userId: interaction.user.id, - term: term, - guildId: interaction.guildId, - commandName: 'urban' - }); - - } catch (error) { - logger.error('Urban Dictionary error', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - term: interaction.options.getString('term'), - guildId: interaction.guildId, - apiStatus: error.response?.status, - commandName: 'urban' - }); - - - if (error.response?.status === 404 || !error.response) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Not Found', `No definitions found for "${interaction.options.getString('term')}" on Urban Dictionary.`)] - }); - } else if (error.response?.status === 429) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Rate Limited', 'Too many requests to Urban Dictionary. Please try again in a few minutes.')] - }); - } else { - await handleInteractionError(interaction, error, { - commandName: 'urban', - source: 'urban_dictionary_api' - }); - } - } - }, -}; - - - - diff --git a/src/commands/ServerStats/modules/serverstats_create.js b/src/commands/ServerStats/modules/serverstats_create.js deleted file mode 100644 index 9bd4912507..0000000000 --- a/src/commands/ServerStats/modules/serverstats_create.js +++ /dev/null @@ -1,113 +0,0 @@ -import { PermissionFlagsBits, ChannelType } from 'discord.js'; -import { createEmbed, errorEmbed, 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'; -export async function handleCreate(interaction, client) { - const guild = interaction.guild; - const type = interaction.options.getString("type"); - const channelType = interaction.options.getString("channel_type"); - const category = interaction.options.getChannel("category"); - - // Defer reply immediately to ensure interaction is acknowledged - try { - await InteractionHelper.safeDefer(interaction); - } catch (error) { - logger.error("Failed to defer reply:", error); - return; - } - - // Check permissions after deferring - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("You need **Manage Channels** permission to create counters.")] - }).catch(logger.error); - return; - } - - try { - if (!category || category.type !== ChannelType.GuildCategory) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("Please select a valid category for the counter channel.")] - }).catch(logger.error); - return; - } - - const targetChannelType = channelType === 'voice' ? ChannelType.GuildVoice : ChannelType.GuildText; - const baseChannelName = getCounterBaseName(type); - - const counters = await getServerCounters(client, guild.id); - - const duplicateType = counters.find(counter => counter.type === type); - - if (duplicateType) { - const duplicateChannel = guild.channels.cache.get(duplicateType.channelId); - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed(`A **${getCounterTypeLabel(type)}** counter already exists for this server${duplicateChannel ? ` in ${duplicateChannel}` : ''}. Delete it first before creating another.`)] - }).catch(logger.error); - return; - } - - const targetChannel = await guild.channels.create({ - name: baseChannelName, - type: targetChannelType, - parent: category.id, - reason: `Counter channel created by ${interaction.user.tag}` - }); - - const existingCounter = counters.find(c => c.channelId === targetChannel.id); - if (existingCounter) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed(`A counter already exists for channel **${targetChannel.name}**. Please delete it first or choose a different type.`)] - }).catch(logger.error); - return; - } - - const newCounter = { - id: Date.now().toString(), - type: type, - channelId: targetChannel.id, - guildId: guild.id, - createdAt: new Date().toISOString(), - enabled: true - }; - - counters.push(newCounter); - - const saved = await saveServerCounters(client, guild.id, counters); - if (!saved) { - await targetChannel.delete('Counter creation failed during save').catch(() => null); - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("Failed to save counter data. Please try again.")] - }).catch(logger.error); - return; - } - - const updated = await updateCounter(client, guild, newCounter); - if (!updated) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("Counter created but failed to update channel name. The counter will update on the next scheduled run.")] - }).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 \`/counter list\` to view all counters.`)] - }).catch(logger.error); - - } catch (error) { - logger.error("Error creating counter:", error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("An error occurred while creating the counter. Please try again.")] - }).catch(logger.error); - } -} - - - diff --git a/src/commands/ServerStats/modules/serverstats_delete.js b/src/commands/ServerStats/modules/serverstats_delete.js deleted file mode 100644 index e0f52f204e..0000000000 --- a/src/commands/ServerStats/modules/serverstats_delete.js +++ /dev/null @@ -1,154 +0,0 @@ -import { getColor } from '../../../config/bot.js'; -import { PermissionFlagsBits, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; -import { createEmbed, errorEmbed } from '../../../utils/embeds.js'; -import { getServerCounters, saveServerCounters, getCounterEmoji, getCounterTypeLabel } from '../../../services/serverstatsService.js'; -import { logger } from '../../../utils/logger.js'; - - - - - - -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -export async function handleDelete(interaction, client) { - const guild = interaction.guild; - const counterId = interaction.options.getString("counter-id"); - - // Defer reply immediately to ensure interaction is acknowledged - try { - await InteractionHelper.safeDefer(interaction); - } catch (error) { - logger.error("Failed to defer reply:", error); - return; - } - - // Check permissions after deferring - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("You need **Manage Channels** permission to delete counters.")] - }).catch(logger.error); - return; - } - - try { - const counters = await getServerCounters(client, guild.id); - - if (counters.length === 0) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("No counters found to delete.")] - }).catch(logger.error); - return; - } - - const counterToDelete = counters.find(c => c.id === counterId); - if (!counterToDelete) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed(`Counter with ID \`${counterId}\` not found. Use \`/counter list\` to see all counters.`)] - }).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!**`, - color: getColor('error') - }); - - const row = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`counter-delete:confirm:${counterToDelete.id}:${interaction.user.id}`) - .setLabel("Confirm Delete") - .setStyle(ButtonStyle.Danger), - new ButtonBuilder() - .setCustomId(`counter-delete:cancel:${counterToDelete.id}:${interaction.user.id}`) - .setLabel("Cancel") - .setStyle(ButtonStyle.Secondary) - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed], components: [row] }).catch(logger.error); - - } catch (error) { - logger.error("Error in handleDelete:", error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("An error occurred while fetching counters. Please try again.")] - }).catch(logger.error); - } -} - - - - - - - -export async function performDeletionByCounterId(client, guild, counterId) { - try { - const counters = await getServerCounters(client, guild.id); - - const counter = counters.find(c => c.id === counterId); - if (!counter) { - return { - success: false, - message: `Counter with ID \`${counterId}\` was not found.` - }; - } - - const updatedCounters = counters.filter(c => c.id !== counter.id); - - const saved = await saveServerCounters(client, guild.id, updatedCounters); - if (!saved) { - return { - success: false, - message: "Failed to delete counter. Please try again." - }; - } - - const channel = guild.channels.cache.get(counter.channelId); - let channelDeleted = false; - - if (channel) { - try { - await channel.delete(`Counter deleted - removing channel: ${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)}`; - - if (channelDeleted) { - message += `\n**Channel:** ${channel.name} (deleted)`; - } else if (channel) { - message += `\n**Channel:** ${channel.name} (failed to delete)`; - } else { - message += `\n**Channel:** Already deleted`; - } - - return { - success: true, - message - }; - - } catch (error) { - logger.error("Error deleting counter:", error); - return { - success: false, - message: "An error occurred while deleting the counter. Please try again." - }; - } -} - - - - - - -function getCounterTypeDisplay(type) { - return `${getCounterEmoji(type)} ${getCounterTypeLabel(type)}`; -} - - - diff --git a/src/commands/ServerStats/modules/serverstats_list.js b/src/commands/ServerStats/modules/serverstats_list.js deleted file mode 100644 index 24c3aefec3..0000000000 --- a/src/commands/ServerStats/modules/serverstats_list.js +++ /dev/null @@ -1,177 +0,0 @@ -import { getColor } from '../../../config/bot.js'; -import { PermissionFlagsBits } from 'discord.js'; -import { createEmbed, errorEmbed } 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'; -export async function handleList(interaction, client) { - const guild = interaction.guild; - - // Defer reply immediately to ensure interaction is acknowledged - try { - await InteractionHelper.safeDefer(interaction); - } catch (error) { - logger.error("Failed to defer reply:", error); - return; - } - - // Check permissions after deferring - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("You need **Manage Channels** permission to view counters.")] - }).catch(logger.error); - return; - } - - try { - const counters = await getServerCounters(client, guild.id); - const stats = await getGuildCounterStats(guild); - - // Clean up counters with deleted channels - const validCounters = []; - const orphanedCounters = []; - - for (const counter of counters) { - const channel = guild.channels.cache.get(counter.channelId); - if (channel) { - validCounters.push(counter); - } else { - orphanedCounters.push(counter); - logger.info(`Removing orphaned counter ${counter.id} (type: ${counter.type}, deleted channel: ${counter.channelId}) from guild ${guild.id}`); - } - } - - // Save cleaned counters if any were orphaned - if (orphanedCounters.length > 0) { - await saveServerCounters(client, guild.id, validCounters); - logger.info(`Cleaned up ${orphanedCounters.length} orphaned counter(s) from guild ${guild.id}`); - } - - if (validCounters.length === 0) { - const embed = createEmbed({ - title: "📋 Server Counters", - description: "No counters have been set up for this server yet.\n\nUse `/counter create` to set up your first counter!", - 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", - inline: false - }); - - embed.addFields({ - name: "📝 **Usage Examples**", - value: "`/counter create type:members channel_type:voice category:Stats`\n`/counter create type:bots channel_type:text category:Server Info`\n`/counter list`", - inline: false - }); - - embed.setFooter({ - text: "Counter System • Automatic updates every 15 minutes" - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }).catch(logger.error); - return; - } - - 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.", - color: getColor('info') - }); - - for (let i = 0; i < validCounters.length; i++) { - const counter = validCounters[i]; - const channel = guild.channels.cache.get(counter.channelId); - - if (!channel) { - // This should not happen since we filtered above, but keep as safety check - logger.warn(`Counter ${counter.id} still has missing channel after cleanup`); - continue; - } - - const currentCount = getCurrentCount(stats, counter.type); - const status = channel.name.includes(':') ? '✅ Active' : '⚠️ Not Updated'; - - 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()}`, - inline: false - }); - } - - embed.addFields({ - name: "📊 **Statistics**", - value: `**Total Counters:** ${validCounters.length}\n**Active Counters:** ${validCounters.filter(c => { - const channel = guild.channels.cache.get(c.channelId); - return channel && channel.name.includes(':'); - }).length}\n**Next Update:** `, - inline: false - }); - - embed.addFields({ - name: "🔧 **Management Commands**", - value: "`/counter create` - Create new counter\n`/counter update` - Update existing counter\n`/counter delete` - Delete counter", - inline: false - }); - - embed.setFooter({ - text: "Counter System • Automatic updates every 15 minutes" - }); - embed.setTimestamp(); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }).catch(logger.error); - - } catch (error) { - logger.error("Error displaying counters:", error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("An error occurred while fetching counters. Please try again.")] - }).catch(logger.error); - } -} - - - - - - -function getCounterTypeDisplay(type) { - return `${getCounterTypeEmoji(type)} ${getCounterTypeLabel(type)}`; -} - - - - - - -function getCounterEmoji(type) { - return getCounterTypeEmoji(type); -} - - - - - - - -function getCurrentCount(stats, type) { - switch (type) { - case "members": - return stats.totalCount; - case "bots": - return stats.botCount; - case "members_only": - return stats.humanCount; - default: - return 0; - } -} - - - diff --git a/src/commands/ServerStats/modules/serverstats_update.js b/src/commands/ServerStats/modules/serverstats_update.js deleted file mode 100644 index cd3d30ece3..0000000000 --- a/src/commands/ServerStats/modules/serverstats_update.js +++ /dev/null @@ -1,109 +0,0 @@ -import { PermissionFlagsBits } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed } from '../../../utils/embeds.js'; -import { getServerCounters, saveServerCounters, updateCounter, getCounterEmoji, getCounterTypeLabel } from '../../../services/serverstatsService.js'; -import { logger } from '../../../utils/logger.js'; - - - - - - -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -export async function handleUpdate(interaction, client) { - const guild = interaction.guild; - const counterId = interaction.options.getString("counter-id"); - const newType = interaction.options.getString("type"); - - // Defer reply immediately to ensure interaction is acknowledged - try { - await InteractionHelper.safeDefer(interaction); - } catch (error) { - logger.error("Failed to defer reply:", error); - return; - } - - // Check permissions after deferring - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("You need **Manage Channels** permission to update counters.")] - }).catch(logger.error); - return; - } - - if (!newType) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("You must provide a new counter type to update.")] - }).catch(logger.error); - return; - } - - try { - const counters = await getServerCounters(client, guild.id); - - const counterIndex = counters.findIndex(c => c.id === counterId); - if (counterIndex === -1) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed(`Counter with ID \`${counterId}\` not found. Use \`/counter list\` to see all counters.`)] - }).catch(logger.error); - return; - } - - const counter = counters[counterIndex]; - const oldChannel = guild.channels.cache.get(counter.channelId); - - if (!oldChannel) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("The channel for this counter no longer exists. You cannot update a counter for a deleted channel.")] - }).catch(logger.error); - return; - } - - if (newType !== counter.type) { - const existingTypeCounter = counters.find(c => c.type === newType && c.id !== counter.id); - if (existingTypeCounter) { - const existingChannel = guild.channels.cache.get(existingTypeCounter.channelId); - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed(`A **${getCounterTypeLabel(newType)}** counter already exists for this server${existingChannel ? ` in ${existingChannel}` : ''}. Delete it first before reusing that type.`)] - }).catch(logger.error); - return; - } - } - - const oldType = counter.type; - - counter.type = newType; - counter.updatedAt = new Date().toISOString(); - - const saved = await saveServerCounters(client, guild.id, counters); - if (!saved) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("Failed to save updated counter data. Please try again.")] - }).catch(logger.error); - return; - } - - const updatedCounter = counters[counterIndex]; - const updated = await updateCounter(client, guild, updatedCounter); - if (!updated) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("Counter updated but failed to update channel name. The counter will update on the next scheduled run.")] - }).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.`)] - }).catch(logger.error); - - } catch (error) { - logger.error("Error updating counter:", error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("An error occurred while updating the counter. Please try again.")] - }).catch(logger.error); - } -} - - - diff --git a/src/commands/ServerStats/serverstats.js b/src/commands/ServerStats/serverstats.js deleted file mode 100644 index 8da7cc79f8..0000000000 --- a/src/commands/ServerStats/serverstats.js +++ /dev/null @@ -1,132 +0,0 @@ -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ChannelType } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; - -import { handleCreate } from './modules/serverstats_create.js'; -import { handleList } from './modules/serverstats_list.js'; -import { handleUpdate } from './modules/serverstats_update.js'; -import { handleDelete } from './modules/serverstats_delete.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("serverstats") - .setDescription("Manage server statistics that track member counts and channel data") - .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels) - .addSubcommand(subcommand => - subcommand - .setName("create") - .setDescription("Create a new statistics tracker channel in a category") - .addStringOption(option => - option - .setName("type") - .setDescription("The type of statistics to track") - .setRequired(true) - .addChoices( - { name: "members + bots", value: "members" }, - { name: "members only", value: "members_only" }, - { name: "bots only", value: "bots" } - ) - ) - .addStringOption(option => - option - .setName("channel_type") - .setDescription("The channel type to create for this tracker") - .setRequired(true) - .addChoices( - { name: "voice channel (recommended)", value: "voice" }, - { name: "text channel", value: "text" } - ) - ) - .addChannelOption(option => - option - .setName("category") - .setDescription("The category where the statistics tracker channel will be created") - .setRequired(true) - .addChannelTypes(ChannelType.GuildCategory) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName("list") - .setDescription("List all statistics trackers for this server") - ) - .addSubcommand(subcommand => - subcommand - .setName("update") - .setDescription("Update an existing statistics tracker") - .addStringOption(option => - option - .setName("counter-id") - .setDescription("The ID of the tracker to update") - .setRequired(true) - ) - .addStringOption(option => - option - .setName("type") - .setDescription("The new tracker type") - .setRequired(false) - .addChoices( - { name: "members + bots", value: "members" }, - { name: "members only", value: "members_only" }, - { name: "bots only", value: "bots" } - ) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName("delete") - .setDescription("Delete an existing statistics tracker") - .addStringOption(option => - option - .setName("counter-id") - .setDescription("The ID of the tracker to delete") - .setRequired(true) - ) - ), - - async execute(interaction, guildConfig, client) { - const subcommand = interaction.options.getSubcommand(); - - try { - switch (subcommand) { - case "create": - await handleCreate(interaction, client); - break; - case "list": - await handleList(interaction, client); - break; - case "update": - await handleUpdate(interaction, client); - break; - case "delete": - await handleDelete(interaction, client); - break; - default: - await InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed("Unknown subcommand.")], - flags: MessageFlags.Ephemeral - }); - } - } catch (error) { - logger.error(`Error in serverstats ${subcommand}:`, error); - - const errorEmbedMsg = createEmbed({ - title: "❌ Error", - description: "An error occurred while processing your request.", - color: getColor('error') - }); - - if (!interaction.replied && !interaction.deferred) { - await InteractionHelper.safeReply(interaction, { embeds: [errorEmbedMsg], flags: MessageFlags.Ephemeral }).catch(logger.error); - } else { - await interaction.followUp({ embeds: [errorEmbedMsg], flags: MessageFlags.Ephemeral }).catch(logger.error); - } - } - } -}; - - - - diff --git a/src/commands/Ticket/claim.js b/src/commands/Ticket/claim.js deleted file mode 100644 index bb98ce668d..0000000000 --- a/src/commands/Ticket/claim.js +++ /dev/null @@ -1,102 +0,0 @@ -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; -import { errorEmbed, successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getTicketPermissionContext } from '../../utils/ticketPermissions.js'; -import { claimTicket } from '../../services/ticket.js'; -export default { - data: new SlashCommandBuilder() - .setName("claim") - .setDescription("Claims an open ticket, assigning it to you.") - .setDMPermission(false), - - async execute(interaction, guildConfig, client) { - try { - - const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - if (!deferred) { - return; - } - - const permissionContext = await getTicketPermissionContext({ client, interaction }); - if (!permissionContext.ticketData) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Not a Ticket Channel", - "This command can only be used in a valid ticket channel.", - ), - ], - }); - } - - if (!permissionContext.canManageTicket) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Permission Denied", - "You need the `Manage Channels` permission or the configured `Ticket Staff Role` to claim tickets.", - ), - ], - }); - } - - const channel = interaction.channel; - const result = await claimTicket(channel, interaction.user); - - if (!result.success) { - logger.warn('Ticket claim failed - not a valid ticket channel', { - userId: interaction.user.id, - channelId: channel.id, - guildId: interaction.guildId, - error: result.error - }); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Not a Ticket Channel", - result.error || "This command can only be used in a valid ticket channel.", - ), - ], - }); - } - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "Ticket Claimed!", - "You have successfully claimed this ticket.", - ), - ], - }); - - logger.info('Ticket claimed successfully', { - userId: interaction.user.id, - userTag: interaction.user.tag, - channelId: channel.id, - channelName: channel.name, - guildId: interaction.guildId, - commandName: 'claim' - }); - - } catch (error) { - logger.error('Error executing claim command', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - channelId: interaction.channel?.id, - guildId: interaction.guildId, - commandName: 'claim' - }); - await handleInteractionError(interaction, error, { - commandName: 'claim', - source: 'ticket_claim_command' - }); - } - }, -}; - - - diff --git a/src/commands/Ticket/close.js b/src/commands/Ticket/close.js deleted file mode 100644 index f05b6bd561..0000000000 --- a/src/commands/Ticket/close.js +++ /dev/null @@ -1,113 +0,0 @@ -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, MessageFlags } from 'discord.js'; -import { errorEmbed, successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getTicketPermissionContext } from '../../utils/ticketPermissions.js'; -import { closeTicket } from '../../services/ticket.js'; -export default { - data: new SlashCommandBuilder() - .setName("close") - .setDescription("Closes the current ticket.") - .setDMPermission(false) - .addStringOption((option) => - option - .setName("reason") - .setDescription("The reason for closing the ticket.") - .setRequired(false), - ), - - async execute(interaction, guildConfig, client) { - try { - - const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - if (!deferred) { - return; - } - - const permissionContext = await getTicketPermissionContext({ client, interaction }); - if (!permissionContext.ticketData) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Not a Ticket Channel", - "This command can only be used in a valid ticket channel.", - ), - ], - }); - } - - if (!permissionContext.canCloseTicket) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Permission Denied", - "You need the `Manage Channels` permission, the configured `Ticket Staff Role`, or be the ticket creator to close this ticket.", - ), - ], - }); - } - - const channel = interaction.channel; - const reason = - interaction.options?.getString("reason") || - "Closed via command without a specific reason."; - - const result = await closeTicket(channel, interaction.user, reason); - - if (!result.success) { - logger.warn('Ticket close failed - not a valid ticket channel', { - userId: interaction.user.id, - channelId: channel.id, - guildId: interaction.guildId, - error: result.error - }); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Not a Ticket Channel", - result.error || "This command can only be used in a valid ticket channel.", - ), - ], - }); - } - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "Ticket Closed!", - "This ticket has been closed successfully.", - ), - ], - }); - - logger.info('Ticket closed successfully', { - userId: interaction.user.id, - userTag: interaction.user.tag, - channelId: channel.id, - channelName: channel.name, - guildId: interaction.guildId, - reason: reason, - commandName: 'close' - }); - - } catch (error) { - logger.error('Error executing close command', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - channelId: interaction.channel?.id, - guildId: interaction.guildId, - commandName: 'close' - }); - await handleInteractionError(interaction, error, { - commandName: 'close', - source: 'ticket_close_command' - }); - } - }, -}; - - - diff --git a/src/commands/Ticket/modules/ticket_dashboard.js b/src/commands/Ticket/modules/ticket_dashboard.js deleted file mode 100644 index 43ece8d982..0000000000 --- a/src/commands/Ticket/modules/ticket_dashboard.js +++ /dev/null @@ -1,1036 +0,0 @@ -import { getColor } from '../../../config/bot.js'; -import { - ActionRowBuilder, - StringSelectMenuBuilder, - StringSelectMenuOptionBuilder, - ModalBuilder, - TextInputBuilder, - TextInputStyle, - RoleSelectMenuBuilder, - ChannelSelectMenuBuilder, - UserSelectMenuBuilder, - ButtonBuilder, - ButtonStyle, - ChannelType, - MessageFlags, - ComponentType, - EmbedBuilder, -} from 'discord.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { successEmbed, errorEmbed } from '../../../utils/embeds.js'; -import { logger } from '../../../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../../../utils/errorHandler.js'; -import { getGuildConfig } from '../../../services/guildConfig.js'; -import { getGuildConfigKey } from '../../../utils/database.js'; -import { getUserTicketCount } from '../../../services/ticket.js'; - -// ─── Embed & Menu Builders ──────────────────────────────────────────────────── - -function buildDashboardEmbed(config, guild) { - 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`'; - - // Get category names from guild - const openCategoryChannel = config.ticketCategoryId ? guild.channels.cache.get(config.ticketCategoryId) : null; - const openCategory = openCategoryChannel ? openCategoryChannel.toString() : '`Not set`'; - - const closedCategoryChannel = config.ticketClosedCategoryId ? guild.channels.cache.get(config.ticketClosedCategoryId) : null; - const closedCategory = closedCategoryChannel ? closedCategoryChannel.toString() : '`Not set`'; - - const rawMsg = config.ticketPanelMessage || 'Click the button below to create a support ticket.'; - const panelMsg = `\`${rawMsg.length > 60 ? rawMsg.substring(0, 60) + '…' : rawMsg}\``; - const btnLabel = `\`${config.ticketButtonLabel || 'Create Ticket'}\``; - - return new EmbedBuilder() - .setTitle('🎫 Ticket System Dashboard') - .setDescription(`Manage ticket system settings for **${guild.name}**.\nSelect an option below to modify a setting.`) - .setColor(getColor('info')) - .addFields( - { name: '📢 Panel Channel', value: panelChannel, inline: true }, - { name: '🛡️ Staff Role', 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: '\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 }, - ) - .setFooter({ text: 'Select an option below • Dashboard closes after 10 minutes of inactivity' }) - .setTimestamp(); -} - -function buildSelectMenu(guildId) { - return new StringSelectMenuBuilder() - .setCustomId(`ticket_config_${guildId}`) - .setPlaceholder('Select a setting to configure...') - .addOptions( - new StringSelectMenuOptionBuilder() - .setLabel('Edit Panel Message') - .setDescription('Change the message displayed on the ticket creation panel') - .setValue('panel_message') - .setEmoji('📝'), - new StringSelectMenuOptionBuilder() - .setLabel('Edit Button Label') - .setDescription('Change the label on the Create Ticket button') - .setValue('button_label') - .setEmoji('🏷️'), - new StringSelectMenuOptionBuilder() - .setLabel('Change Open Tickets Category') - .setDescription('Category where new tickets are created') - .setValue('open_category') - .setEmoji('📁'), - new StringSelectMenuOptionBuilder() - .setLabel('Change Closed Tickets Category') - .setDescription('Category where closed tickets are moved') - .setValue('closed_category') - .setEmoji('📂'), - new StringSelectMenuOptionBuilder() - .setLabel('Set Max Tickets per User') - .setDescription('Limit how many open tickets one user can have at once') - .setValue('max_tickets') - .setEmoji('🔢'), - new StringSelectMenuOptionBuilder() - .setLabel('Set Ticket Logs Channel') - .setDescription('Channel to receive ticket feedback, lifecycle events, and logs') - .setValue('logs_channel') - .setEmoji('🎫'), - new StringSelectMenuOptionBuilder() - .setLabel('Set Transcript Channel') - .setDescription('Channel to receive auto-generated transcripts on deletion') - .setValue('transcript_channel') - .setEmoji('📜'), - ); -} - -function buildButtonRow(guildConfig, guildId, disabled = false) { - const dmEnabled = guildConfig.dmOnClose !== false; - return new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`ticket_cfg_dm_toggle_${guildId}`) - .setLabel('DM on Close') - .setStyle(dmEnabled ? ButtonStyle.Success : ButtonStyle.Danger) - .setEmoji(dmEnabled ? '📬' : '📭') - .setDisabled(disabled), - new ButtonBuilder() - .setCustomId(`ticket_cfg_staff_role_btn_${guildId}`) - .setLabel('Staff Role') - .setStyle(ButtonStyle.Secondary) - .setEmoji('🛡️') - .setDisabled(disabled), - new ButtonBuilder() - .setCustomId(`ticket_cfg_delete_${guildId}`) - .setLabel('Delete System') - .setStyle(ButtonStyle.Danger) - .setEmoji('🗑️') - .setDisabled(disabled), - ); -} - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -async function refreshDashboard(rootInteraction, guildConfig, guildId) { - const buttonRow = buildButtonRow(guildConfig, guildId); - const selectRow = new ActionRowBuilder().addComponents(buildSelectMenu(guildId)); - await InteractionHelper.safeEditReply(rootInteraction, { - embeds: [buildDashboardEmbed(guildConfig, rootInteraction.guild)], - components: [buttonRow, selectRow], - }).catch(() => {}); -} - -/** - * Attempts to find and edit the live ticket panel message in the panel channel. - * Returns true if the panel was found and updated, false otherwise. - */ -async function updateLivePanel(client, guild, config) { - if (!config.ticketPanelChannelId) return false; - try { - const channel = await guild.channels.fetch(config.ticketPanelChannelId).catch(() => null); - if (!channel) return false; - - const messages = await channel.messages.fetch({ limit: 50 }); - const panelMsg = messages.find( - m => - m.author.id === client.user.id && - m.components?.length > 0 && - m.components[0]?.components?.[0]?.customId === 'create_ticket', - ); - if (!panelMsg) return false; - - const updatedEmbed = new EmbedBuilder() - .setTitle('🎫 Support Tickets') - .setDescription(config.ticketPanelMessage || 'Click the button below to create a support ticket.') - .setColor(getColor('info')); - - const button = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('create_ticket') - .setLabel(config.ticketButtonLabel || 'Create Ticket') - .setStyle(ButtonStyle.Primary) - .setEmoji('📩'), - ); - - await panelMsg.edit({ embeds: [updatedEmbed], components: [button] }); - return true; - } catch (error) { - logger.warn('Failed to update live ticket panel:', error.message); - return false; - } -} - -// ─── Main Export ────────────────────────────────────────────────────────────── - -export default { - async execute(interaction, config, client) { - try { - const guildId = interaction.guild.id; - const guildConfig = await getGuildConfig(client, guildId); - - if (!guildConfig.ticketPanelChannelId) { - throw new TitanBotError( - 'Ticket system not configured', - ErrorTypes.CONFIGURATION, - 'The ticket system has not been set up yet. Run `/ticket setup` first to configure it.', - ); - } - - const selectMenu = buildSelectMenu(guildId); - const selectRow = new ActionRowBuilder().addComponents(selectMenu); - const buttonRow = buildButtonRow(guildConfig, guildId); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [buildDashboardEmbed(guildConfig, interaction.guild)], - components: [buttonRow, selectRow], - }); - - const replyMessage = await interaction.fetchReply().catch(() => null); - const replyMessageId = replyMessage?.id; - - const collector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.StringSelect, - filter: i => - i.user.id === interaction.user.id && - i.customId === `ticket_config_${guildId}` && - (!replyMessageId || i.message.id === replyMessageId), - time: 600_000, - }); - - const buttonCollector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.Button, - filter: i => - i.user.id === interaction.user.id && - (!replyMessageId || i.message.id === replyMessageId) && - (i.customId === `ticket_cfg_dm_toggle_${guildId}` || - i.customId === `ticket_cfg_staff_role_btn_${guildId}` || - i.customId === `ticket_cfg_delete_${guildId}`), - - time: 600_000, - }); - - collector.on('collect', async (selectInteraction) => { - const selectedOption = selectInteraction.values[0]; - try { - switch (selectedOption) { - case 'panel_message': - await handlePanelMessage(selectInteraction, interaction, guildConfig, guildId, client); - break; - case 'button_label': - await handleButtonLabel(selectInteraction, interaction, guildConfig, guildId, client); - break; - case 'staff_role': - await handleStaffRole(selectInteraction, interaction, guildConfig, guildId, client); - break; - case 'open_category': - await handleOpenCategory(selectInteraction, interaction, guildConfig, guildId, client); - break; - case 'closed_category': - await handleClosedCategory(selectInteraction, interaction, guildConfig, guildId, client); - break; - case 'max_tickets': - await handleMaxTickets(selectInteraction, interaction, guildConfig, guildId, client); - break; - case 'logs_channel': - await handleLogsChannel(selectInteraction, interaction, guildConfig, guildId, client); - break; - case 'transcript_channel': - await handleTranscriptChannel(selectInteraction, interaction, guildConfig, guildId, client); - break; - } - } catch (error) { - if (error instanceof TitanBotError) { - logger.debug(`Ticket config validation error: ${error.message}`); - } else { - logger.error('Unexpected ticket config menu error:', error); - } - - const errorMessage = - error instanceof TitanBotError - ? error.userMessage || 'An error occurred while processing your selection.' - : 'An unexpected error occurred while updating the configuration.'; - - // Already deferred at the top of the collector - await selectInteraction - .followUp({ - embeds: [errorEmbed('Configuration Error', errorMessage)], - flags: MessageFlags.Ephemeral, - }) - .catch(() => {}); - } - }); - - buttonCollector.on('collect', async (btnInteraction) => { - try { - if (btnInteraction.customId === `ticket_cfg_dm_toggle_${guildId}`) { - await handleDmOnClose(btnInteraction, interaction, guildConfig, guildId, client); - } else if (btnInteraction.customId === `ticket_cfg_staff_role_btn_${guildId}`) { - await handleStaffRole(btnInteraction, interaction, guildConfig, guildId, client); - } else if (btnInteraction.customId === `ticket_cfg_delete_${guildId}`) { - await handleDeleteSystem(btnInteraction, interaction, guildConfig, guildId, client); - } - } catch (error) { - if (error.code === 40060) return; - if (error instanceof TitanBotError) { - logger.debug(`Ticket config button error: ${error.message}`); - } else { - logger.error('Unexpected ticket config button error:', error); - } - const errorMessage = - error instanceof TitanBotError - ? error.userMessage || 'An error occurred while processing your selection.' - : 'An unexpected error occurred while updating the configuration.'; - - // Already deferred at the top of the collector - await btnInteraction - .followUp({ - embeds: [errorEmbed('Configuration Error', errorMessage)], - flags: MessageFlags.Ephemeral, - }) - .catch(() => {}); - } - }); - - collector.on('end', async (collected, reason) => { - buttonCollector.stop(); - if (reason === 'time') { - const timeoutEmbed = new EmbedBuilder() - .setTitle('⏰ Dashboard Timed Out') - .setDescription('This dashboard has been closed due to inactivity. Please run the command again to continue.') - .setColor(getColor('error')); - await InteractionHelper.safeEditReply(interaction, { - embeds: [timeoutEmbed], - components: [], - }).catch(() => {}); - } - }); - } catch (error) { - if (error instanceof TitanBotError) throw error; - logger.error('Unexpected error in ticket_config:', error); - throw new TitanBotError( - `Ticket config failed: ${error.message}`, - ErrorTypes.UNKNOWN, - 'Failed to open the ticket configuration dashboard.', - ); - } - }, -}; - -// ─── Panel Message ──────────────────────────────────────────────────────────── - -async function handlePanelMessage(selectInteraction, rootInteraction, guildConfig, guildId, client) { - const modal = new ModalBuilder() - .setCustomId('ticket_cfg_panel_msg') - .setTitle('Edit Panel Message') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('panel_msg_input') - .setLabel('Panel Message') - .setStyle(TextInputStyle.Paragraph) - .setValue( - guildConfig.ticketPanelMessage || - 'Click the button below to create a support ticket.', - ) - .setMaxLength(2000) - .setMinLength(1) - .setRequired(true) - .setPlaceholder('Click the button below to create a support ticket.'), - ), - ); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => - i.customId === 'ticket_cfg_panel_msg' && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - const newMessage = submitted.fields.getTextInputValue('panel_msg_input').trim(); - guildConfig.ticketPanelMessage = newMessage; - await client.db.set(getGuildConfigKey(guildId), guildConfig); - - const panelUpdated = await updateLivePanel(client, rootInteraction.guild, guildConfig); - - await submitted.reply({ - embeds: [ - successEmbed( - '✅ Panel Message Updated', - `The panel message has been updated.${ - panelUpdated - ? '\nThe live ticket panel has also been refreshed.' - : '\n> **Note:** The live panel could not be located. The new message will apply the next time you run `/ticket setup`.' - }`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, guildConfig, guildId); -} - -// ─── Button Label ───────────────────────────────────────────────────────────── - -async function handleButtonLabel(selectInteraction, rootInteraction, guildConfig, guildId, client) { - const modal = new ModalBuilder() - .setCustomId('ticket_cfg_btn_label') - .setTitle('Edit Button Label') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('btn_label_input') - .setLabel('Button Label (max 80 characters)') - .setStyle(TextInputStyle.Short) - .setValue(guildConfig.ticketButtonLabel || 'Create Ticket') - .setMaxLength(80) - .setMinLength(1) - .setRequired(true) - .setPlaceholder('Create Ticket'), - ), - ); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => - i.customId === 'ticket_cfg_btn_label' && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - const newLabel = submitted.fields.getTextInputValue('btn_label_input').trim(); - guildConfig.ticketButtonLabel = newLabel; - await client.db.set(getGuildConfigKey(guildId), guildConfig); - - const panelUpdated = await updateLivePanel(client, rootInteraction.guild, guildConfig); - - await submitted.reply({ - embeds: [ - successEmbed( - '✅ Button Label Updated', - `Button label changed to \`${newLabel}\`.${ - panelUpdated - ? '\nThe live ticket panel button has also been updated.' - : '\n> **Note:** The live panel could not be located. The new label will apply the next time you run `/ticket setup`.' - }`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, guildConfig, guildId); -} - -// ─── Staff Role ─────────────────────────────────────────────────────────────── - -async function handleStaffRole(selectInteraction, rootInteraction, guildConfig, guildId, client) { - await selectInteraction.deferUpdate(); - - const roleSelect = new RoleSelectMenuBuilder() - .setCustomId('ticket_cfg_staff_role') - .setPlaceholder('Select the staff role...') - .setMaxValues(1); - - const row = new ActionRowBuilder().addComponents(roleSelect); - - await selectInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('🛡️ Change Staff Role') - .setDescription( - `**Current:** ${guildConfig.ticketStaffRoleId ? `<@&${guildConfig.ticketStaffRoleId}>` : '`Not set`'}\n\nSelect the role that should have staff access to manage tickets.`, - ) - .setColor(getColor('info')), - ], - components: [row], - flags: MessageFlags.Ephemeral, - }); - - const roleCollector = rootInteraction.channel.createMessageComponentCollector({ - componentType: ComponentType.RoleSelect, - filter: i => - i.user.id === selectInteraction.user.id && i.customId === 'ticket_cfg_staff_role', - time: 60_000, - max: 1, - }); - - roleCollector.on('collect', async roleInteraction => { - await roleInteraction.deferUpdate(); - const role = roleInteraction.roles.first(); - - guildConfig.ticketStaffRoleId = role.id; - await client.db.set(getGuildConfigKey(guildId), guildConfig); - - await roleInteraction.followUp({ - embeds: [successEmbed('✅ Staff Role Updated', `Staff role set to ${role}.`)], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, guildConfig, guildId); - }); - - roleCollector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - selectInteraction - .followUp({ - embeds: [errorEmbed('Timed Out', 'No role was selected. The staff role was not changed.')], - flags: MessageFlags.Ephemeral, - }) - .catch(() => {}); - } - }); -} - -// ─── Open Tickets Category ──────────────────────────────────────────────────── - -async function handleOpenCategory(selectInteraction, rootInteraction, guildConfig, guildId, client) { - await selectInteraction.deferUpdate(); - - const channelSelect = new ChannelSelectMenuBuilder() - .setCustomId('ticket_cfg_open_cat') - .setPlaceholder('Select a category...') - .addChannelTypes(ChannelType.GuildCategory) - .setMaxValues(1); - - const row = new ActionRowBuilder().addComponents(channelSelect); - - await selectInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('📁 Change Open Tickets Category') - .setDescription( - `**Current:** ${guildConfig.ticketCategoryId ? `<#${guildConfig.ticketCategoryId}>` : '`Not set`'}\n\nSelect the category where new tickets will be created.`, - ) - .setColor(getColor('info')), - ], - components: [row], - flags: MessageFlags.Ephemeral, - }); - - const catCollector = rootInteraction.channel.createMessageComponentCollector({ - componentType: ComponentType.ChannelSelect, - filter: i => - i.user.id === selectInteraction.user.id && i.customId === 'ticket_cfg_open_cat', - time: 60_000, - max: 1, - }); - - catCollector.on('collect', async catInteraction => { - await catInteraction.deferUpdate(); - const category = catInteraction.channels.first(); - - guildConfig.ticketCategoryId = category.id; - await client.db.set(getGuildConfigKey(guildId), guildConfig); - - await catInteraction.followUp({ - embeds: [ - successEmbed( - '✅ Open Category Updated', - `New tickets will now be created in **${category.name}**.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, guildConfig, guildId); - }); - - catCollector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - selectInteraction - .followUp({ - embeds: [ - errorEmbed('Timed Out', 'No category was selected. The setting was not changed.'), - ], - flags: MessageFlags.Ephemeral, - }) - .catch(() => {}); - } - }); -} - -// ─── Closed Tickets Category ────────────────────────────────────────────────── - -async function handleClosedCategory( - selectInteraction, - rootInteraction, - guildConfig, - guildId, - client, -) { - await selectInteraction.deferUpdate(); - - const channelSelect = new ChannelSelectMenuBuilder() - .setCustomId('ticket_cfg_closed_cat') - .setPlaceholder('Select a category...') - .addChannelTypes(ChannelType.GuildCategory) - .setMaxValues(1); - - const row = new ActionRowBuilder().addComponents(channelSelect); - - await selectInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('📂 Change Closed Tickets Category') - .setDescription( - `**Current:** ${guildConfig.ticketClosedCategoryId ? `<#${guildConfig.ticketClosedCategoryId}>` : '`Not set`'}\n\nSelect the category where closed tickets will be moved.`, - ) - .setColor(getColor('info')), - ], - components: [row], - flags: MessageFlags.Ephemeral, - }); - - const catCollector = rootInteraction.channel.createMessageComponentCollector({ - componentType: ComponentType.ChannelSelect, - filter: i => - i.user.id === selectInteraction.user.id && i.customId === 'ticket_cfg_closed_cat', - time: 60_000, - max: 1, - }); - - catCollector.on('collect', async catInteraction => { - await catInteraction.deferUpdate(); - const category = catInteraction.channels.first(); - - guildConfig.ticketClosedCategoryId = category.id; - await client.db.set(getGuildConfigKey(guildId), guildConfig); - - await catInteraction.followUp({ - embeds: [ - successEmbed( - '✅ Closed Category Updated', - `Closed tickets will now be moved to **${category.name}**.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, guildConfig, guildId); - }); - - catCollector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - selectInteraction - .followUp({ - embeds: [ - errorEmbed('Timed Out', 'No category was selected. The setting was not changed.'), - ], - flags: MessageFlags.Ephemeral, - }) - .catch(() => {}); - } - }); -} - -// ─── Max Tickets per User ───────────────────────────────────────────────────── - -async function handleMaxTickets(selectInteraction, rootInteraction, guildConfig, guildId, client) { - const modal = new ModalBuilder() - .setCustomId('ticket_cfg_max_tickets') - .setTitle('Set Max Tickets per User') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('max_tickets_input') - .setLabel('Max Open Tickets (1–10)') - .setStyle(TextInputStyle.Short) - .setValue(String(guildConfig.maxTicketsPerUser || 3)) - .setMaxLength(2) - .setMinLength(1) - .setRequired(true) - .setPlaceholder('3'), - ), - ); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => - i.customId === 'ticket_cfg_max_tickets' && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - const raw = submitted.fields.getTextInputValue('max_tickets_input').trim(); - const newMax = parseInt(raw, 10); - - if (isNaN(newMax) || newMax < 1 || newMax > 10) { - await submitted.reply({ - embeds: [errorEmbed('Invalid Value', 'Max tickets must be a whole number between **1** and **10**.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - - guildConfig.maxTicketsPerUser = newMax; - await client.db.set(getGuildConfigKey(guildId), guildConfig); - - await submitted.reply({ - embeds: [ - successEmbed( - '✅ Max Tickets Updated', - `Users can now have at most **${newMax}** open ticket${newMax !== 1 ? 's' : ''} at a time.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, guildConfig, guildId); -} - -// ─── DM on Close Toggle ─────────────────────────────────────────────────────── - -async function handleDmOnClose(btnInteraction, rootInteraction, guildConfig, guildId, client) { - await btnInteraction.deferUpdate(); - - const newState = guildConfig.dmOnClose === false; - guildConfig.dmOnClose = newState; - await client.db.set(getGuildConfigKey(guildId), guildConfig); - - await btnInteraction.followUp({ - embeds: [ - successEmbed( - '✅ DM on Close Updated', - `Users will **${newState ? 'now' : 'no longer'}** receive a DM when their ticket is closed.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, guildConfig, guildId); -} - -// ─── Feedback Logs Channel ──────────────────────────────────────────────────── - -async function handleLogsChannel(selectInteraction, rootInteraction, guildConfig, guildId, client) { - await selectInteraction.deferUpdate(); - - const channelSelect = new ChannelSelectMenuBuilder() - .setCustomId('ticket_cfg_logs_channel') - .setPlaceholder('Select a channel...') - .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.') - .setColor(getColor('info')) - ], - components: [new ActionRowBuilder().addComponents(channelSelect)], - flags: MessageFlags.Ephemeral - }); - - 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, - max: 1 - }); - - collector.on('collect', async channelInteraction => { - await channelInteraction.deferUpdate(); - const channel = channelInteraction.channels.first(); - - guildConfig.ticketLogsChannelId = channel.id; - await client.db.set(getGuildConfigKey(guildId), guildConfig); - - await channelInteraction.followUp({ - embeds: [successEmbed('✅ Logs Channel Updated', `Ticket logs will be sent to ${channel}`)], - flags: MessageFlags.Ephemeral - }); - - await refreshDashboard(rootInteraction, guildConfig, guildId); - }); - - collector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - selectInteraction.followUp({ - embeds: [errorEmbed('Timed Out', 'No channel selected. No changes were made.')], - flags: MessageFlags.Ephemeral - }).catch(() => {}); - } - }); -} - -// ─── Transcript Channel ─────────────────────────────────────────────────────── - -async function handleTranscriptChannel(selectInteraction, rootInteraction, guildConfig, guildId, client) { - await selectInteraction.deferUpdate(); - - const channelSelect = new ChannelSelectMenuBuilder() - .setCustomId('ticket_cfg_transcript_channel') - .setPlaceholder('Select a channel...') - .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.') - .setColor(getColor('info')) - ], - components: [new ActionRowBuilder().addComponents(channelSelect)], - flags: MessageFlags.Ephemeral - }); - - 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, - max: 1 - }); - - collector.on('collect', async channelInteraction => { - await channelInteraction.deferUpdate(); - const channel = channelInteraction.channels.first(); - - guildConfig.ticketTranscriptChannelId = channel.id; - await client.db.set(getGuildConfigKey(guildId), guildConfig); - - await channelInteraction.followUp({ - embeds: [successEmbed('✅ Transcript Channel Updated', `Transcripts will be sent to ${channel}`)], - flags: MessageFlags.Ephemeral - }); - - await refreshDashboard(rootInteraction, guildConfig, guildId); - }); - - collector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - selectInteraction.followUp({ - embeds: [errorEmbed('Timed Out', 'No channel selected. No changes were made.')], - flags: MessageFlags.Ephemeral - }).catch(() => {}); - } - }); -} - -// ─── Check User Tickets ─────────────────────────────────────────────────────── - -async function handleCheckUser(selectInteraction, rootInteraction, guildConfig, guildId, client) { - await selectInteraction.deferUpdate(); - - const userSelect = new UserSelectMenuBuilder() - .setCustomId('ticket_cfg_check_user') - .setPlaceholder('Select a user to check...') - .setMaxValues(1); - - const row = new ActionRowBuilder().addComponents(userSelect); - - await selectInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('🔍 Check User Tickets') - .setDescription('Select a user to view their current open ticket count.') - .setColor(getColor('info')), - ], - components: [row], - flags: MessageFlags.Ephemeral, - }); - - const userCollector = rootInteraction.channel.createMessageComponentCollector({ - componentType: ComponentType.UserSelect, - filter: i => - i.user.id === selectInteraction.user.id && i.customId === 'ticket_cfg_check_user', - time: 60_000, - max: 1, - }); - - userCollector.on('collect', async userInteraction => { - await userInteraction.deferUpdate(); - const targetUser = userInteraction.users.first(); - const maxTickets = guildConfig.maxTicketsPerUser || 3; - const openCount = await getUserTicketCount(guildId, targetUser.id); - const atLimit = openCount >= maxTickets; - - await userInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle(`🎫 Ticket Check — ${targetUser.username}`) - .setDescription( - `**Open Tickets:** ${openCount} / ${maxTickets}\n` + - `**Remaining:** ${Math.max(0, maxTickets - openCount)}\n\n` + - (atLimit - ? '⚠️ This user has reached their ticket limit.' - : '✅ This user can still open more tickets.'), - ) - .setColor(atLimit ? getColor('error') : getColor('success')) - .setThumbnail(targetUser.displayAvatarURL({ size: 64 })) - .setTimestamp(), - ], - flags: MessageFlags.Ephemeral, - }); - }); - - userCollector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - selectInteraction - .followUp({ - embeds: [errorEmbed('Timed Out', 'No user was selected.')], - flags: MessageFlags.Ephemeral, - }) - .catch(() => {}); - } - }); -} - -// ─── Delete Ticket System ───────────────────────────────────────────────────── - -async function handleDeleteSystem(btnInteraction, rootInteraction, guildConfig, guildId, client) { - const deleteModal = new ModalBuilder() - .setCustomId('ticket_delete_confirm_modal') - .setTitle('Delete Ticket System') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('delete_confirmation') - .setLabel('Type "DELETE" to confirm') - .setStyle(TextInputStyle.Short) - .setPlaceholder('DELETE') - .setMaxLength(6) - .setMinLength(6) - .setRequired(true) - ) - ); - - await btnInteraction.showModal(deleteModal); - - const submitted = await btnInteraction - .awaitModalSubmit({ - filter: i => i.customId === 'ticket_delete_confirm_modal' && i.user.id === btnInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) { - await refreshDashboard(rootInteraction, guildConfig, guildId); - return; - } - - const confirmation = submitted.fields.getTextInputValue('delete_confirmation').trim(); - - if (confirmation !== 'DELETE') { - await submitted.reply({ - embeds: [errorEmbed('Incorrect Confirmation', 'You must type "DELETE" exactly to confirm deletion.')], - flags: MessageFlags.Ephemeral, - }); - await refreshDashboard(rootInteraction, guildConfig, guildId); - return; - } - - await submitted.deferUpdate(); - - const keysToDelete = [ - 'ticketPanelChannelId', - 'ticketPanelMessageId', - 'ticketStaffRoleId', - 'ticketCategoryId', - 'ticketClosedCategoryId', - 'ticketPanelMessage', - 'ticketButtonLabel', - 'maxTicketsPerUser', - 'dmOnClose', - ]; - - // Delete the panel embed from Discord - if (guildConfig.ticketPanelChannelId) { - try { - const panelChannel = await client.guilds.cache.get(guildId)?.channels.fetch(guildConfig.ticketPanelChannelId).catch(() => null); - if (panelChannel) { - if (guildConfig.ticketPanelMessageId) { - const panelMessage = await panelChannel.messages.fetch(guildConfig.ticketPanelMessageId).catch(() => null); - if (panelMessage) await panelMessage.delete().catch(() => {}); - } else { - // Fallback: scan for the panel by button customId - const messages = await panelChannel.messages.fetch({ limit: 50 }).catch(() => null); - if (messages) { - const found = messages.find( - m => m.author.id === client.user.id && - m.components?.[0]?.components?.[0]?.customId === 'create_ticket' - ); - if (found) await found.delete().catch(() => {}); - } - } - } - } catch (panelDeleteError) { - logger.warn('Could not delete ticket panel message:', panelDeleteError.message); - } - } - - // Clear all open ticket records for the guild from the database - try { - const { pgConfig } = await import('../../../config/postgres.js'); - if (client.db?.db?.pool && typeof client.db.db.isAvailable === 'function' && client.db.db.isAvailable()) { - await client.db.db.pool.query( - `DELETE FROM ${pgConfig.tables.tickets} WHERE guild_id = $1`, - [guildId] - ); - } - } catch (ticketDeleteError) { - logger.warn('Could not clear ticket records from database:', ticketDeleteError.message); - } - - for (const key of keysToDelete) { - delete guildConfig[key]; - } - await client.db.set(getGuildConfigKey(guildId), guildConfig); - - await submitted.followUp({ - embeds: [ - successEmbed( - '✅ Ticket System Deleted', - 'All ticket system configuration has been cleared. Run `/ticket setup` to set it up again.', - ), - ], - flags: MessageFlags.Ephemeral, - }); - - await InteractionHelper.safeEditReply(rootInteraction, { - embeds: [ - new EmbedBuilder() - .setTitle('🗑️ Ticket System Deleted') - .setDescription('The ticket system configuration has been cleared.') - .setColor(getColor('error')) - .setTimestamp(), - ], - components: [], - }).catch(() => {}); -} \ No newline at end of file diff --git a/src/commands/Ticket/priority.js b/src/commands/Ticket/priority.js deleted file mode 100644 index 113bef5eed..0000000000 --- a/src/commands/Ticket/priority.js +++ /dev/null @@ -1,119 +0,0 @@ -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; -import { errorEmbed, successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getTicketPermissionContext } from '../../utils/ticketPermissions.js'; -import { updateTicketPriority } from '../../services/ticket.js'; - -export default { - data: new SlashCommandBuilder() - .setName("priority") - .setDescription("Sets the priority level for the current support ticket.") - .addStringOption((option) => - option - .setName("level") - .setDescription("The priority level for the ticket.") - .setRequired(true) - .addChoices( - { name: "🔴 Urgent", value: "urgent" }, - { name: "🟠 High", value: "high" }, - { name: "🟡 Medium", value: "medium" }, - { name: "🟢 Low", value: "low" }, - { name: "⚪ None", value: "none" }, - ), - ) - .setDMPermission(false), - category: "Ticket", - - async execute(interaction, guildConfig, client) { - try { - - const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - if (!deferred) { - return; - } - - const permissionContext = await getTicketPermissionContext({ client, interaction }); - if (!permissionContext.ticketData) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Not a Ticket Channel", - "This command can only be used in a valid ticket channel.", - ), - ], - }); - } - - if (!permissionContext.canManageTicket) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Permission Denied", - "You need the `Manage Channels` permission or the configured `Ticket Staff Role` to change ticket priority.", - ), - ], - }); - } - - const priorityLevel = interaction.options.getString("level"); - const result = await updateTicketPriority(interaction.channel, priorityLevel, interaction.user); - - if (!result.success) { - logger.warn('Priority update failed - not a valid ticket channel', { - userId: interaction.user.id, - channelId: interaction.channel.id, - guildId: interaction.guildId, - error: result.error - }); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Not a Ticket Channel", - result.error || "This command can only be used in a valid ticket channel.", - ), - ], - }); - } - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "Priority Updated", - `Ticket priority set to **${priorityLevel.toUpperCase()}**.`, - ), - ], - }); - - logger.info('Ticket priority updated successfully', { - userId: interaction.user.id, - userTag: interaction.user.tag, - channelId: interaction.channel.id, - channelName: interaction.channel.name, - guildId: interaction.guildId, - priority: priorityLevel, - commandName: 'priority' - }); - - } catch (error) { - logger.error('Error executing priority command', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - channelId: interaction.channel?.id, - guildId: interaction.guildId, - commandName: 'priority' - }); - await handleInteractionError(interaction, error, { - commandName: 'priority', - source: 'ticket_priority_command' - }); - } - }, -}; - - - - diff --git a/src/commands/Ticket/ticket.js b/src/commands/Ticket/ticket.js deleted file mode 100644 index e3a1a5015b..0000000000 --- a/src/commands/Ticket/ticket.js +++ /dev/null @@ -1,336 +0,0 @@ -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getGuildConfig } from '../../services/guildConfig.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; - -import ticketConfig from './modules/ticket_dashboard.js'; - -export default { - data: new SlashCommandBuilder() - .setName("ticket") - .setDescription("Manages the server's ticket system.") - .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels) - .addSubcommand((subcommand) => - subcommand - .setName("setup") - .setDescription( - "Sets up the ticket creation panel in a specified channel.", - ) - .addChannelOption((option) => - option -.setName("panel_channel") - .setDescription( - "The channel where the ticket panel will be sent.", - ) - .addChannelTypes(ChannelType.GuildText) - .setRequired(true), - ) - - .addStringOption((option) => - option - .setName("panel_message") - .setDescription( - "The main message/description for the ticket panel.", - ) - .setRequired(true), - ) - .addStringOption((option) => - option - .setName("button_label") - .setDescription( - "The label for the ticket creation button (default: Create Ticket)", - ) - .setRequired(false), - ) - .addChannelOption((option) => - option - .setName("category") - .setDescription( - "The category where new tickets will be created (optional).", - ) - .addChannelTypes(ChannelType.GuildCategory) - .setRequired(false), - ) - .addChannelOption((option) => - option - .setName("closed_category") - .setDescription( - "The category where closed tickets will be moved (optional).", - ) - .addChannelTypes(ChannelType.GuildCategory) - .setRequired(false), - ) - .addRoleOption((option) => - option - .setName("staff_role") - .setDescription( - "The role that can access tickets (optional).", - ) - .setRequired(false), - ) - .addIntegerOption((option) => - option - .setName("max_tickets_per_user") - .setDescription("Maximum number of tickets a user can create (default: 3)") - .setMinValue(1) - .setMaxValue(10) - .setRequired(false), - ) - .addBooleanOption((option) => - option - .setName("dm_on_close") - .setDescription("Send DM to user when their ticket is closed (default: true)") - .setRequired(false), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("dashboard") - .setDescription("Open the interactive ticket system dashboard"), - ), - category: "ticket", - - async execute(interaction, config, client) { - try { - - const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - if (!deferred) { - return; - } - - if ( - !interaction.member.permissions.has( - PermissionFlagsBits.ManageChannels, - ) - ) { - logger.warn('Ticket command permission denied', { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'ticket' - }); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Permission Denied", - "You need the `Manage Channels` permission for this action.", - ), - ], - }); - } - - const subcommand = interaction.options.getSubcommand(); - - if (subcommand === "dashboard") { - return ticketConfig.execute(interaction, config, client); - } - - if (subcommand === "setup") { - const existingConfig = await getGuildConfig(client, interaction.guildId); - if (existingConfig?.ticketPanelChannelId) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - 'Ticket System Already Active', - `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"); - const categoryChannel = interaction.options.getChannel("category"); - const closedCategoryChannel = interaction.options.getChannel("closed_category"); - const staffRole = interaction.options.getRole("staff_role"); -const panelMessage = interaction.options.getString("panel_message") || "Click the button below to create a support ticket."; - const buttonLabel = - interaction.options.getString("button_label") || -"Create Ticket"; - const maxTicketsPerUser = interaction.options.getInteger("max_tickets_per_user") || 3; -const dmOnClose = interaction.options.getBoolean("dm_on_close") !== false; - - const setupEmbed = createEmbed({ - title: "🎫 Support Tickets", -description: panelMessage, - color: getColor('info') - }); - - const ticketButton = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId("create_ticket") -.setLabel(buttonLabel) - .setStyle(ButtonStyle.Primary) - .setEmoji("📩"), - ); - - try { - await panelChannel.send({ - embeds: [setupEmbed], - components: [ticketButton], - }); - - if (client.db && interaction.guildId) { - const currentConfig = existingConfig; - currentConfig.ticketCategoryId = categoryChannel ? categoryChannel.id : null; - currentConfig.ticketClosedCategoryId = closedCategoryChannel ? closedCategoryChannel.id : null; - currentConfig.ticketStaffRoleId = staffRole ? staffRole.id : null; - currentConfig.ticketPanelChannelId = panelChannel.id; - currentConfig.ticketPanelMessage = panelMessage; - currentConfig.ticketButtonLabel = buttonLabel; - currentConfig.maxTicketsPerUser = maxTicketsPerUser; - currentConfig.dmOnClose = dmOnClose; - - const { getGuildConfigKey } = await import('../../utils/database.js'); - const configKey = getGuildConfigKey(interaction.guildId); - await client.db.set(configKey, currentConfig); - logger.info('Ticket configuration saved', { - guildId: interaction.guildId, - categoryId: categoryChannel?.id, - closedCategoryId: closedCategoryChannel?.id, - staffRoleId: staffRole?.id, - maxTickets: maxTicketsPerUser, - dmOnClose: dmOnClose - }); - } - - let successMessage = `The ticket creation panel has been sent to ${panelChannel}. `; - - if (categoryChannel) { - successMessage += `New tickets will be created in the **${categoryChannel.name}** category. `; - } else { - successMessage += 'New tickets will be created in a new "Tickets" category. '; - } - - if (closedCategoryChannel) { - successMessage += `Closed tickets will be moved to **${closedCategoryChannel.name}**. `; - } - - if (staffRole) { - successMessage += `**${staffRole.name}** role will have access to tickets. `; - } - - successMessage += `\n\n**Max Tickets Per User:** ${maxTicketsPerUser}\n**DM on Close:** ${dmOnClose ? 'Enabled' : 'Disabled'}`; - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "Ticket Panel Set Up", - successMessage, - ), - ], - }); - - logger.info('Ticket panel setup completed', { - userId: interaction.user.id, - userTag: interaction.user.tag, - guildId: interaction.guildId, - panelChannelId: panelChannel.id, - categoryId: categoryChannel?.id, - closedCategoryId: closedCategoryChannel?.id, - staffRoleId: staffRole?.id, - maxTickets: maxTicketsPerUser, - dmOnClose: dmOnClose, - commandName: 'ticket_setup' - }); - - const logEmbed = createEmbed({ - title: "🔧 Ticket System Setup (Configuration Log)", - description: `The ticket panel was set up in ${panelChannel} by ${interaction.user}.`, - color: getColor('warning') - }) - .addFields( - { - name: "Panel Channel", - value: panelChannel.toString(), - inline: true, - }, - { - name: "Ticket Category", - value: categoryChannel - ? categoryChannel.toString() - : "None specified.", - inline: true, - }, - { - name: "Closed Category", - value: closedCategoryChannel - ? closedCategoryChannel.toString() - : "None specified.", - inline: true, - }, - { - name: "Staff Role", - value: staffRole - ? staffRole.toString() - : "None specified.", - inline: true, - }, - { - name: "Max Tickets Per User", - value: maxTicketsPerUser.toString(), - inline: true, - }, - { - name: "DM on Close", - value: dmOnClose ? 'Enabled' : 'Disabled', - inline: true, - }, - { - name: "Moderator", - value: `${interaction.user.tag} (${interaction.user.id})`, - inline: false, - }, - ); - - - } catch (error) { - logger.error('Ticket setup error', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'ticket_setup' - }); - if (interaction.deferred || interaction.replied) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Setup Failed", - "Could not send the ticket panel or save configuration. Check the bot's permissions (especially the ability to send messages in the target channel) and database connection.", - ), - ], - }).catch(err => { - logger.error('Failed to send error reply', { - error: err.message, - guildId: interaction.guildId - }); - }); - } else { - await handleInteractionError(interaction, error, { - commandName: 'ticket_setup', - source: 'ticket_setup_command' - }); - } - } - } - } catch (error) { - logger.error('Error executing ticket command', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'ticket' - }); - await handleInteractionError(interaction, error, { - commandName: 'ticket', - source: 'ticket_command_main' - }); - } - } -}; - - - diff --git a/src/commands/Tools/baseconvert.js b/src/commands/Tools/baseconvert.js deleted file mode 100644 index 53984f15a5..0000000000 --- a/src/commands/Tools/baseconvert.js +++ /dev/null @@ -1,248 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getColor } from '../../config/bot.js'; - -const BASE_ALPHABETS = { - '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' }, - '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' }, - 'B62': { base: 62, prefix: '', name: 'Base62', alphabet: '0-9A-Za-z' }, -}; - -const BASE_NAMES = Object.entries(BASE_ALPHABETS).map(([key, { name }]) => ({ name: `${key} (${name})`, value: key })); -const BASE_CHARSETS = { - BIN: '01', - OCT: '01234567', - DEC: '0123456789', - HEX: '0123456789ABCDEF', - B36: '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ', - B58: '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz', - B62: '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', -}; - -function parseBigIntFromBase(value, baseKey) { - if (baseKey === 'B64') { - const bytes = Buffer.from(value, 'base64'); - return bytes.reduce((acc, byte) => (acc * 256n) + BigInt(byte), 0n); - } - - const charset = BASE_CHARSETS[baseKey]; - if (!charset) { - throw new Error(`Unsupported base: ${baseKey}`); - } - - const normalized = ['BIN', 'OCT', 'DEC', 'HEX', 'B36'].includes(baseKey) - ? value.toUpperCase() - : value; - - let result = 0n; - const base = BigInt(charset.length); - - for (const char of normalized) { - const digit = charset.indexOf(char); - if (digit < 0) { - throw new Error(`Invalid character '${char}' for base ${baseKey}`); - } - result = (result * base) + BigInt(digit); - } - - return result; -} - -function formatBigIntToBase(value, baseKey) { - if (baseKey === 'B64') { - if (value === 0n) { - return Buffer.from([0]).toString('base64'); - } - - const bytes = []; - let n = value; - while (n > 0n) { - bytes.unshift(Number(n & 0xffn)); - n >>= 8n; - } - - return Buffer.from(bytes).toString('base64'); - } - - const charset = BASE_CHARSETS[baseKey]; - if (!charset) { - throw new Error(`Unsupported base: ${baseKey}`); - } - - if (value === 0n) { - return '0'; - } - - const base = BigInt(charset.length); - let n = value; - let output = ''; - - while (n > 0n) { - const index = Number(n % base); - output = charset[index] + output; - n /= base; - } - - return output; -} - -export default { - data: new SlashCommandBuilder() - .setName('baseconvert') - .setDescription('Convert numbers between different bases') - .addStringOption(option => - option.setName('number') - .setDescription('The number to convert') - .setRequired(true)) - .addStringOption(option => - option.setName('from') - .setDescription('Source base/format') - .setRequired(true) - .addChoices(...BASE_NAMES)) - .addStringOption(option => - option.setName('to') - .setDescription('Target base/format (default: all)') - .setRequired(false) - .addChoices(...BASE_NAMES)), - - async execute(interaction) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`BaseConvert interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'baseconvert' - }); - return; - } - - try { - const numberStr = interaction.options.getString('number').trim(); - const fromBase = interaction.options.getString('from'); - const toBase = interaction.options.getString('to'); - - const { prefix: fromPrefix, name: fromName } = BASE_ALPHABETS[fromBase]; - - const cleanNumber = fromPrefix && numberStr.startsWith(fromPrefix) - ? numberStr.slice(fromPrefix.length) - : numberStr; - - if (!cleanNumber) { - const embed = errorEmbed('❌ Empty Input', 'You must provide a number to convert.\n\n**Example:** `/baseconvert number:1010 from:BIN to:DEC`'); - embed.setColor(getColor('error')); - return InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - }); - } - - const alphabet = BASE_ALPHABETS[fromBase].alphabet; - const regex = new RegExp(`^[${alphabet}]+$`, 'i'); - - if (!regex.test(cleanNumber)) { - let examples = ''; - if (fromBase === 'BIN') { - examples = '\n\n**Valid:** 101, 1010, 11111 | **Invalid:** 5 (digit 5 not allowed)'; - } else if (fromBase === 'OCT') { - examples = '\n\n**Valid:** 77, 123, 755 | **Invalid:** 8 (only 0-7 allowed)'; - } else if (fromBase === 'DEC') { - examples = '\n\n**Valid:** 42, 123, 999 | **Invalid:** 12.34 (no decimals)'; - } else if (fromBase === 'HEX') { - examples = '\n\n**Valid:** FF, A1B2, DEADBEEF | **Invalid:** G (only 0-9, A-F)'; - } - const embed = errorEmbed( - `❌ Invalid ${fromName}`, - `You provided: \`${cleanNumber}\`\n\nValid characters: \`${alphabet}\`${examples}` - ); - embed.setColor(getColor('error')); - logger.warn(`Invalid base conversion input: ${cleanNumber} for base ${fromBase}`); - return InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - }); - } - - let decimalValue; - try { - if (fromBase === 'B64') { - decimalValue = parseBigIntFromBase(cleanNumber, fromBase); - } else { - decimalValue = parseBigIntFromBase(cleanNumber, fromBase); - } - } catch (error) { - logger.error('Base conversion parse error:', error); - const embed = errorEmbed('⚠️ Conversion Failed', 'The number is too large to process.\n\nTry with a smaller number.'); - embed.setColor(getColor('error')); - return InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - }); - } - - if (toBase) { - const { prefix: toPrefix, name: toName } = BASE_ALPHABETS[toBase]; - let result; - - try { - result = formatBigIntToBase(decimalValue, toBase); - - const embed = successEmbed( - '🔄 Base Conversion Result', - `**From ${fromName} (${fromBase}):** \`${fromPrefix}${cleanNumber}\`\n` + - `**To ${toName} (${toBase}):** \`${toPrefix}${result}\`\n` + - `**Decimal:** \`${decimalValue.toLocaleString()}\`` - ); - embed.setColor(getColor('success')); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - - } catch (error) { - logger.error(`Base conversion error to ${toName}:`, error); - const embed = errorEmbed(`⚠️ Failed to Convert to ${toName}`, 'The result would be too large or incompatible.\n\nTry with a smaller number or different target base.'); - embed.setColor(getColor('error')); - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); - } - - } else { - let description = `**Input (${fromName}):** \`${fromPrefix}${cleanNumber}\`\n`; - description += `**Decimal:** \`${decimalValue.toLocaleString()}\`\n\n`; - - for (const [baseKey, { prefix, name }] of Object.entries(BASE_ALPHABETS)) { - if (baseKey === fromBase) continue; - - try { - let value = formatBigIntToBase(decimalValue, baseKey); - - description += `**${name} (${baseKey}):** \`${prefix}${value}\`\n`; - } catch (error) { - description += `**${name} (${baseKey}):** *Too large to convert*\n`; - } - } - - const embed = successEmbed( - '🔄 Base Conversion Results', - description - ); - embed.setColor(getColor('primary')); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } - - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'baseconvert' - }); - } - }, -}; - - - diff --git a/src/commands/Tools/calculate.js b/src/commands/Tools/calculate.js deleted file mode 100644 index 82b3c402ca..0000000000 --- a/src/commands/Tools/calculate.js +++ /dev/null @@ -1,360 +0,0 @@ -import { SlashCommandBuilder, ButtonBuilder, ButtonStyle, ActionRowBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { getColor } from '../../config/bot.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { evaluateMathExpression } from '../../utils/safeMathParser.js'; - -// Store calculation context for modal handlers -const calculationContexts = new Map(); - -function evaluate(expression) { - return evaluateMathExpression(expression); -} - -const calculationHistory = new Map(); -const MAX_HISTORY = 5; - -export { calculationContexts }; - -export default { - data: new SlashCommandBuilder() - .setName("calculate") - .setDescription("Evaluate a mathematical expression") - .addStringOption((option) => - option - .setName("expression") - .setDescription( - "The mathematical expression to evaluate (e.g., 2+2*3, sin(45 deg), 16^0.5)", - ) - .setRequired(true), - ), - - async execute(interaction) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Calculate interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'calculate' - }); - return; - } - -try { - - const expression = interaction.options.getString("expression"); - - if ( - !/^[0-9+\-*/.()^%! ,<>=&|~?:\[\]{}a-z√π∞°]+$/i.test(expression) - ) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "❌ Invalid Expression", - "**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", - ), - ], - }); - } - - const dangerousPatterns = [ - /\b(?:import|require|process|fs|child_process|exec|eval|Function|setTimeout|setInterval|new\s+Function)\s*\(/i, - /`/g, -/\$\{.*\}/, - /\b(?:localStorage|document|window|fetch|XMLHttpRequest)\b/, - /\b(?:while|for)\s*\([^)]*\)\s*\{/, - /\b(?:function\*|yield|await|async)\b/, - ]; - - for (const pattern of dangerousPatterns) { - if (pattern.test(expression)) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "🔒 Security Alert", - "**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.", - ), - ], - flags: ["Ephemeral"], - }); - } - } - - let result; - try { - result = evaluate(expression); - - let formattedResult; - if (typeof result === "number") { - formattedResult = result.toLocaleString("en-US", { - maximumFractionDigits: 10, - }); - - if ( - Math.abs(result) > 0 && - (Math.abs(result) >= 1e10 || Math.abs(result) < 1e-3) - ) { - formattedResult = result.toExponential(6); - } - } else if (typeof result === "boolean") { - formattedResult = result ? "true" : "false"; - } else if (result === null || result === undefined) { - formattedResult = "No result"; - } else if ( - Array.isArray(result) || - typeof result === "object" - ) { - formattedResult = - "```json\n" + JSON.stringify(result, null, 2) + "\n```"; - } else { - formattedResult = String(result); - } - - const userId = interaction.user.id; - if (!calculationHistory.has(userId)) { - calculationHistory.set(userId, []); - } - - const history = calculationHistory.get(userId); - history.unshift({ - expression, - result: formattedResult, - timestamp: Date.now(), - }); - - if (history.length > MAX_HISTORY) { - history.pop(); - } - - const row = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`calc_${interaction.id}_add`) - .setLabel("+") - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId(`calc_${interaction.id}_subtract`) - .setLabel("-") - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId(`calc_${interaction.id}_multiply`) - .setLabel("×") - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId(`calc_${interaction.id}_divide`) - .setLabel("÷") - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId(`calc_${interaction.id}_history`) - .setLabel("History") - .setStyle(ButtonStyle.Secondary), - ); - - const embed = successEmbed( - "🧮 Calculation Result", - `**Expression:** \`${expression.replace(/`/g, "\`")}\`\n` + - `**Result:** \`${formattedResult}\`\n\n` + - `*Use the buttons below to perform operations with the result.*`, - ); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - components: [row], - }); - - const filter = (i) => - i.customId.startsWith(`calc_${interaction.id}`) && - i.user.id === interaction.user.id; -const BUTTON_TIMEOUT = 300000; - const collector = - interaction.channel.createMessageComponentCollector({ - filter, - time: BUTTON_TIMEOUT, - }); - - collector.on("collect", async (i) => { - try { - const operation = i.customId.split("_")[2]; - - if (operation === "history") { - if (!i.deferred && !i.replied) { - await i.deferUpdate().catch(console.error); - } - - const userHistory = - calculationHistory.get(userId) || []; - - if (userHistory.length === 0) { - await i.followUp({ - content: "No calculation history found.", - flags: ["Ephemeral"], - }); - return; - } - - const historyText = userHistory - .map( - (item, index) => - `${index + 1}. **${item.expression}** = \`${item.result}\`\n` + - ` `, - ) - .join("\n\n"); - - await i.followUp({ - content: `📜 **Your Calculation History**\n\n${historyText}`, - flags: ["Ephemeral"], - }); - return; - } - - let operator = ""; - - switch (operation) { - case "add": - operator = "+"; - break; - case "subtract": - operator = "-"; - break; - case "multiply": - operator = "*"; - break; - case "divide": - operator = "/"; - break; - } - - try { - const contextKey = `${i.user.id}_${operation}`; - calculationContexts.set(contextKey, { - expression, - formattedResult, - operator, - messageId: interaction.message?.id, - channelId: interaction.channelId, - userId: i.user.id - }); - - await i.showModal({ - customId: `calc_modal:${operation}`, - title: `Enter a number to ${operation}`, - components: [ - { - type: 1, - components: [ - { - type: 4, - customId: `operand:${contextKey}`, - label: `Number to ${operator} with ${formattedResult}`, - placeholder: "Enter a number...", - style: 1, - required: true, - maxLength: 50, - }, - ], - }, - ], - }); - } catch (modalError) { - logger.error("Failed to show modal:", modalError); - if (!i.replied && !i.deferred) { - await i.reply({ - content: "Failed to open calculator. Please try again.", - flags: ["Ephemeral"], - }).catch(console.error); - } - return; - } - - } catch (error) { - logger.error("Button interaction error:", error); - if (!i.deferred && !i.replied) { - await i.followUp({ - content: "An error occurred while processing your request.", - flags: ["Ephemeral"], - }).catch(console.error); - } - } - }); - - collector.on("end", (collected, reason) => { - if (reason === "timeout") { - const disabledRow = - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId( - `calc_${interaction.id}_expired`, - ) - .setLabel("Calculator Expired") - .setStyle(ButtonStyle.Secondary) - .setDisabled(true), - ); - - interaction - .editReply({ - components: [disabledRow], - content: - "⏱️ This calculator has expired. Use the command again to perform more calculations.", - }) - .catch(console.error); - } else { - const disabledRow = ActionRowBuilder.from( - row, - ).setComponents( - row.components.map((component) => - ButtonBuilder.from(component).setDisabled(true), - ), - ); - - interaction - .editReply({ components: [disabledRow] }) - .catch(console.error); - } - }); - } catch (error) { - logger.error('Calculation error:', error); - - let errorMessage = 'Failed to evaluate the expression. '; - - if (error.message.includes('Unexpected type')) { - errorMessage += - 'The expression contains an unsupported operation or function.'; - } else if (error.message.includes('Undefined symbol')) { - errorMessage += - 'The expression contains an undefined variable or function.'; - } else if (error.message.includes('Brackets not balanced')) { - errorMessage += 'The expression has unbalanced brackets.'; - } else if ( - error.message.includes('Unexpected operator') || - error.message.includes('Unexpected character') - ) { - errorMessage += - 'The expression contains an invalid operator or character.'; - } else { - errorMessage += 'Please check the syntax and try again.'; - } - - const embed = errorEmbed('Calculation Error', errorMessage); - embed.setColor(getColor('error')); - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - }); - } - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'calculate' - }); - } - }, -}; - - - - - diff --git a/src/commands/Tools/countdown.js b/src/commands/Tools/countdown.js deleted file mode 100644 index 64ffbc2f30..0000000000 --- a/src/commands/Tools/countdown.js +++ /dev/null @@ -1,104 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { createControlButtons, formatTime, startCountdown } from '../../handlers/countdownButtons.js'; - -const activeCountdowns = new Map(); - -export { activeCountdowns }; - -export default { - data: new SlashCommandBuilder() - .setName("countdown") - .setDescription("Start a countdown timer") - .addIntegerOption((option) => - option - .setName("minutes") - .setDescription("Number of minutes to count down (0-1440)") - .setMinValue(0) - .setMaxValue(1440) - .setRequired(false), - ) - .addIntegerOption((option) => - option - .setName("seconds") - .setDescription("Number of seconds to count down (0-59)") - .setMinValue(0) - .setMaxValue(59) - .setRequired(false), - ) - .addStringOption((option) => - option - .setName("title") - .setDescription("Optional title for the countdown") - .setRequired(false), - ), - - async execute(interaction) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Countdown interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'countdown' - }); - return; - } - - try { - const minutes = interaction.options.getInteger("minutes") || 0; - const seconds = interaction.options.getInteger("seconds") || 0; - const title = interaction.options.getString("title") || "Countdown Timer"; - - const totalSeconds = minutes * 60 + seconds; - - if (totalSeconds <= 0) { - throw new Error("Please specify a duration of at least 1 second."); - } - - if (totalSeconds > 86400) { - throw new Error("Countdown cannot be longer than 24 hours."); - } - - const endTime = Date.now() + totalSeconds * 1000; - const countdownId = `${interaction.channelId}-${Date.now()}`; - - const row = createControlButtons(countdownId); - - const initialEmbed = successEmbed( - `⏱️ ${title}`, - `Time remaining: **${formatTime(totalSeconds)}**`, - ); - - const message = await interaction.channel.send({ - embeds: [initialEmbed], - components: [row], - }); - - const countdownData = { - message, - endTime, - remainingTime: totalSeconds * 1000, - isPaused: false, - title, - lastUpdate: Date.now(), - interval: null, - }; - - activeCountdowns.set(countdownId, countdownData); - startCountdown(countdownId, countdownData, activeCountdowns); - - await InteractionHelper.safeEditReply(interaction, { - content: "✅ Countdown started!", - flags: MessageFlags.Ephemeral, - }); - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'countdown' - }); - } - }, -}; diff --git a/src/commands/Tools/embedbuilder.js b/src/commands/Tools/embedbuilder.js deleted file mode 100644 index c398ce2a5e..0000000000 --- a/src/commands/Tools/embedbuilder.js +++ /dev/null @@ -1,1198 +0,0 @@ -import { - SlashCommandBuilder, - PermissionFlagsBits, - ActionRowBuilder, - StringSelectMenuBuilder, - StringSelectMenuOptionBuilder, - ChannelSelectMenuBuilder, - ModalBuilder, - TextInputBuilder, - TextInputStyle, - ButtonBuilder, - ButtonStyle, - MessageFlags, - ComponentType, - ChannelType, - EmbedBuilder, - LabelBuilder, - RadioGroupBuilder, -} from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { successEmbed, errorEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { getColor } from '../../config/bot.js'; - -// ─── Constants ──────────────────────────────────────────────────────────────── - -const MAX_FIELDS = 25; -const IDLE_TIMEOUT = 900_000; // 15 minutes - -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: '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: '🎨' }, -]; - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -function isValidUrl(str) { - try { - const url = new URL(str); - return url.protocol === 'http:' || url.protocol === 'https:'; - } catch { - return false; - } -} - -function isValidHex(str) { - return /^#[0-9A-Fa-f]{6}$/.test(str); -} - -// ─── Embed Builders ──────────────────────────────────────────────────────────── - -/** - * Builds the live preview embed from current state. - */ -function buildPreviewEmbed(state) { - const embed = new EmbedBuilder(); - - if (state.title) embed.setTitle(state.title.substring(0, 256)); - if (state.description) embed.setDescription(state.description.substring(0, 4096)); - - try { - embed.setColor(state.color || getColor('primary')); - } catch { - embed.setColor(getColor('primary')); - } - - if (state.author?.name) { - const obj = { 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) }; - if (state.footer.iconUrl && isValidUrl(state.footer.iconUrl)) obj.iconURL = state.footer.iconUrl; - embed.setFooter(obj); - } - - if (state.thumbnail && isValidUrl(state.thumbnail)) embed.setThumbnail(state.thumbnail); - if (state.image && isValidUrl(state.image)) embed.setImage(state.image); - if (state.timestamp) embed.setTimestamp(); - - if (state.fields.length > 0) embed.addFields(state.fields.slice(0, 25)); - - // Ensure the embed renders if completely empty - if ( - !state.title && - !state.description && - state.fields.length === 0 && - !state.author?.name - ) { - embed.setDescription('*(Empty — use the menu below to add content)*'); - } - - return embed; -} - -/** - * Builds the status/control dashboard embed (shown below the preview). - */ -function buildDashboardEmbed(state) { - const trunc = (str, n) => - 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}`, - ]; - - return new EmbedBuilder() - .setTitle('🛠️ Embed Builder — Control Panel') - .setDescription(lines.join('\n')) - .setColor(getColor('info')) - .setFooter({ text: 'The preview above updates live · Closes after 5 min of inactivity' }); -} - -/** - * Builds the main action select menu. - */ -function buildMainMenu(state) { - const select = new StringSelectMenuBuilder() - .setCustomId('eb_menu') - .setPlaceholder('Choose an action...') - .addOptions( - new StringSelectMenuOptionBuilder() - .setLabel('Edit Content') - .setDescription('Set the title and description') - .setValue('edit_content') - .setEmoji('✏️'), - new StringSelectMenuOptionBuilder() - .setLabel('Set Color') - .setDescription('Pick a preset or enter a custom hex code') - .setValue('set_color') - .setEmoji('🎨'), - new StringSelectMenuOptionBuilder() - .setLabel('Set Author') - .setDescription('Configure the author block at the top of the embed') - .setValue('set_author') - .setEmoji('👤'), - new StringSelectMenuOptionBuilder() - .setLabel('Set Footer') - .setDescription('Configure the footer text and icon') - .setValue('set_footer') - .setEmoji('📄'), - new StringSelectMenuOptionBuilder() - .setLabel('Set Images') - .setDescription('Set the thumbnail or large banner image') - .setValue('set_images') - .setEmoji('🖼️'), - new StringSelectMenuOptionBuilder() - .setLabel(`Add Field (${state.fields.length}/${MAX_FIELDS})`) - .setDescription('Add a new inline or block field') - .setValue('add_field') - .setEmoji('➕'), - ); - - if (state.fields.length > 0) { - select.addOptions( - new StringSelectMenuOptionBuilder() - .setLabel('Edit Field') - .setDescription('Modify the name, value, or inline setting of a field') - .setValue('edit_field') - .setEmoji('📝'), - new StringSelectMenuOptionBuilder() - .setLabel('Remove Field') - .setDescription('Delete a field from the embed') - .setValue('remove_field') - .setEmoji('➖'), - ); - - if (state.fields.length >= 2) { - select.addOptions( - new StringSelectMenuOptionBuilder() - .setLabel('Reorder Fields') - .setDescription('Move a field up or down in the list') - .setValue('reorder_fields') - .setEmoji('↕️'), - ); - } - } - - select.addOptions( - new StringSelectMenuOptionBuilder() - .setLabel(state.timestamp ? 'Disable Timestamp' : 'Enable Timestamp') - .setDescription('Toggle the automatic timestamp in the footer') - .setValue('toggle_timestamp') - .setEmoji('🕐'), - new StringSelectMenuOptionBuilder() - .setLabel('Post Embed') - .setDescription('Send the finished embed to a channel') - .setValue('post_embed') - .setEmoji('📤'), - new StringSelectMenuOptionBuilder() - .setLabel('JSON / Raw Data') - .setDescription('View the raw JSON for this embed') - .setValue('json_export') - .setEmoji('📋'), - new StringSelectMenuOptionBuilder() - .setLabel('Reset Everything') - .setDescription('Clear all fields and start over') - .setValue('reset_all') - .setEmoji('🗑️'), - ); - - return select; -} - -/** - * Updates the dashboard message with the latest state. - */ -async function refreshDashboard(interaction, state) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [buildPreviewEmbed(state), buildDashboardEmbed(state)], - components: [new ActionRowBuilder().addComponents(buildMainMenu(state))], - }); -} - -// ─── Option Handlers ────────────────────────────────────────────────────────── - -async function handleEditContent(selectInteraction, rootInteraction, state) { - const modal = new ModalBuilder() - .setCustomId('eb_content') - .setTitle('Edit Content') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('eb_title') - .setLabel('Title (max 256 characters)') - .setStyle(TextInputStyle.Short) - .setValue(state.title || '') - .setMaxLength(256) - .setRequired(false) - .setPlaceholder('My Embed Title'), - ), - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('eb_description') - .setLabel('Description (max 4000 characters)') - .setStyle(TextInputStyle.Paragraph) - .setValue(state.description ? state.description.substring(0, 4000) : '') - .setMaxLength(4000) - .setRequired(false) - .setPlaceholder('Write your embed description here...'), - ), - ); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => i.customId === 'eb_content' && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - // Defer immediately to avoid interaction timeout - await submitted.deferUpdate().catch(() => {}); - - state.title = submitted.fields.getTextInputValue('eb_title').trim() || null; - state.description = submitted.fields.getTextInputValue('eb_description').trim() || null; - - await refreshDashboard(rootInteraction, state); -} - -async function handleSetColor(selectInteraction, rootInteraction, state) { - await selectInteraction.deferUpdate().catch(() => {}); - - const colorSelect = new StringSelectMenuBuilder() - .setCustomId('eb_color_pick') - .setPlaceholder('Choose a color...') - .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'), - ), - ); - - await selectInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('🎨 Set Color') - .setDescription( - 'Select a preset color or choose **Custom Hex** to enter your own `#RRGGBB` value.', - ) - .setColor(getColor('info')), - ], - components: [new ActionRowBuilder().addComponents(colorSelect)], - flags: MessageFlags.Ephemeral, - }); - - const colorCollector = rootInteraction.channel.createMessageComponentCollector({ - componentType: ComponentType.StringSelect, - filter: i => - i.user.id === selectInteraction.user.id && i.customId === 'eb_color_pick', - time: 60_000, - max: 1, - }); - - colorCollector.on('collect', async colorInter => { - const picked = colorInter.values[0]; - - if (picked === '__custom__') { - const hexModal = new ModalBuilder() - .setCustomId('eb_custom_hex') - .setTitle('Custom Color') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('hex_value') - .setLabel('Hex Color Code') - .setStyle(TextInputStyle.Short) - .setPlaceholder('#5865F2') - .setMaxLength(7) - .setMinLength(7) - .setRequired(true), - ), - ); - - await colorInter.showModal(hexModal); - - const hexSubmit = await colorInter - .awaitModalSubmit({ - filter: i => - i.customId === 'eb_custom_hex' && i.user.id === colorInter.user.id, - time: 60_000, - }) - .catch(() => null); - - if (!hexSubmit) return; - - const hex = hexSubmit.fields.getTextInputValue('hex_value').trim(); - if (!isValidHex(hex)) { - await hexSubmit.reply({ - embeds: [ - errorEmbed( - 'Invalid Hex', - `\`${hex}\` is not a valid hex color. Use the format \`#RRGGBB\` (e.g. \`#5865F2\`).`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - return; - } - - state.color = hex; - await hexSubmit.deferUpdate().catch(() => {}); - } else { - state.color = picked; - await colorInter.deferUpdate().catch(() => {}); - } - - await refreshDashboard(rootInteraction, state); - }); -} - -async function handleSetAuthor(selectInteraction, rootInteraction, state) { - const modal = new ModalBuilder() - .setCustomId('eb_author') - .setTitle('Set Author') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('author_name') - .setLabel('Author Name (leave blank to remove)') - .setStyle(TextInputStyle.Short) - .setValue(state.author?.name || '') - .setMaxLength(256) - .setRequired(false) - .setPlaceholder('Your Name'), - ), - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('author_icon') - .setLabel('Author Icon URL (optional)') - .setStyle(TextInputStyle.Short) - .setValue(state.author?.iconUrl || '') - .setRequired(false) - .setPlaceholder('https://example.com/icon.png'), - ), - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('author_url') - .setLabel('Author Link URL (optional)') - .setStyle(TextInputStyle.Short) - .setValue(state.author?.url || '') - .setRequired(false) - .setPlaceholder('https://example.com'), - ), - ); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => i.customId === 'eb_author' && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - const name = submitted.fields.getTextInputValue('author_name').trim(); - const iconUrl = submitted.fields.getTextInputValue('author_icon').trim(); - const url = submitted.fields.getTextInputValue('author_url').trim(); - - if (iconUrl && !isValidUrl(iconUrl)) { - await submitted.reply({ - embeds: [errorEmbed('Invalid URL', 'Author icon URL must be a valid `https://` URL.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - if (url && !isValidUrl(url)) { - await submitted.reply({ - embeds: [errorEmbed('Invalid URL', 'Author link URL must be a valid `https://` URL.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - - state.author = name ? { name, iconUrl: iconUrl || null, url: url || null } : null; - - await submitted.deferUpdate().catch(() => {}); - await refreshDashboard(rootInteraction, state); -} - -async function handleSetFooter(selectInteraction, rootInteraction, state) { - const modal = new ModalBuilder() - .setCustomId('eb_footer') - .setTitle('Set Footer') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('footer_text') - .setLabel('Footer Text (leave blank to remove)') - .setStyle(TextInputStyle.Short) - .setValue(state.footer?.text || '') - .setMaxLength(2048) - .setRequired(false) - .setPlaceholder('Built with TitanBot'), - ), - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('footer_icon') - .setLabel('Footer Icon URL (optional)') - .setStyle(TextInputStyle.Short) - .setValue(state.footer?.iconUrl || '') - .setRequired(false) - .setPlaceholder('https://example.com/icon.png'), - ), - ); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => i.customId === 'eb_footer' && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - const text = submitted.fields.getTextInputValue('footer_text').trim(); - const iconUrl = submitted.fields.getTextInputValue('footer_icon').trim(); - - if (iconUrl && !isValidUrl(iconUrl)) { - await submitted.reply({ - embeds: [errorEmbed('Invalid URL', 'Footer icon URL must be a valid `https://` URL.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - - state.footer = text ? { text, iconUrl: iconUrl || null } : null; - - await submitted.deferUpdate().catch(() => {}); - await refreshDashboard(rootInteraction, state); -} - -async function handleSetImages(selectInteraction, rootInteraction, state) { - await selectInteraction.deferUpdate().catch(() => {}); - - const imageSelect = new StringSelectMenuBuilder() - .setCustomId('eb_image_pick') - .setPlaceholder('What would you like to change?') - .addOptions( - new StringSelectMenuOptionBuilder() - .setLabel('Set Thumbnail') - .setDescription('Small image displayed in the top-right corner') - .setValue('set_thumbnail') - .setEmoji('🖼️'), - new StringSelectMenuOptionBuilder() - .setLabel('Set Large Image') - .setDescription('Full-width banner image at the bottom') - .setValue('set_image') - .setEmoji('📸'), - new StringSelectMenuOptionBuilder() - .setLabel('Clear Thumbnail') - .setDescription('Remove the current thumbnail') - .setValue('clear_thumbnail') - .setEmoji('🗑️'), - new StringSelectMenuOptionBuilder() - .setLabel('Clear Large Image') - .setDescription('Remove the current large image') - .setValue('clear_image') - .setEmoji('🗑️'), - ); - - await selectInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('🖼️ Set Images') - .setDescription('Choose which image to set or remove.') - .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 }, - ) - .setColor(getColor('info')), - ], - components: [new ActionRowBuilder().addComponents(imageSelect)], - flags: MessageFlags.Ephemeral, - }); - - const imgMenuCollector = rootInteraction.channel.createMessageComponentCollector({ - componentType: ComponentType.StringSelect, - filter: i => - i.user.id === selectInteraction.user.id && i.customId === 'eb_image_pick', - time: 60_000, - max: 1, - }); - - imgMenuCollector.on('collect', async imgInter => { - const pick = imgInter.values[0]; - - if (pick === 'clear_thumbnail') { - state.thumbnail = null; - await imgInter.deferUpdate(); - await refreshDashboard(rootInteraction, state); - return; - } - if (pick === 'clear_image') { - state.image = null; - await imgInter.deferUpdate(); - await refreshDashboard(rootInteraction, state); - return; - } - - const isThumb = pick === 'set_thumbnail'; - - const urlModal = new ModalBuilder() - .setCustomId('eb_image_url') - .setTitle(isThumb ? 'Set Thumbnail' : 'Set Large Image') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('image_url') - .setLabel('Image URL') - .setStyle(TextInputStyle.Short) - .setValue(isThumb ? (state.thumbnail || '') : (state.image || '')) - .setRequired(true) - .setPlaceholder('https://example.com/image.png'), - ), - ); - - await imgInter.showModal(urlModal); - - const submitted = await imgInter - .awaitModalSubmit({ - filter: i => - i.customId === 'eb_image_url' && i.user.id === imgInter.user.id, - time: 60_000, - }) - .catch(() => null); - - if (!submitted) return; - - const url = submitted.fields.getTextInputValue('image_url').trim(); - if (!isValidUrl(url)) { - await submitted.reply({ - embeds: [ - errorEmbed('Invalid URL', 'Image URL must be a valid `https://` link to a publicly accessible image.'), - ], - flags: MessageFlags.Ephemeral, - }); - return; - } - - if (isThumb) state.thumbnail = url; - else state.image = url; - - await submitted.deferUpdate().catch(() => {}); - await refreshDashboard(rootInteraction, state); - }); -} - -async function handleAddField(selectInteraction, rootInteraction, state) { - if (state.fields.length >= MAX_FIELDS) { - await selectInteraction.deferUpdate(); - await selectInteraction.followUp({ - embeds: [errorEmbed('Fields Full', `Embeds can have a maximum of ${MAX_FIELDS} fields.`)], - flags: MessageFlags.Ephemeral, - }); - return; - } - - const modal = new ModalBuilder() - .setCustomId('eb_add_field') - .setTitle('Add Field'); - - const fieldNameLabel = new LabelBuilder() - .setLabel('Field Name (max 256 characters)') - .setTextInputComponent( - new TextInputBuilder() - .setCustomId('field_name') - .setStyle(TextInputStyle.Short) - .setMaxLength(256) - .setRequired(true) - .setPlaceholder('Field Title'), - ); - - const fieldValueLabel = new LabelBuilder() - .setLabel('Field Value (max 1024 characters)') - .setTextInputComponent( - new TextInputBuilder() - .setCustomId('field_value') - .setStyle(TextInputStyle.Paragraph) - .setMaxLength(1024) - .setRequired(true) - .setPlaceholder('Field content goes here...'), - ); - - const inlineRadio = new RadioGroupBuilder() - .setCustomId('field_inline') - .setRequired(false) - .addOptions([ - { label: 'No — full width', value: 'no' }, - { label: 'Yes — side-by-side', value: 'yes' }, - ]); - - const inlineLabel = new LabelBuilder() - .setLabel('Display inline?') - .setRadioGroupComponent(inlineRadio); - - modal.addLabelComponents(fieldNameLabel, fieldValueLabel, inlineLabel); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => i.customId === 'eb_add_field' && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - const name = submitted.fields.getTextInputValue('field_name').trim(); - const value = submitted.fields.getTextInputValue('field_value').trim(); - const inline = submitted.fields.getRadioGroup('field_inline') === 'yes'; - - state.fields.push({ name, value, inline }); - - await submitted.deferUpdate().catch(() => {}); - await refreshDashboard(rootInteraction, state); -} - -async function handleEditField(selectInteraction, rootInteraction, state) { - await selectInteraction.deferUpdate(); - - const pickSelect = new StringSelectMenuBuilder() - .setCustomId('eb_edit_field_pick') - .setPlaceholder('Select a field to edit...') - .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'}`, - ) - .setValue(String(i)) - .setEmoji('📝'), - ), - ); - - await selectInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('📝 Edit Field') - .setDescription('Select the field you want to modify.') - .setColor(getColor('info')), - ], - components: [new ActionRowBuilder().addComponents(pickSelect)], - flags: MessageFlags.Ephemeral, - }); - - const pickCollector = rootInteraction.channel.createMessageComponentCollector({ - componentType: ComponentType.StringSelect, - filter: i => - i.user.id === selectInteraction.user.id && i.customId === 'eb_edit_field_pick', - time: 60_000, - max: 1, - }); - - pickCollector.on('collect', async pickInter => { - const idx = parseInt(pickInter.values[0], 10); - const field = state.fields[idx]; - if (!field) { await pickInter.deferUpdate(); return; } - - const modal = new ModalBuilder() - .setCustomId('eb_edit_field_modal') - .setTitle(`Edit Field ${idx + 1}`); - - const editNameLabel = new LabelBuilder() - .setLabel('Field Name') - .setTextInputComponent( - new TextInputBuilder() - .setCustomId('field_name') - .setStyle(TextInputStyle.Short) - .setValue(field.name) - .setMaxLength(256) - .setRequired(true), - ); - - const editValueLabel = new LabelBuilder() - .setLabel('Field Value') - .setTextInputComponent( - new TextInputBuilder() - .setCustomId('field_value') - .setStyle(TextInputStyle.Paragraph) - .setValue(field.value.substring(0, 4000)) - .setMaxLength(1024) - .setRequired(true), - ); - - const editInlineRadio = new RadioGroupBuilder() - .setCustomId('field_inline') - .setRequired(false) - .addOptions([ - { label: 'No — full width', value: 'no' }, - { label: 'Yes — side-by-side', value: 'yes' }, - ]); - // Pre-select the current value - if (field.inline) { - editInlineRadio.setOptions([ - { label: 'No — full width', value: 'no' }, - { label: 'Yes — side-by-side', value: 'yes', default: true }, - ]); - } - - const editInlineLabel = new LabelBuilder() - .setLabel('Display inline?') - .setRadioGroupComponent(editInlineRadio); - - modal.addLabelComponents(editNameLabel, editValueLabel, editInlineLabel); - - await pickInter.showModal(modal); - - const submitted = await pickInter - .awaitModalSubmit({ - filter: i => - i.customId === 'eb_edit_field_modal' && i.user.id === pickInter.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - const name = submitted.fields.getTextInputValue('field_name').trim(); - const value = submitted.fields.getTextInputValue('field_value').trim(); - const inline = submitted.fields.getRadioGroup('field_inline') === 'yes'; - - state.fields[idx] = { name, value, inline }; - - await submitted.deferUpdate().catch(() => {}); - await refreshDashboard(rootInteraction, state); - }); -} - -async function handleRemoveField(selectInteraction, rootInteraction, state) { - await selectInteraction.deferUpdate(); - - const pickSelect = new StringSelectMenuBuilder() - .setCustomId('eb_remove_field_pick') - .setPlaceholder('Select a field to remove...') - .addOptions( - state.fields.slice(0, 25).map((f, i) => - new StringSelectMenuOptionBuilder() - .setLabel(`${i + 1}. ${f.name.substring(0, 50)}`) - .setDescription( - `${f.value.substring(0, 90)}${f.value.length > 90 ? '…' : ''}`, - ) - .setValue(String(i)) - .setEmoji('➖'), - ), - ); - - await selectInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('➖ Remove Field') - .setDescription('Select the field you want to delete.') - .setColor(getColor('warning')), - ], - components: [new ActionRowBuilder().addComponents(pickSelect)], - flags: MessageFlags.Ephemeral, - }); - - const removeCollector = rootInteraction.channel.createMessageComponentCollector({ - componentType: ComponentType.StringSelect, - filter: i => - i.user.id === selectInteraction.user.id && i.customId === 'eb_remove_field_pick', - time: 60_000, - max: 1, - }); - - removeCollector.on('collect', async removeInter => { - await removeInter.deferUpdate(); - const idx = parseInt(removeInter.values[0], 10); - state.fields.splice(idx, 1); - await refreshDashboard(rootInteraction, state); - }); -} - -async function handleReorderFields(selectInteraction, rootInteraction, state) { - await selectInteraction.deferUpdate(); - - const pickSelect = new StringSelectMenuBuilder() - .setCustomId('eb_reorder_pick') - .setPlaceholder('Select a field to move...') - .addOptions( - state.fields.slice(0, 25).map((f, i) => - new StringSelectMenuOptionBuilder() - .setLabel(`${i + 1}. ${f.name.substring(0, 50)}`) - .setDescription( - `${f.value.substring(0, 90)}${f.value.length > 90 ? '…' : ''}`, - ) - .setValue(String(i)) - .setEmoji('↕️'), - ), - ); - - await selectInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('↕️ Reorder Fields') - .setDescription('Select a field, then use the arrows to move it up or down.') - .setColor(getColor('info')), - ], - components: [new ActionRowBuilder().addComponents(pickSelect)], - flags: MessageFlags.Ephemeral, - }); - - const pickCollector = rootInteraction.channel.createMessageComponentCollector({ - componentType: ComponentType.StringSelect, - filter: i => - i.user.id === selectInteraction.user.id && i.customId === 'eb_reorder_pick', - time: 60_000, - max: 1, - }); - - pickCollector.on('collect', async pickInter => { - await pickInter.deferUpdate(); - const sourceIdx = parseInt(pickInter.values[0], 10); - - const upBtn = new ButtonBuilder() - .setCustomId('eb_reorder_up') - .setLabel('Move Up') - .setStyle(ButtonStyle.Primary) - .setEmoji('⬆️') - .setDisabled(sourceIdx === 0); - - const downBtn = new ButtonBuilder() - .setCustomId('eb_reorder_down') - .setLabel('Move Down') - .setStyle(ButtonStyle.Primary) - .setEmoji('⬇️') - .setDisabled(sourceIdx === state.fields.length - 1); - - const cancelBtn = new ButtonBuilder() - .setCustomId('eb_reorder_cancel') - .setLabel('Cancel') - .setStyle(ButtonStyle.Secondary); - - await pickInter.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('↕️ Move Field') - .setDescription( - `Moving **${state.fields[sourceIdx].name}** — currently at position **${sourceIdx + 1}** of **${state.fields.length}**.`, - ) - .setColor(getColor('info')), - ], - components: [new ActionRowBuilder().addComponents(upBtn, downBtn, cancelBtn)], - flags: MessageFlags.Ephemeral, - }); - - const dirCollector = rootInteraction.channel.createMessageComponentCollector({ - componentType: ComponentType.Button, - filter: i => - i.user.id === selectInteraction.user.id && - ['eb_reorder_up', 'eb_reorder_down', 'eb_reorder_cancel'].includes(i.customId), - time: 30_000, - max: 1, - }); - - dirCollector.on('collect', async dirInter => { - await dirInter.deferUpdate(); - if (dirInter.customId === 'eb_reorder_cancel') return; - - const targetIdx = - dirInter.customId === 'eb_reorder_up' ? sourceIdx - 1 : sourceIdx + 1; - - if (targetIdx < 0 || targetIdx >= state.fields.length) return; - - const temp = state.fields[sourceIdx]; - state.fields[sourceIdx] = state.fields[targetIdx]; - state.fields[targetIdx] = temp; - - await refreshDashboard(rootInteraction, state); - }); - }); -} - -async function handlePostEmbed(selectInteraction, rootInteraction, state, guild) { - if ( - !state.title && - !state.description && - state.fields.length === 0 && - !state.author?.name - ) { - await selectInteraction.deferUpdate(); - await selectInteraction.followUp({ - embeds: [ - errorEmbed( - 'Empty Embed', - 'Add at least a title, description, or field before posting.', - ), - ], - flags: MessageFlags.Ephemeral, - }); - return; - } - - await selectInteraction.deferUpdate(); - - const chanSelect = new ChannelSelectMenuBuilder() - .setCustomId('eb_post_channel') - .setPlaceholder('Select a channel...') - .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement); - - await selectInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('📤 Post Embed') - .setDescription('Select the channel where this embed will be sent.') - .setColor(getColor('info')), - ], - components: [new ActionRowBuilder().addComponents(chanSelect)], - flags: MessageFlags.Ephemeral, - }); - - const chanCollector = rootInteraction.channel.createMessageComponentCollector({ - componentType: ComponentType.ChannelSelect, - filter: i => - i.user.id === selectInteraction.user.id && i.customId === 'eb_post_channel', - time: 60_000, - max: 1, - }); - - chanCollector.on('collect', async chanInter => { - await chanInter.deferUpdate(); - const channel = chanInter.channels.first(); - - if (!channel) { - await chanInter.followUp({ - embeds: [errorEmbed('No Channel', 'Could not resolve the selected channel.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - - const perms = channel.permissionsFor(guild.members.me); - if (!perms?.has([PermissionFlagsBits.SendMessages, PermissionFlagsBits.EmbedLinks])) { - await chanInter.followUp({ - embeds: [ - errorEmbed( - 'Missing Permissions', - `I need **Send Messages** and **Embed Links** permissions in ${channel} to post there.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - return; - } - - const finalEmbed = buildPreviewEmbed(state); - - // Remove the placeholder description before sending - if (finalEmbed.data.description === '*(Empty — use the menu below to add content)*') { - finalEmbed.setDescription(null); - } - - await channel.send({ embeds: [finalEmbed] }); - - await chanInter.followUp({ - embeds: [successEmbed('✅ Embed Sent', `Your embed has been posted to ${channel}.`)], - flags: MessageFlags.Ephemeral, - }); - }); -} - -async function handleJsonExport(selectInteraction, rootInteraction, state) { - await selectInteraction.deferUpdate(); - - const previewEmbed = buildPreviewEmbed(state); - const json = JSON.stringify(previewEmbed.toJSON(), null, 2); - - if (json.length <= 3980) { - await selectInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('📋 Embed JSON') - .setDescription(`\`\`\`json\n${json}\n\`\`\``) - .setColor(getColor('info')), - ], - flags: MessageFlags.Ephemeral, - }); - } else { - await selectInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('📋 Embed JSON') - .setDescription('The JSON is too long to display inline — see the attached file.') - .setColor(getColor('info')), - ], - files: [ - { - attachment: Buffer.from(json, 'utf-8'), - name: 'embed.json', - }, - ], - flags: MessageFlags.Ephemeral, - }); - } -} - -// ─── Main Export ────────────────────────────────────────────────────────────── - -export default { - data: new SlashCommandBuilder() - .setName('embedbuilder') - .setDescription('Build and post a fully custom embed with live preview') - .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages), - - async execute(interaction) { - try { - const deferSuccess = await InteractionHelper.safeDefer(interaction, { - flags: MessageFlags.Ephemeral, - }); - if (!deferSuccess) return; - - const guild = interaction.guild; - - // Builder state — holds every embed property being constructed - const state = { - title: null, - description: null, - color: getColor('primary'), - author: null, - footer: null, - thumbnail: null, - image: null, - timestamp: false, - fields: [], - }; - - await refreshDashboard(interaction, state); - - const collector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.StringSelect, - filter: i => - i.user.id === interaction.user.id && i.customId === 'eb_menu', - time: IDLE_TIMEOUT, - }); - - collector.on('collect', async ci => { - try { - switch (ci.values[0]) { - case 'edit_content': - await handleEditContent(ci, interaction, state); - break; - case 'set_color': - await handleSetColor(ci, interaction, state); - break; - case 'set_author': - await handleSetAuthor(ci, interaction, state); - break; - case 'set_footer': - await handleSetFooter(ci, interaction, state); - break; - case 'set_images': - await handleSetImages(ci, interaction, state); - break; - case 'add_field': - await handleAddField(ci, interaction, state); - break; - case 'edit_field': - await handleEditField(ci, interaction, state); - break; - case 'remove_field': - await handleRemoveField(ci, interaction, state); - break; - case 'reorder_fields': - await handleReorderFields(ci, interaction, state); - break; - case 'toggle_timestamp': - state.timestamp = !state.timestamp; - await ci.deferUpdate(); - await refreshDashboard(interaction, state); - break; - case 'post_embed': - await handlePostEmbed(ci, interaction, state, guild); - break; - case 'json_export': - await handleJsonExport(ci, interaction, state); - break; - case 'reset_all': - state.title = null; - state.description = null; - state.color = getColor('primary'); - state.author = null; - state.footer = null; - state.thumbnail = null; - state.image = null; - state.timestamp = false; - state.fields = []; - await ci.deferUpdate(); - await refreshDashboard(interaction, state); - break; - default: - await ci.deferUpdate(); - } - } catch (error) { - logger.error('Error in embedbuilder collector:', error); - const msg = - error instanceof TitanBotError - ? error.userMessage || 'An error occurred.' - : 'An unexpected error occurred.'; - if (!ci.replied && !ci.deferred) await ci.deferUpdate().catch(() => {}); - await ci - .followUp({ - embeds: [errorEmbed('Error', msg)], - flags: MessageFlags.Ephemeral, - }) - .catch(() => {}); - } - }); - - collector.on('end', async (_, reason) => { - if (reason === 'time') { - await InteractionHelper.safeEditReply(interaction, { components: [] }).catch(() => {}); - } - }); - } catch (error) { - if (error instanceof TitanBotError) throw error; - logger.error('Unexpected error in embedbuilder:', error); - throw new TitanBotError( - `embedbuilder failed: ${error.message}`, - ErrorTypes.UNKNOWN, - 'Failed to open the embed builder.', - ); - } - }, -}; diff --git a/src/commands/Tools/generatepassword.js b/src/commands/Tools/generatepassword.js deleted file mode 100644 index 07c5cd18d2..0000000000 --- a/src/commands/Tools/generatepassword.js +++ /dev/null @@ -1,158 +0,0 @@ -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName('generatepassword') - .setDescription('Generate a strong, random password') - .addIntegerOption(option => - option.setName('length') - .setDescription('Password length (default: 16, max: 50)') - .setMinValue(8) - .setMaxValue(50) - .setRequired(false)) - .addBooleanOption(option => - option.setName('uppercase') - .setDescription('Include uppercase letters (A-Z)') - .setRequired(false)) - .addBooleanOption(option => - option.setName('numbers') - .setDescription('Include numbers (0-9)') - .setRequired(false)) - .addBooleanOption(option => - option.setName('symbols') - .setDescription('Include symbols (!@#$%^&*)') - .setRequired(false)), - - async execute(interaction) { - const deferSuccess = await InteractionHelper.safeDefer(interaction, { - flags: MessageFlags.Ephemeral - }); - - if (!deferSuccess) { - logger.warn('GeneratePassword interaction defer failed', { - userId: interaction.user?.id, - guildId: interaction.guildId, - commandName: 'generatepassword' - }); - return; - } - - try { - const length = interaction.options.getInteger('length') || 16; - const includeUppercase = interaction.options.getBoolean('uppercase') ?? true; - const includeNumbers = interaction.options.getBoolean('numbers') ?? true; - const includeSymbols = interaction.options.getBoolean('symbols') ?? true; - - if (length < 8 || length > 50) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('❌ Invalid Length', 'Password must be 8-50 characters. You provided: ' + length)], - }); - return; - } - - const lowercase = 'abcdefghijklmnopqrstuvwxyz'; - const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; - const numbers = '0123456789'; - const symbols = '!@#$%^&*()_+-=[]{}|;:,.<>?'; - - let chars = lowercase; - if (includeUppercase) chars += uppercase; - if (includeNumbers) chars += numbers; - if (includeSymbols) chars += symbols; - - let password = ''; - const randomValues = new Uint32Array(length); - crypto.getRandomValues(randomValues); - - for (let i = 0; i < length; i++) { - const randomIndex = randomValues[i] % chars.length; - password += chars[randomIndex]; - } - - if (includeUppercase && !/[A-Z]/.test(password)) { - const randomIndex = Math.floor(Math.random() * length); - const randomUpper = uppercase[Math.floor(Math.random() * uppercase.length)]; - password = password.substring(0, randomIndex) + randomUpper + password.substring(randomIndex + 1); - } - - if (includeNumbers && !/[0-9]/.test(password)) { - const randomIndex = Math.floor(Math.random() * length); - const randomNumber = numbers[Math.floor(Math.random() * numbers.length)]; - password = password.substring(0, randomIndex) + randomNumber + password.substring(randomIndex + 1); - } - - if (includeSymbols && !/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(password)) { - const randomIndex = Math.floor(Math.random() * length); - const randomSymbol = symbols[Math.floor(Math.random() * symbols.length)]; - password = password.substring(0, randomIndex) + randomSymbol + password.substring(randomIndex + 1); - } - - let strength = 'Weak'; - let strengthEmoji = '🔴'; -let strengthColor = getColor('error'); - - const hasLower = /[a-z]/.test(password); - const hasUpper = /[A-Z]/.test(password); - const hasNumber = /[0-9]/.test(password); - const hasSymbol = /[^a-zA-Z0-9]/.test(password); - - const uniqueChars = new Set(password).size; - const uniqueRatio = uniqueChars / password.length; - - let score = 0; - score += password.length * 4; - score += (password.length - (password.match(/[a-z]/g) || []).length) * 2; - score += (password.length - (password.match(/[A-Z]/g) || []).length) * 2; - score += (password.match(/[0-9]/g) || []).length * 4; - score += (password.match(/[^a-zA-Z0-9]/g) || []).length * 6; - - if (uniqueRatio < 0.5) score *= 0.7; - if (hasLower && hasUpper) score *= 1.2; - if (hasNumber) score *= 1.2; - if (hasSymbol) score *= 1.3; - - if (score > 80) { - strength = 'Very Strong'; - strengthEmoji = '🟢'; -strengthColor = getColor('success'); - } else if (score > 60) { - strength = 'Strong'; - strengthEmoji = '🟢'; -strengthColor = getColor('success'); - } else if (score > 40) { - strength = 'Good'; - strengthEmoji = '🟡'; -strengthColor = getColor('warning'); - } else if (score > 20) { - strength = 'Weak'; - strengthEmoji = '🟠'; -strengthColor = getColor('warning'); - } - - const embed = successEmbed( - '🔑 Generated Password', - `**Password:** ||\`${password}\`||\n` + - `**Length:** ${password.length} characters\n` + - `**Strength:** ${strengthEmoji} ${strength}\n` + - `**Contains:** ${hasLower ? 'Lowercase' : ''}${hasUpper ? ', Uppercase' : ''}${hasNumber ? ', Numbers' : ''}${hasSymbol ? ', Symbols' : ''}` - ).setColor(strengthColor); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - }); - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'generatepassword' - }); - } - }, -}; - - - - diff --git a/src/commands/Tools/hexcolor.js b/src/commands/Tools/hexcolor.js deleted file mode 100644 index c50ce19320..0000000000 --- a/src/commands/Tools/hexcolor.js +++ /dev/null @@ -1,144 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { getColor } from '../../config/bot.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName('hexcolor') - .setDescription('Generate a random hex color with preview') - .addStringOption(option => - option.setName('color') - .setDescription('Specific hex color (e.g., #FF5733 or FF5733)') - .setRequired(false)), - - async execute(interaction) { - await InteractionHelper.safeExecute( - interaction, - async () => { - let hexColor = interaction.options.getString('color'); - let isRandom = false; - - if (!hexColor) { - isRandom = true; - hexColor = '#' + Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0'); - } else { - hexColor = hexColor.replace('#', ''); - if (!/^[0-9A-Fa-f]{3,6}$/.test(hexColor)) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('❌ Invalid Hex Color', '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)')], - }); - } - - if (hexColor.length === 3) { - hexColor = hexColor.split('').map(c => c + c).join(''); - } - - hexColor = '#' + hexColor.toUpperCase(); - } - - const r = parseInt(hexColor.slice(1, 3), 16); - const g = parseInt(hexColor.slice(3, 5), 16); - const b = parseInt(hexColor.slice(5, 7), 16); - - const brightness = (r * 299 + g * 587 + b * 114) / 1000; - const textColor = brightness > 128 ? '#000000' : '#FFFFFF'; - - const colorPreviewUrl = `https://dummyimage.com/200x100/${hexColor.replace('#', '')}/${textColor.replace('#', '')}?text=${encodeURIComponent(hexColor)}`; - - const colorName = getColorName(hexColor); - - const embed = successEmbed( - '🎨 Color Information', - `**Hex:** \`${hexColor}\`\n` + - `**RGB:** \`rgb(${r}, ${g}, ${b})\`\n` + - `**HSL:** \`${rgbToHsl(r, g, b)}\`\n` + - `**Name:** ${colorName || 'Custom Color'}` - ) - .setColor(hexColor) - .setImage(colorPreviewUrl); - - if (isRandom) { - embed.setFooter({ text: '✨ Randomly generated color' }); - } - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, - 'Failed to generate color information. Please try again.', - { - autoDefer: true, - deferOptions: { flags: MessageFlags.Ephemeral } - } - ); - }, -}; - -function rgbToHsl(r, g, b) { - 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; - - if (max === min) { -h = s = 0; - } else { - const d = max - min; - s = l > 0.5 ? d / (2 - max - min) : d / (max + min); - switch (max) { - case r: h = (g - b) / d + (g < b ? 6 : 0); break; - case g: h = (b - r) / d + 2; break; - case b: h = (r - g) / d + 4; break; - } - h /= 6; - } - - return `hsl(${Math.round(h * 360)}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%)`; -} - -function getColorName(hex) { - const colors = { - '#FF0000': 'Red', - '#00FF00': 'Green', - '#0000FF': 'Blue', - '#FFFF00': 'Yellow', - '#FF00FF': 'Magenta', - '#00FFFF': 'Cyan', - '#000000': 'Black', - '#FFFFFF': 'White', - '#808080': 'Gray', - '#FFA500': 'Orange', - '#800080': 'Purple', - '#A52A2A': 'Brown', - '#FFC0CB': 'Pink', - '#008000': 'Dark Green', - '#000080': 'Navy', - '#FFD700': 'Gold', - '#C0C0C0': 'Silver', - '#FF6347': 'Tomato', - '#40E0D0': 'Turquoise', - '#E6E6FA': 'Lavender' - }; - - if (colors[hex.toUpperCase()]) { - return colors[hex.toUpperCase()]; - } - - const hexValue = parseInt(hex.replace('#', ''), 16); - let closestColor = ''; - let minDistance = Infinity; - - for (const [colorHex, name] of Object.entries(colors)) { - const colorValue = parseInt(colorHex.replace('#', ''), 16); - const distance = Math.abs(hexValue - colorValue); - - if (distance < minDistance) { - minDistance = distance; - closestColor = name; - } - } - - return minDistance < 1000000 ? `Close to ${closestColor}` : null; -} - - - diff --git a/src/commands/Tools/poll.js b/src/commands/Tools/poll.js deleted file mode 100644 index 080f18b585..0000000000 --- a/src/commands/Tools/poll.js +++ /dev/null @@ -1,124 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { getColor } from '../../config/bot.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -const EMOJIS = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣', '🔟']; -const MAX_OPTIONS = 10; -export default { - data: new SlashCommandBuilder() - .setName('poll') - .setDescription('Create a simple poll with up to 10 options') - .addStringOption(option => - option.setName('question') - .setDescription('The poll question') - .setRequired(true)) - .addStringOption(option => - option.setName('option1') - .setDescription('First option') - .setRequired(true)) - .addStringOption(option => - option.setName('option2') - .setDescription('Second option') - .setRequired(true)) - .addStringOption(option => - option.setName('option3') - .setDescription('Third option (optional)') - .setRequired(false)) - .addStringOption(option => - option.setName('option4') - .setDescription('Fourth option (optional)') - .setRequired(false)) - .addStringOption(option => - option.setName('option5') - .setDescription('Fifth option (optional)') - .setRequired(false)) - .addStringOption(option => - option.setName('option6') - .setDescription('Sixth option (optional)') - .setRequired(false)) - .addStringOption(option => - option.setName('option7') - .setDescription('Seventh option (optional)') - .setRequired(false)) - .addStringOption(option => - option.setName('option8') - .setDescription('Eighth option (optional)') - .setRequired(false)) - .addStringOption(option => - option.setName('option9') - .setDescription('Ninth option (optional)') - .setRequired(false)) - .addStringOption(option => - option.setName('option10') - .setDescription('Tenth option (optional)') - .setRequired(false)) - .addBooleanOption(option => - option.setName('anonymous') - .setDescription('Make the poll anonymous (default: false)') - .setRequired(false)), - - async execute(interaction) { - const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - if (!deferSuccess) { - logger.warn(`Poll interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'poll' - }); - return; - } - - try { - const question = interaction.options.getString('question'); - const isAnonymous = interaction.options.getBoolean('anonymous') || false; - - const options = []; - for (let i = 1; i <= MAX_OPTIONS; i++) { - const option = interaction.options.getString(`option${i}`); - if (option) options.push(option); - } - - if (options.length < 2) { - throw new Error("You must provide at least 2 options for the poll."); - } - - let description = `**${question}**\n\n`; - options.forEach((option, index) => { - description += `${EMOJIS[index]} ${option}\n`; - }); - - if (isAnonymous) { - description += '\n*This is an anonymous poll. Votes are not tracked to users.*'; - } else { - description += '\n*React with the emoji to vote!*'; - } - - const embed = successEmbed( - `📊 ${isAnonymous ? 'Anonymous ' : ''}Poll`, - description - ); - - const message = await interaction.channel.send({ embeds: [embed] }); - - for (let i = 0; i < options.length; i++) { - await message.react(EMOJIS[i]); - await new Promise(resolve => setTimeout(resolve, 500)); - } - - await InteractionHelper.safeEditReply(interaction, { - content: '✅ Poll created successfully!', - }); - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'poll' - }); - } - }, -}; - - - - diff --git a/src/commands/Tools/randomuser.js b/src/commands/Tools/randomuser.js deleted file mode 100644 index d610db34b7..0000000000 --- a/src/commands/Tools/randomuser.js +++ /dev/null @@ -1,203 +0,0 @@ -import { SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { getColor } from '../../config/bot.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export default { - data: new SlashCommandBuilder() - .setName('randomuser') - .setDescription('Select a random user from the server') - .addRoleOption(option => - option.setName('role') - .setDescription('Limit selection to users with this role') - .setRequired(false)) - .addBooleanOption(option => - option.setName('bots') - .setDescription('Include bots in the selection (default: false)') - .setRequired(false)) - .addBooleanOption(option => - option.setName('online') - .setDescription('Only select from online users (default: false)') - .setRequired(false)) - .addBooleanOption(option => - option.setName('mention') - .setDescription('Mention the selected user (default: false)') - .setRequired(false)), - - async execute(interaction) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`RandomUser interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'randomuser' - }); - return; - } - -try { - if (!interaction.guild) { - return interaction.editReply({ - embeds: [errorEmbed('❌ Server Only', 'This command can only be used in a server/guild.')], - }); - } - - const role = interaction.options.getRole('role'); - const includeBots = interaction.options.getBoolean('bots') || false; - const onlineOnly = interaction.options.getBoolean('online') || false; - const shouldMention = interaction.options.getBoolean('mention') || false; - - let members = interaction.guild.members.cache.filter(member => { - if (member.user.bot && !includeBots) return false; - - if (onlineOnly && member.presence?.status === 'offline') return false; - - if (role && !member.roles.cache.has(role.id)) return false; - - return true; - }); - - let memberArray = Array.from(members.values()); - - if (!includeBots) { - memberArray = memberArray.filter(member => !member.user.bot); - } - - if (memberArray.length === 0) { - let errorMessage = 'Could not find any users matching your filters:'; - if (role) errorMessage = `No users have the **${role.name}** role.`; - if (onlineOnly) errorMessage = 'No users are currently online.'; - if (role && onlineOnly) errorMessage = `No **${role.name}** members are online.`; - - return interaction.editReply({ - embeds: [errorEmbed('❌ No Users Found', errorMessage + '\n\nTry adjusting your filters.')], - flags: ["Ephemeral"] - }); - } - - const randomIndex = Math.floor(Math.random() * memberArray.length); - const selectedMember = memberArray[randomIndex]; - - const user = selectedMember.user; - const joinDate = selectedMember.joinedAt; - const roles = selectedMember.roles.cache -.filter(role => role.id !== interaction.guild.id) - .sort((a, b) => b.position - a.position) - .map(role => role.toString()) -.slice(0, 10); - - const embed = successEmbed( - '🎲 Random User Selected', - shouldMention ? `${selectedMember}` : `**${user.username}**` - ) - .setThumbnail(user.displayAvatarURL({ dynamic: true, size: 256 })) - .addFields( - { name: '👤 Username', value: user.username, inline: true }, - { name: '🤖 Bot', value: user.bot ? 'Yes' : 'No', inline: true }, - { name: `🎭 Roles (${roles.length})`, value: roles.length > 0 ? roles.slice(0, 5).join(' ') + (roles.length > 5 ? ` +${roles.length - 5} more` : '') : 'No roles', inline: false } - ) - .setColor('primary'); - - const row = new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setCustomId(`randomuser_${interaction.user.id}_again`) - .setLabel('🎲 Pick Another User') - .setStyle(ButtonStyle.Primary) - ); - - const response = await interaction.editReply({ - content: shouldMention ? `${selectedMember}, you've been chosen!` : null, - embeds: [embed], - components: [row], - allowedMentions: { users: shouldMention ? [user.id] : [] } - }); - - const filter = (i) => i.customId === `randomuser_${interaction.user.id}_again` && i.user.id === interaction.user.id; -const collector = response.createMessageComponentCollector({ filter, time: 300000 }); - - collector.on('collect', async (i) => { - try { - let newMembers = interaction.guild.members.cache.filter(member => { - if (member.user.bot && !includeBots) return false; - - if (onlineOnly && member.presence?.status === 'offline') return false; - - if (role && !member.roles.cache.has(role.id)) return false; - - return true; - }); - - let newMemberArray = Array.from(newMembers.values()); - - if (!includeBots) { - newMemberArray = newMemberArray.filter(member => !member.user.bot); - } - - if (newMemberArray.length === 0) { - await i.update({ - embeds: [errorEmbed('No Users Found', 'No users found matching the criteria.')], - components: [row] - }); - return; - } - - const newRandomIndex = Math.floor(Math.random() * newMemberArray.length); - const newSelectedMember = newMemberArray[newRandomIndex]; - const newUser = newSelectedMember.user; - - const newRoles = newSelectedMember.roles.cache - .filter(r => r.id !== interaction.guild.id) - .sort((a, b) => b.position - a.position) - .map(r => r.toString()) - .slice(0, 10); - - const newEmbed = successEmbed( - '🎲 Random User Selected', - shouldMention ? `${newSelectedMember}` : `**${newUser.username}**` - ) - .setThumbnail(newUser.displayAvatarURL({ dynamic: true, size: 256 })) - .addFields( - { name: '👤 Username', value: newUser.username, inline: true }, - { name: '🤖 Bot', value: newUser.bot ? 'Yes' : 'No', inline: true }, - { name: `🎭 Roles (${newRoles.length})`, value: newRoles.length > 0 ? newRoles.slice(0, 5).join(' ') + (newRoles.length > 5 ? ` +${newRoles.length - 5} more` : '') : 'No roles', inline: false } - ) - .setColor(newSelectedMember.displayHexColor || '#3498db'); - - await i.update({ - content: shouldMention ? `${newSelectedMember}, you've been chosen!` : null, - embeds: [newEmbed], - components: [row], - allowedMentions: { users: shouldMention ? [newUser.id] : [] } - }); - - } catch (error) { - logger.error('Button interaction error:', error); - await i.reply({ - content: 'An error occurred while selecting another user.', - flags: ['Ephemeral'] - }); - } - }); - - collector.on('end', () => { - const disabledRow = ActionRowBuilder.from(row).setComponents( - ButtonBuilder.from(row.components[0]).setDisabled(true) - ); - - interaction.editReply({ components: [disabledRow] }).catch(console.error); - }); - - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'randomuser' - }); - } - }, -}; - - - diff --git a/src/commands/Tools/shorten.js b/src/commands/Tools/shorten.js deleted file mode 100644 index 6512bbff85..0000000000 --- a/src/commands/Tools/shorten.js +++ /dev/null @@ -1,138 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { getColor } from '../../config/bot.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export default { - data: new SlashCommandBuilder() - .setName("shorten") - .setDescription("Shorten a URL using is.gd") - .addStringOption(option => - option - .setName("url") - .setDescription("The URL to shorten") - .setRequired(true) - ) - .addStringOption(option => - option - .setName("custom") - .setDescription("Custom URL ending (optional)") - .setRequired(false) - ) - .setDMPermission(false), - category: "Tools", - - async execute(interaction) { - const deferSuccess = await InteractionHelper.safeDefer(interaction, { - flags: MessageFlags.Ephemeral - }); - if (!deferSuccess) { - logger.warn(`Shorten interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'shorten' - }); - return; - } - - try { - const url = interaction.options.getString("url"); - const custom = interaction.options.getString("custom"); - - try { - new URL(url); - } catch (e) { - const embed = errorEmbed("Invalid URL", "Invalid URL format. Include http:// or https://"); - embed.setColor(getColor('error')); - return InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - }); - } - - if (custom && !/^[a-zA-Z0-9_-]+$/.test(custom)) { - const embed = errorEmbed("Invalid Custom URL", "Custom URL can only contain letters, numbers, underscores, and hyphens."); - embed.setColor(getColor('error')); - return InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - }); - } - - let apiUrl = `https://is.gd/create.php?format=simple&url=${encodeURIComponent(url)}`; - if (custom) { - apiUrl += `&shorturl=${encodeURIComponent(custom)}`; - } - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 10000); - - let response; - try { - response = await fetch(apiUrl, { - signal: controller.signal, - headers: { - 'User-Agent': 'TitanBot URL Shortener/1.0' - } - }); - } catch (networkError) { - const message = networkError?.name === 'AbortError' - ? 'The URL shortener timed out. Please try again in a moment.' - : 'Unable to reach the URL shortener service right now. Please try again later.'; - const embed = errorEmbed('Network Error', message); - embed.setColor(getColor('error')); - return InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - }); - } finally { - clearTimeout(timeout); - } - - if (!response.ok) { - const embed = errorEmbed('URL Shortening Failed', `Shortener service returned HTTP ${response.status}. Please try again later.`); - embed.setColor(getColor('error')); - return InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - }); - } - - const shortUrl = await response.text(); - - try { - new URL(shortUrl); - } catch (e) { - if (shortUrl.includes("already exists")) { - const embed = errorEmbed("URL Already Taken", "That custom URL is already taken. Try a different one."); - embed.setColor(getColor('error')); - return InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - }); - } else if (shortUrl.includes("invalid")) { - const embed = errorEmbed("Invalid URL", "Invalid URL. Include http:// or https://"); - embed.setColor(getColor('error')); - return InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - }); - } - const embed = errorEmbed("URL Shortening Failed", `URL shortening failed: ${shortUrl}`); - embed.setColor(getColor('error')); - return InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - }); - } - - const embed = successEmbed("URL Shortened", `Here's your shortened URL: ${shortUrl}`); - embed.setColor(getColor('success')); - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - }); - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'shorten' - }); - } - }, -}; - - diff --git a/src/commands/Tools/time.js b/src/commands/Tools/time.js deleted file mode 100644 index 5a59ba4018..0000000000 --- a/src/commands/Tools/time.js +++ /dev/null @@ -1,67 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { getColor } from '../../config/bot.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName('time') - .setDescription('Get the current time in different timezones') - .addStringOption(option => - option.setName('timezone') - .setDescription('The timezone to display (e.g., UTC, America/New_York)') - .setRequired(false)), - - async execute(interaction) { - await InteractionHelper.safeExecute( - interaction, - async () => { - const timezone = interaction.options.getString('timezone') || 'UTC'; - - let timeString; - try { - timeString = new Date().toLocaleString('en-US', { - timeZone: timezone, - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - timeZoneName: 'short' - }); - } catch (error) { - logger.warn(`Invalid timezone requested: ${timezone}`); - const embed = errorEmbed('Invalid Timezone', 'Invalid timezone. Please use a valid timezone identifier (e.g., UTC, America/New_York, Europe/London)'); - embed.setColor(getColor('error')); - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - }); - return; - } - - const now = new Date(); - const unixTimestamp = Math.floor(now.getTime() / 1000); - - const embed = successEmbed( - '🕒 Current Time', - `**${timezone}:** ${timeString}\n` + - `**Unix Timestamp:** \`${unixTimestamp}\`\n` + - `**ISO String:** \`${now.toISOString()}\`` - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, - 'Failed to get current time. Please try again.', - { - autoDefer: true, - deferOptions: { flags: MessageFlags.Ephemeral } - } - ); - }, -}; - - - - diff --git a/src/commands/Tools/unixtime.js b/src/commands/Tools/unixtime.js deleted file mode 100644 index 23d32f0812..0000000000 --- a/src/commands/Tools/unixtime.js +++ /dev/null @@ -1,41 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { getColor } from '../../config/bot.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName('unixtime') - .setDescription('Get the current Unix timestamp'), - - async execute(interaction) { - await InteractionHelper.safeExecute( - interaction, - async () => { - const now = new Date(); - 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()}` - ); - embed.setColor(getColor('success')); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - }); - }, - 'Failed to get unix timestamp. Please try again.', - { - autoDefer: true, - deferOptions: { flags: MessageFlags.Ephemeral } - } - ); - }, -}; - - - diff --git a/src/commands/Utility/avatar.js b/src/commands/Utility/avatar.js deleted file mode 100644 index 3916b29d7c..0000000000 --- a/src/commands/Utility/avatar.js +++ /dev/null @@ -1,52 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("avatar") - .setDescription("Display a user's avatar image") - .addUserOption((option) => - option - .setName("target") - .setDescription( - "The user whose avatar you want to see (defaults to you)", - ), - ), - - async execute(interaction) { - try { - const user = interaction.options.getUser("target") || interaction.user; - const avatarUrl = user.displayAvatarURL({ size: 2048, dynamic: true }); - - const embed = createEmbed({ - title: `${user.username}'s Avatar`, - description: `[Download Link](${avatarUrl})` - }) - .setImage(avatarUrl); - - await InteractionHelper.safeReply(interaction, { embeds: [embed] }); - logger.info(`Avatar command executed`, { - userId: interaction.user.id, - targetUserId: user.id, - guildId: interaction.guildId - }); - } catch (error) { - logger.error(`Avatar command execution failed`, { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'avatar' - }); - await handleInteractionError(interaction, error, { - commandName: 'avatar', - source: 'avatar_command' - }); - } - } -}; - - diff --git a/src/commands/Utility/firstmsg.js b/src/commands/Utility/firstmsg.js deleted file mode 100644 index 6603208efd..0000000000 --- a/src/commands/Utility/firstmsg.js +++ /dev/null @@ -1,79 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -export default { - data: new SlashCommandBuilder() - .setName("firstmsg") - .setDescription("Get a link to the first message in this channel") - .setDMPermission(false) - .setDefaultMemberPermissions(PermissionFlagsBits.SendMessages), - category: "Utility", - - async execute(interaction, config, client) { - try { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`FirstMsg interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'firstmsg' - }); - return; - } - - const messages = await interaction.channel.messages.fetch({ - limit: 1, - after: '1', - cache: false - }); - - const firstMessage = messages.first(); - - if (!firstMessage) { - logger.info(`FirstMsg - no messages found in channel`, { - userId: interaction.user.id, - channelId: interaction.channelId, - guildId: interaction.guildId - }); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed("First Message", "No messages found in this channel!")], - }); - } - - const messageLink = `https://discord.com/channels/${interaction.guildId}/${interaction.channelId}/${firstMessage.id}`; - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "First Message in #" + interaction.channel.name, - `Message Link: ${messageLink}` - ), - ], - }); - - logger.info(`FirstMsg command executed`, { - userId: interaction.user.id, - channelId: interaction.channelId, - messageId: firstMessage.id, - guildId: interaction.guildId - }); - } catch (error) { - logger.error(`FirstMsg command execution failed`, { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - channelId: interaction.channelId, - guildId: interaction.guildId, - commandName: 'firstmsg' - }); - await handleInteractionError(interaction, error, { - commandName: 'firstmsg', - source: 'firstmsg_command' - }); - } - }, -}; - - diff --git a/src/commands/Utility/modules/report.js b/src/commands/Utility/modules/report.js deleted file mode 100644 index 4c3f08ed77..0000000000 --- a/src/commands/Utility/modules/report.js +++ /dev/null @@ -1,69 +0,0 @@ -import { getColor } from '../../../config/bot.js'; -import { createEmbed, errorEmbed } from '../../../utils/embeds.js'; -import { getGuildConfig } from '../../../services/guildConfig.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { handleInteractionError } from '../../../utils/errorHandler.js'; -import { logger } from '../../../utils/logger.js'; - -export default { - async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction, { ephemeral: true }); - if (!deferSuccess) { - logger.warn('Report interaction defer failed', { userId: interaction.user.id, guildId: interaction.guildId }); - return; - } - - const targetUser = interaction.options.getUser('user'); - const reason = interaction.options.getString('reason'); - const guildId = interaction.guildId; - - const guildConfig = await getGuildConfig(client, guildId); - const reportChannelId = guildConfig.reportChannelId; - - if (!reportChannelId) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Setup Required', 'The report channel has not been set up. Please ask a moderator to use `/report setchannel` first.')], - }); - } - - const reportChannel = interaction.guild.channels.cache.get(reportChannelId); - if (!reportChannel) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Channel Missing', 'The configured report channel is missing or inaccessible. Please ask a moderator to reset it.')], - }); - } - - try { - const reportEmbed = createEmbed({ - title: `🚨 NEW USER REPORT: ${targetUser.tag}`, - description: `**Reported By:** ${interaction.user.tag} (\`${interaction.user.id}\`)\n**Reported User:** ${targetUser.tag} (\`${targetUser.id}\`)`, - }) - .setColor(getColor('error')) - .setThumbnail(targetUser.displayAvatarURL()) - .addFields( - { name: 'Reason', value: reason }, - { name: 'Reported In Channel', value: interaction.channel.toString(), inline: true }, - { name: 'Time', value: new Date().toUTCString(), inline: true }, - ); - - await reportChannel.send({ - content: `<@&${interaction.guild.ownerId}> New Report!`, - embeds: [reportEmbed], - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [createEmbed({ title: '✅ Report Submitted', description: `Your report against **${targetUser.tag}** has been successfully filed and sent to the moderation team. Thank you!` })], - }); - - logger.info('Report submitted', { - userId: interaction.user.id, - reportedUserId: targetUser.id, - guildId, - reasonLength: reason.length, - }); - } catch (error) { - logger.error('report error:', error); - await handleInteractionError(interaction, error, { commandName: 'report', source: 'report' }); - } - }, -}; diff --git a/src/commands/Utility/modules/report_setchannel.js b/src/commands/Utility/modules/report_setchannel.js deleted file mode 100644 index 10c1e803e5..0000000000 --- a/src/commands/Utility/modules/report_setchannel.js +++ /dev/null @@ -1,36 +0,0 @@ -import { PermissionsBitField, ChannelType } from 'discord.js'; -import { errorEmbed, successEmbed } from '../../../utils/embeds.js'; -import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { logger } from '../../../utils/logger.js'; - -export default { - async execute(interaction, config, client) { - if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Permission Denied', 'You need **Manage Server** permissions to set the report channel.')], - ephemeral: true, - }); - } - - const channel = interaction.options.getChannel('channel'); - const guildId = interaction.guildId; - - try { - const guildConfig = await getGuildConfig(client, guildId); - guildConfig.reportChannelId = channel.id; - await setGuildConfig(client, guildId, guildConfig); - - return InteractionHelper.safeReply(interaction, { - embeds: [successEmbed('✅ Report Channel Set', `All new reports will now be sent to ${channel}.`)], - ephemeral: true, - }); - } catch (error) { - logger.error('report_setchannel error:', error); - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Database Error', 'Could not save the channel configuration.')], - ephemeral: true, - }); - } - }, -}; diff --git a/src/commands/Utility/report.js b/src/commands/Utility/report.js deleted file mode 100644 index 57ef35b97e..0000000000 --- a/src/commands/Utility/report.js +++ /dev/null @@ -1,68 +0,0 @@ -import { SlashCommandBuilder, ChannelType } from 'discord.js'; -import { errorEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -import report from './modules/report.js'; -import reportSetchannel from './modules/report_setchannel.js'; - -export default { - data: new SlashCommandBuilder() - .setName('report') - .setDescription('Report a user to server staff, or configure where reports are sent.') - .setDMPermission(false) - .addSubcommand(subcommand => - subcommand - .setName('file') - .setDescription('Report a user to the server moderation team.') - .addUserOption(option => - option - .setName('user') - .setDescription('The user you want to report.') - .setRequired(true), - ) - .addStringOption(option => - option - .setName('reason') - .setDescription('The reason for the report (be detailed).') - .setRequired(true) - .setMaxLength(500), - ), - ) - .addSubcommand(subcommand => - subcommand - .setName('setchannel') - .setDescription('Set the channel where user reports are sent. (Manage Server required)') - .addChannelOption(option => - option - .setName('channel') - .setDescription('The text channel to receive reports.') - .addChannelTypes(ChannelType.GuildText) - .setRequired(true), - ), - ), - category: 'Utility', - - async execute(interaction, config, client) { - try { - const subcommand = interaction.options.getSubcommand(); - - if (subcommand === 'file') { - return await report.execute(interaction, config, client); - } - - if (subcommand === 'setchannel') { - return await reportSetchannel.execute(interaction, config, client); - } - - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Error', 'Unknown subcommand.')], - ephemeral: true, - }); - } catch (error) { - logger.error('report command error:', error); - await handleInteractionError(interaction, error, { commandName: 'report', source: 'report_command' }); - } - }, -}; \ No newline at end of file diff --git a/src/commands/Utility/serverinfo.js b/src/commands/Utility/serverinfo.js deleted file mode 100644 index b471420dc8..0000000000 --- a/src/commands/Utility/serverinfo.js +++ /dev/null @@ -1,76 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export default { - data: new SlashCommandBuilder() - .setName("serverinfo") - .setDescription("Get detailed information about the server"), - - async execute(interaction) { - try { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`ServerInfo interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'serverinfo' - }); - return; - } - - const guild = interaction.guild; - const owner = await guild.fetchOwner(); - - const createdTimestamp = Math.floor(guild.createdAt.getTime() / 1000); - - const embed = createEmbed({ title: `🏰 Server Info: ${guild.name}`, description: `Server ID: ${guild.id}` }) - .setThumbnail(guild.iconURL({ size: 256 })) - .addFields( - { name: "Owner", value: owner.user.tag, inline: true }, - { name: "Members", value: `${guild.memberCount}`, inline: true }, - { - name: "Channels", - value: `${guild.channels.cache.size}`, - inline: true, - }, - { name: "Roles", value: `${guild.roles.cache.size}`, inline: true }, - { - name: "Boosts", - value: `Level ${guild.premiumTier} (${guild.premiumSubscriptionCount})`, - inline: true, - }, - { - name: "Creation Date", - value: ``, - inline: true, - }, - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.info(`ServerInfo command executed`, { - userId: interaction.user.id, - guildId: guild.id, - guildName: guild.name, - memberCount: guild.memberCount - }); - } catch (error) { - logger.error(`ServerInfo command execution failed`, { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'serverinfo' - }); - await handleInteractionError(interaction, error, { - commandName: 'serverinfo', - source: 'serverinfo_command' - }); - } - }, -}; - - - diff --git a/src/commands/Utility/todo.js b/src/commands/Utility/todo.js deleted file mode 100644 index 8ee6963992..0000000000 --- a/src/commands/Utility/todo.js +++ /dev/null @@ -1,547 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getFromDb, setInDb } from '../../utils/database.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import crypto from 'crypto'; - -function generateShareId() { - return crypto.randomBytes(16).toString('hex'); -} - -export default { - data: new SlashCommandBuilder() - .setName("todo") - .setDescription("Manage your personal to-do list") - .addSubcommand(subcommand => - subcommand - .setName("add") - .setDescription("Add a task to your to-do list") - .addStringOption(option => - option - .setName("task") - .setDescription("The task to add") - .setRequired(true) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName("list") - .setDescription("View your to-do list") - ) - .addSubcommand(subcommand => - subcommand - .setName("complete") - .setDescription("Mark a task as complete") - .addIntegerOption(option => - option - .setName("number") - .setDescription("The number of the task to complete") - .setRequired(true) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName("remove") - .setDescription("Remove a task from your to-do list") - .addIntegerOption(option => - option - .setName("number") - .setDescription("The number of the task to remove") - .setRequired(true) - ) - ) - .addSubcommandGroup(group => - group - .setName("share") - .setDescription("Manage shared to-do lists") - .addSubcommand(subcommand => - subcommand - .setName("create") - .setDescription("Create a new shared to-do list") - .addStringOption(option => - option - .setName("name") - .setDescription("Name for the shared list") - .setRequired(true) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName("add") - .setDescription("Add a member to a shared list") - .addStringOption(option => - option - .setName("list_id") - .setDescription("ID of the shared list") - .setRequired(true) - ) - .addUserOption(option => - option - .setName("user") - .setDescription("User to add to the list") - .setRequired(true) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName("view") - .setDescription("View a shared to-do list") - .addStringOption(option => - option - .setName("list_id") - .setDescription("ID of the shared list") - .setRequired(true) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName("addtask") - .setDescription("Add a task to a shared to-do list") - .addStringOption(option => - option - .setName("list_id") - .setDescription("ID of the shared list") - .setRequired(true) - ) - .addStringOption(option => - option - .setName("task") - .setDescription("The task to add") - .setRequired(true) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName("remove") - .setDescription("Remove a task from a shared to-do list") - .addStringOption(option => - option - .setName("list_id") - .setDescription("ID of the shared list") - .setRequired(true) - ) - .addIntegerOption(option => - option - .setName("number") - .setDescription("The number of the task to remove") - .setRequired(true) - ) - ) - ) - .setDMPermission(false) - .setDefaultMemberPermissions(PermissionFlagsBits.SendMessages), - category: "Utility", - - async execute(interaction, config, client) { - const userId = interaction.user.id; - const subcommand = interaction.options.getSubcommand(); - const shareSubcommand = interaction.options.getSubcommandGroup() === 'share' ? interaction.options.getSubcommand() : null; - - async function getOrCreateSharedList(listId, creatorId = null, listName = null) { - const listKey = `shared_todo_${listId}`; - let listData = await getFromDb(listKey, null); - - if (!listData || (listData.ok === false && listData.error)) { - if (creatorId) { - listData = { - id: listId, - name: listName, - creatorId, - members: [creatorId], - tasks: [], - nextId: 1, - createdAt: new Date().toISOString() - }; - await setInDb(listKey, listData); - } else { - return null; - } - } - - if (listData) { - if (!Array.isArray(listData.tasks)) listData.tasks = []; - if (!listData.nextId) listData.nextId = 1; - if (!Array.isArray(listData.members)) listData.members = []; - } - - return listData; - } - - try { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Todo interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'todo' - }); - return; - } - - if (shareSubcommand) { - switch (shareSubcommand) { - case 'create': { - const listName = interaction.options.getString('name'); - const listId = generateShareId(); - - await getOrCreateSharedList(listId, userId, listName); - - const userSharedLists = await getFromDb(`user_shared_lists_${userId}`, []); - const sharedListsArray = Array.isArray(userSharedLists) ? userSharedLists : []; - if (!sharedListsArray.includes(listId)) { - sharedListsArray.push(listId); - await setInDb(`user_shared_lists_${userId}`, sharedListsArray); - } - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "Shared List Created", - `Created shared list "${listName}" with ID: \`${listId}\`\n` + - `Use \`/todo share add list_id:${listId} user:@username\` to add members.` - ) - ] - }); - } - - case 'add': { - const listId = interaction.options.getString('list_id'); - const memberToAdd = interaction.options.getUser('user'); - - const listData = await getOrCreateSharedList(listId); - if (!listData) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("Error", "Shared list not found.")] - }); - } - - if (listData.creatorId !== userId) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("Error", "Only the list creator can add members.")] - }); - } - - if (!listData.members.includes(memberToAdd.id)) { - listData.members.push(memberToAdd.id); - await setInDb(`shared_todo_${listId}`, listData); - - const memberLists = await getFromDb(`user_shared_lists_${memberToAdd.id}`, []); - const memberListsArray = Array.isArray(memberLists) ? memberLists : []; - if (!memberListsArray.includes(listId)) { - memberListsArray.push(listId); - await setInDb(`user_shared_lists_${memberToAdd.id}`, memberListsArray); - } - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed("Member Added", - `Added ${memberToAdd.username} to the shared list "${listData.name}"` - ) - ] - }); - } else { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("Error", "User is already a member of this list.")] - }); - } - } - - case 'view': { - const listId = interaction.options.getString('list_id'); - const listData = await getOrCreateSharedList(listId); - - if (!listData) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("Error", "Shared list not found.")] - }); - } - - if (!listData.members.includes(userId)) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("Error", "You don't have access to this list.")] - }); - } - - if (listData.tasks.length === 0) { - const memberList = listData.members.map(memberId => { - const member = interaction.guild.members.cache.get(memberId); - return member ? member.user.username : `<@${memberId}>`; - }).join(', '); - - const owner = interaction.guild.members.cache.get(listData.creatorId); - const ownerName = owner ? owner.user.username : `<@${listData.creatorId}>`; - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - `📋 **${listData.name}**\n\n` + - `👑 **Owner:** ${ownerName}\n` + - `👥 **Members:** ${memberList}\n\n` + - `*This list is currently empty. Use the "Add Task" button to add tasks!*`, - `Shared List (ID: \`${listId}\`)` - ) - ], - components: [ - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`shared_todo_add_${listId}`) - .setLabel('Add Task') - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId(`shared_todo_complete_${listId}`) - .setLabel('Complete Task') - .setStyle(ButtonStyle.Success), - new ButtonBuilder() - .setCustomId(`shared_todo_remove_${listId}`) - .setLabel('Remove Task') - .setStyle(ButtonStyle.Danger) - ) - ] - }); - } - - const taskList = listData.tasks - .map(task => - `${task.completed ? '✅' : '📝'} #${task.id} ${task.text} ` + - `\`[${new Date(task.createdAt).toLocaleDateString()}]` + - (task.completed ? ` • Completed by ${task.completedBy}` : '') + '`' - ) - .join('\n'); - - const memberList = listData.members.map(memberId => { - const member = interaction.guild.members.cache.get(memberId); - return member ? member.user.username : `<@${memberId}>`; - }).join(', '); - - const owner = interaction.guild.members.cache.get(listData.creatorId); - const ownerName = owner ? owner.user.username : `<@${listData.creatorId}>`; - - const fullListDisplay = `📋 **${listData.name}**\n\n` + - `👑 **Owner:** ${ownerName}\n` + - `👥 **Members:** ${memberList}\n\n` + - `**Tasks:**\n${taskList}`; - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed(fullListDisplay, `Shared List (ID: \`${listId}\`)`) - ], - components: [ - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`shared_todo_add_${listId}`) - .setLabel('Add Task') - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId(`shared_todo_complete_${listId}`) - .setLabel('Complete Task') - .setStyle(ButtonStyle.Success), - new ButtonBuilder() - .setCustomId(`shared_todo_remove_${listId}`) - .setLabel('Remove Task') - .setStyle(ButtonStyle.Danger) - ) - ] - }); - } - - case 'addtask': { - const listId = interaction.options.getString('list_id'); - const taskText = interaction.options.getString('task'); - - const listData = await getOrCreateSharedList(listId); - - if (!listData) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("Error", "Shared list not found.")] - }); - } - - if (!listData.members.includes(userId)) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("Error", "You don't have access to this list.")] - }); - } - - const newTask = { - id: listData.nextId++, - text: taskText, - completed: false, - createdAt: new Date().toISOString(), - createdBy: userId - }; - - listData.tasks.push(newTask); - await setInDb(`shared_todo_${listId}`, listData); - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed("Task Added", `Added "${taskText}" to the shared list "${listData.name}"`) - ] - }); - } - - case 'remove': { - const listId = interaction.options.getString('list_id'); - const taskNumber = interaction.options.getInteger('number'); - - const listData = await getOrCreateSharedList(listId); - - if (!listData) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("Error", "Shared list not found.")] - }); - } - - if (!listData.members.includes(userId)) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("Error", "You don't have access to this list.")] - }); - } - - const taskIndex = listData.tasks.findIndex(task => task.id === taskNumber); - if (taskIndex === -1) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("Error", "Task not found.")] - }); - } - - const [removedTask] = listData.tasks.splice(taskIndex, 1); - await setInDb(`shared_todo_${listId}`, listData); - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed("Task Removed", `Removed "${removedTask.text}" from the shared list "${listData.name}".`) - ] - }); - } - } - return; - } - - const dbKey = `todo_${userId}`; - - const userData = await getFromDb(dbKey, { - tasks: [], - nextId: 1 - }); - - if (!userData.tasks) userData.tasks = []; - if (!userData.nextId) userData.nextId = 1; - - switch (subcommand) { - case 'add': { - const taskText = interaction.options.getString('task'); - - const newTask = { - id: userData.nextId++, - text: taskText, - completed: false, - createdAt: new Date().toISOString() - }; - - userData.tasks.push(newTask); - await setInDb(dbKey, userData); - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "Task Added", - `Added "${taskText}" to your to-do list.` - ), - ], - }); - } - - case 'list': { - if (userData.tasks.length === 0) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed("Your to-do list is empty!", "Your To-Do List")], - }); - } - - const taskList = userData.tasks - .map(task => - `${task.completed ? '✅' : '📝'} #${task.id} ${task.text} ` + - `\`[${new Date(task.createdAt).toLocaleDateString()}\`` - ) - .join('\n'); - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed(taskList, "Your To-Do List") - ], - }); - } - - case 'complete': { - const taskNumber = interaction.options.getInteger('number'); - const task = userData.tasks.find(t => t.id === taskNumber); - - if (!task) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("Error", "Task not found.")], - }); - } - - if (task.completed) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("Task Already Completed", `Task #${task.id} is already completed.`)], - }); - } - - task.completed = true; - await setInDb(`todo_${userId}`, userData); - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed("Task Completed", `Marked "${task.text}" as complete!`) - ], - }); - } - - case 'remove': { - const taskNumber = interaction.options.getInteger('number'); - const taskIndex = userData.tasks.findIndex(t => t.id === taskNumber); - - if (taskIndex === -1) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("Error", "Task not found.")], - }); - } - - const [removedTask] = userData.tasks.splice(taskIndex, 1); - await setInDb(`todo_${userId}`, userData); - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed("Task Removed", `Removed "${removedTask.text}" from your to-do list.`) - ], - }); - } - - default: - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("Error", "Invalid subcommand.")], - }); - } - } catch (error) { - logger.error(`Todo command execution failed`, { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'todo' - }); - await handleInteractionError(interaction, error, { - commandName: 'todo', - source: 'todo_command' - }); - } - }, -}; - - - - diff --git a/src/commands/Utility/userinfo.js b/src/commands/Utility/userinfo.js deleted file mode 100644 index 40dd58cd58..0000000000 --- a/src/commands/Utility/userinfo.js +++ /dev/null @@ -1,91 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("userinfo") - .setDescription("Get detailed information about a user") - .addUserOption((option) => - option - .setName("target") - .setDescription("The user to inspect (defaults to you)"), - ), - - async execute(interaction) { - try { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`UserInfo interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'userinfo' - }); - return; - } - - const user = interaction.options.getUser("target") || interaction.user; - const member = interaction.guild.members.cache.get(user.id); - - const createdTimestamp = Math.floor(user.createdAt.getTime() / 1000); - const joinedTimestamp = member?.joinedAt ? Math.floor(member.joinedAt.getTime() / 1000) : null; - - const embed = createEmbed({ title: `👤 User Info: ${user.username}` }) - .setThumbnail(user.displayAvatarURL({ size: 256 })) - .addFields( - { name: "ID", value: user.id, inline: true }, - { name: "Bot", value: user.bot ? "Yes" : "No", inline: true }, - { - name: "Roles", - value: - member && member.roles.cache.size > 1 - ? member.roles.cache - .map((r) => r.name) - .slice(0, 5) - .join(", ") - : "None", - inline: true, - }, - { - name: "Account Created", - value: ``, - inline: false, - }, - { - name: "Joined Server", - value: joinedTimestamp ? `` : "Not in server", - inline: false, - }, - { - name: "Highest Role", - value: member?.roles?.highest?.name || "None", - inline: true, - }, - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.info(`UserInfo command executed`, { - userId: interaction.user.id, - targetUserId: user.id, - guildId: interaction.guildId - }); - } catch (error) { - logger.error(`UserInfo command execution failed`, { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'userinfo' - }); - await handleInteractionError(interaction, error, { - commandName: 'userinfo', - source: 'userinfo_command' - }); - } - }, -}; - - - - diff --git a/src/commands/Utility/weather.js b/src/commands/Utility/weather.js deleted file mode 100644 index 6a47cde5db..0000000000 --- a/src/commands/Utility/weather.js +++ /dev/null @@ -1,158 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -const GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search"; -const WEATHER_URL = "https://api.open-meteo.com/v1/forecast"; - -export default { - data: new SlashCommandBuilder() - .setName("weather") - .setDescription("Get real-time weather information for a location") - .addStringOption((option) => - option - .setName("city") - .setDescription("The city name, e.g., 'London' or 'Tokyo'") - .setRequired(true), - ), - - async execute(interaction) { - try { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Weather interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'weather' - }); - return; - } - - const city = interaction.options.getString("city"); - - const geoResponse = await fetch( - `${GEOCODING_URL}?name=${encodeURIComponent(city)}`, - ); - const geoData = await geoResponse.json(); - - if (!geoData.results || geoData.results.length === 0) { - logger.info(`Weather command - city not found`, { - userId: interaction.user.id, - city: city, - guildId: interaction.guildId - }); - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "City Not Found", - `Could not find a location for **${city}**. Please check the spelling.`, - ), - ], - }); - return; - } - - const { latitude, longitude, name, country } = geoData.results[0]; - const cityDisplay = name; - - const weatherResponse = await fetch( - `${WEATHER_URL}?latitude=${latitude}&longitude=${longitude}¤t_weather=true`, - ); - const weatherData = await weatherResponse.json(); - - if (weatherData.error) { - logger.error(`Weather API error`, { - error: weatherData.reason, - city: city, - userId: interaction.user.id, - guildId: interaction.guildId - }); - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "API Error", - "A weather service error occurred.", - ), - ], - }); - return; - } - - const current = weatherData.current || weatherData.current_weather || {}; - const temperature = current.temperature != null ? Math.round(current.temperature) : "N/A"; - const humidity = current.relativehumidity ?? current.relative_humidity_2m ?? "N/A"; - const windSpeed = current.windspeed != null ? Math.round(current.windspeed) : "N/A"; - const weatherCode = current.weathercode ?? current.weather_code ?? null; - - const condition = getWeatherDescription(weatherCode); - - const embed = createEmbed({ title: `🌎 Weather in ${cityDisplay}, ${country}`, description: condition.description }) - .addFields( - { - name: "🌡️ Temperature", - value: `${temperature}°C`, - inline: true, - }, - { - name: "💧 Humidity", - value: `${humidity}%`, - inline: true, - }, - { - name: "💨 Wind Speed", - value: `${windSpeed} km/h`, - inline: true, - }, - ) - .setFooter({ - text: `Latitude: ${latitude.toFixed(2)} | Longitude: ${longitude.toFixed(2)}`, - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.info(`Weather command executed`, { - userId: interaction.user.id, - city: cityDisplay, - country: country, - temperature: temperature, - guildId: interaction.guildId - }); - } catch (error) { - logger.error(`Weather command execution failed`, { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'weather' - }); - await handleInteractionError(interaction, error, { - commandName: 'weather', - source: 'weather_command' - }); - } - }, -}; - - - - - -function getWeatherDescription(code) { - if (code >= 0 && code <= 3) { - return { description: "Clear sky / Partly cloudy ☀️", emoji: "☀️" }; - } else if (code >= 45 && code <= 48) { - return { description: "Fog and Rime fog 🌫️", emoji: "🌫️" }; - } else if (code >= 51 && code <= 67) { - return { description: "Drizzle or Rain 🌧️", emoji: "🌧️" }; - } else if (code >= 71 && code <= 75) { - return { description: "Snow fall ❄️", emoji: "❄️" }; - } else if (code >= 80 && code <= 86) { - return { description: "Showers (Rain/Snow) 🌨️", emoji: "🌨️" }; - } else if (code >= 95 && code <= 99) { - return { description: "Thunderstorm ⛈️", emoji: "⛈️" }; - } - return { description: "Unknown conditions.", emoji: "" }; -} - - - diff --git a/src/commands/Utility/wipedata.js b/src/commands/Utility/wipedata.js deleted file mode 100644 index 6aad2fb872..0000000000 --- a/src/commands/Utility/wipedata.js +++ /dev/null @@ -1,59 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getConfirmationButtons } from '../../utils/components.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName('wipedata') - .setDescription('Delete all your personal data from the bot (irreversible)'), - - async execute(interaction, guildConfig, client) { - try { - const warningMessage = - `⚠️ **THIS ACTION IS IRREVERSIBLE!** ⚠️\n\n` + - `This will permanently delete **ALL** your data from this server including:\n` + - `• 💰 Economy balance (wallet & bank)\n` + - `• 📊 Levels and XP\n` + - `• 🎒 Inventory items\n` + - `• 🛍️ Shop purchases\n` + - `• 🎂 Birthday information\n` + - `• 🔢 Counter data\n` + - `• 📋 All other personal data\n\n` + - `**This cannot be undone. Are you absolutely sure?**`; - - const embed = warningEmbed(warningMessage, '🗑️ Wipe All Data'); - - const confirmButtons = getConfirmationButtons('wipedata'); - - await InteractionHelper.safeReply(interaction, { - embeds: [embed], - components: [confirmButtons], - flags: MessageFlags.Ephemeral - }); - - logger.info(`Wipedata command executed - confirmation prompt shown`, { - userId: interaction.user.id, - guildId: interaction.guildId - }); - } catch (error) { - logger.error(`Wipedata command execution failed`, { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'wipedata' - }); - await handleInteractionError(interaction, error, { - commandName: 'wipedata', - source: 'wipedata_command' - }); - } - } -}; - - - - diff --git a/src/commands/Verification/autoverify.js b/src/commands/Verification/autoverify.js deleted file mode 100644 index 78c56834dd..0000000000 --- a/src/commands/Verification/autoverify.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './modules/autoVerify.js'; diff --git a/src/commands/Verification/modules/autoVerify.js b/src/commands/Verification/modules/autoVerify.js deleted file mode 100644 index 3b0fd28dcc..0000000000 --- a/src/commands/Verification/modules/autoVerify.js +++ /dev/null @@ -1,192 +0,0 @@ -import { botConfig, getColor } from '../../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed } from '../../../utils/embeds.js'; -import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../../utils/errorHandler.js'; -import { validateAutoVerifyCriteria } from '../../../services/verificationService.js'; -import { logger } from '../../../utils/logger.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { getWelcomeConfig } from '../../../utils/database.js'; -import autoVerifyDashboard from './autoVerifyDashboard.js'; - -const autoVerifyDefaults = botConfig.verification?.autoVerify || {}; -const minAccountAgeDays = autoVerifyDefaults.minAccountAge ?? 1; -const maxAccountAgeDays = autoVerifyDefaults.maxAccountAge ?? 365; -const defaultAccountAgeDays = autoVerifyDefaults.defaultAccountAgeDays ?? 7; - -export default { - data: new SlashCommandBuilder() - .setName("autoverify") - .setDescription("Configure automatic verification settings") - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .addSubcommand(subcommand => - subcommand - .setName("setup") - .setDescription("Set up automatic verification") - .addRoleOption(option => - option - .setName("role") - .setDescription("Role to assign to users who meet auto-verify criteria") - .setRequired(true) - ) - .addStringOption(option => - option - .setName("criteria") - .setDescription("Criteria for automatic verification") - .addChoices( - { name: "Account Age", value: "account_age" }, - { name: "No Criteria", value: "none" } - ) - .setRequired(true) - ) - .addIntegerOption(option => - option - .setName("account_age_days") - .setDescription("Minimum account age in days (required for account age criteria)") - .setMinValue(minAccountAgeDays) - .setMaxValue(maxAccountAgeDays) - .setRequired(false) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName("dashboard") - .setDescription("Open the auto-verification dashboard for customization") - ), - - async execute(interaction, config, client) { - const wrappedExecute = withErrorHandling(async () => { - const subcommand = interaction.options.getSubcommand(); - const guild = interaction.guild; - - switch (subcommand) { - case "setup": - return await handleSetup(interaction, guild, client); - case "dashboard": - return await autoVerifyDashboard.execute(interaction, config, client); - default: - throw createError( - `Unknown subcommand: ${subcommand}`, - ErrorTypes.VALIDATION, - "Invalid subcommand selected.", - { subcommand } - ); - } - }, { command: 'autoverify', subcommand: interaction.options.getSubcommand() }); - - return await wrappedExecute(interaction, config, client); - } -}; - -async function handleSetup(interaction, guild, client) { - const criteria = interaction.options.getString("criteria"); - const accountAgeDays = interaction.options.getInteger("account_age_days") || defaultAccountAgeDays; - const targetRole = interaction.options.getRole("role"); - - await InteractionHelper.safeDefer(interaction); - - try { - const guildConfig = await getGuildConfig(client, guild.id); - const welcomeConfig = await getWelcomeConfig(client, guild.id); - const verificationEnabled = Boolean(guildConfig.verification?.enabled); - const hasAutoRoleConfigured = Boolean(guildConfig.autoRole) || (Array.isArray(welcomeConfig.roleIds) && welcomeConfig.roleIds.length > 0); - - if (verificationEnabled || hasAutoRoleConfigured) { - 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.', - { - guildId: guild.id, - verificationEnabled, - hasAutoRoleConfigured, - expected: true, - suppressErrorLog: true - } - ); - } - - 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 } - ); - } - - if (!botMember.permissions.has(PermissionFlagsBits.ManageRoles)) { - throw createError( - 'Missing ManageRoles permission', - ErrorTypes.PERMISSION, - "I need the 'Manage Roles' permission to assign auto-verify roles.", - { guildId: guild.id } - ); - } - - if (targetRole.id === guild.id || targetRole.managed) { - throw createError( - 'Invalid auto-verify role selected', - ErrorTypes.VALIDATION, - 'Please choose a normal assignable role (not @everyone or an integration-managed role).', - { guildId: guild.id, roleId: targetRole.id, managed: targetRole.managed } - ); - } - - if (targetRole.position >= botMember.roles.highest.position) { - 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.', - { guildId: guild.id, roleId: targetRole.id, rolePosition: targetRole.position, botRolePosition: botMember.roles.highest.position } - ); - } - - - validateAutoVerifyCriteria(criteria, criteria === 'account_age' ? accountAgeDays : 1); - - if (!guildConfig.verification) { - guildConfig.verification = {}; - } - - guildConfig.verification.autoVerify = { - enabled: true, - criteria: criteria, - accountAgeDays: criteria === "account_age" ? accountAgeDays : null, - roleId: targetRole.id, - configuredVia: 'setup' - }; - - await setGuildConfig(client, guild.id, guildConfig); - - let criteriaDescription = ""; - switch (criteria) { - case "account_age": - criteriaDescription = `\`${accountAgeDays} days\` old`; - break; - case "none": - criteriaDescription = "All users immediately"; - break; - } - - logger.info('Auto-verify enabled', { - guildId: guild.id, - criteria, - accountAgeDays: criteria === 'account_age' ? accountAgeDays : null, - roleId: targetRole.id - }); - - 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.` - )] - }); - - } catch (error) { - - throw error; - } -} - diff --git a/src/commands/Verification/modules/autoVerifyDashboard.js b/src/commands/Verification/modules/autoVerifyDashboard.js deleted file mode 100644 index 1b3834e192..0000000000 --- a/src/commands/Verification/modules/autoVerifyDashboard.js +++ /dev/null @@ -1,544 +0,0 @@ -import { botConfig, getColor } from '../../../config/bot.js'; -import { - ActionRowBuilder, - StringSelectMenuBuilder, - StringSelectMenuOptionBuilder, - ModalBuilder, - TextInputBuilder, - TextInputStyle, - RoleSelectMenuBuilder, - ButtonBuilder, - ButtonStyle, - MessageFlags, - ComponentType, - EmbedBuilder, -} from 'discord.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { successEmbed, errorEmbed } from '../../../utils/embeds.js'; -import { logger } from '../../../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../../../utils/errorHandler.js'; -import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; -import { getWelcomeConfig } from '../../../utils/database.js'; -import { validateAutoVerifyCriteria } from '../../../services/verificationService.js'; -import { botHasPermission } from '../../../utils/permissionGuard.js'; - -const autoVerifyDefaults = botConfig.verification?.autoVerify || {}; -const minAccountAgeDays = autoVerifyDefaults.minAccountAge ?? 1; -const maxAccountAgeDays = autoVerifyDefaults.maxAccountAge ?? 365; -const defaultAccountAgeDays = autoVerifyDefaults.defaultAccountAgeDays ?? 7; - -// ─── Embed & Menu Builders ──────────────────────────────────────────────────── - -function buildDashboardEmbed(cfg, guild, conflictSummary = '') { - const autoVerify = cfg.verification?.autoVerify; - const autoVerifyRole = autoVerify?.roleId ? guild.roles.cache.get(autoVerify.roleId) : null; - - let criteriaDescription = "`Not configured`"; - if (autoVerify?.criteria) { - switch (autoVerify.criteria) { - case "account_age": - criteriaDescription = `\`Account Age\` - \`${autoVerify.accountAgeDays} days\``; - break; - case "none": - criteriaDescription = `\`No Criteria\``; - 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.`) - .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: '\u200B', value: '\u200B', inline: true }, - { name: '\u200B', value: '\u200B', inline: true }, - ); - - if (conflictSummary) { - embed.addFields({ name: '⚠️ Setup Conflicts', value: conflictSummary, inline: false }); - } - - return embed - .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) - .setTimestamp(); -} - -function buildSelectMenu(guildId) { - return new StringSelectMenuBuilder() - .setCustomId(`autoverify_cfg_${guildId}`) - .setPlaceholder('Select a setting to configure...') - .addOptions( - new StringSelectMenuOptionBuilder() - .setLabel('Change Role') - .setDescription('Select the role to assign automatically') - .setValue('role') - .setEmoji('🏷️'), - new StringSelectMenuOptionBuilder() - .setLabel('Edit Account Age Days') - .setDescription('Set minimum account age in days') - .setValue('account_age') - .setEmoji('📅'), - ); -} - -function buildButtonRow(cfg, guildId, disabled = false) { - const autoVerifyOn = cfg.verification?.autoVerify?.enabled === true; - return new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`autoverify_cfg_criteria_${guildId}`) - .setLabel('Change Criteria') - .setStyle(ButtonStyle.Primary) - .setEmoji('🎯') - .setDisabled(disabled), - new ButtonBuilder() - .setCustomId(`autoverify_cfg_toggle_${guildId}`) - .setLabel('Auto-Verification') - .setStyle(autoVerifyOn ? ButtonStyle.Success : ButtonStyle.Danger) - .setEmoji('🤖') - .setDisabled(disabled), - ); -} - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -async function refreshDashboard(rootInteraction, cfg, guildId, client) { - try { - const selectMenu = buildSelectMenu(guildId); - - // Get conflict summary - let conflictSummary = ''; - try { - const welcomeConfig = await getWelcomeConfig(client, guildId); - const verificationEnabled = Boolean(cfg.verification?.enabled); - 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 - ].filter(Boolean); - - if (conflicts.length > 0) { - conflictSummary = conflicts.join('\n'); - } - } catch (error) { - logger.warn('Could not fetch autoverify dashboard conflicts:', error.message); - } - - await InteractionHelper.safeEditReply(rootInteraction, { - embeds: [buildDashboardEmbed(cfg, rootInteraction.guild, conflictSummary)], - components: [ - buildButtonRow(cfg, guildId), - new ActionRowBuilder().addComponents(selectMenu), - ], - flags: MessageFlags.Ephemeral, - }); - } catch (error) { - logger.debug('Could not refresh autoverify dashboard (interaction may have expired):', error.message); - } -} - -// ─── Main Export ────────────────────────────────────────────────────────────── - -export default { - async execute(interaction, config, client) { - try { - const guildId = interaction.guild.id; - const guildConfig = await getGuildConfig(client, guildId); - - // Check if auto-verification is configured - if (!guildConfig.verification?.autoVerify?.enabled) { - // Check for blocking systems - const welcomeConfig = await getWelcomeConfig(client, guildId); - const verificationEnabled = Boolean(guildConfig.verification?.enabled); - const autoRoleConfigured = Boolean(guildConfig.autoRole) || (Array.isArray(welcomeConfig.roleIds) && welcomeConfig.roleIds.length > 0); - - const blockingMessage = []; - if (verificationEnabled) blockingMessage.push('Verification system is enabled'); - if (autoRoleConfigured) blockingMessage.push('AutoRole is configured'); - - const blockingText = blockingMessage.length > 0 - ? `\n\n⚠️ **To enable AutoVerify, you must first disable:**\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 \`/autoverify setup\` to configure it.`) - .setColor(getColor('warning')) - .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) - .setTimestamp() - ], - flags: MessageFlags.Ephemeral - }); - } - - await InteractionHelper.safeDefer(interaction, { ephemeral: true }); - - const selectMenu = buildSelectMenu(guildId); - - // Get conflict summary - let conflictSummary = ''; - try { - const welcomeConfig = await getWelcomeConfig(client, guildId); - const verificationEnabled = Boolean(guildConfig.verification?.enabled); - 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 - ].filter(Boolean); - - if (conflicts.length > 0) { - conflictSummary = conflicts.join('\n'); - } - } catch (error) { - logger.warn('Could not fetch autoverify dashboard conflicts:', error.message); - } - - await InteractionHelper.safeEditReply(interaction, { - embeds: [buildDashboardEmbed(guildConfig, interaction.guild, conflictSummary)], - components: [ - buildButtonRow(guildConfig, guildId), - new ActionRowBuilder().addComponents(selectMenu), - ], - flags: MessageFlags.Ephemeral, - }); - - // ── Select collector ────────────────────────────────────────────── - const collector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.StringSelect, - filter: i => - i.user.id === interaction.user.id && i.customId === `autoverify_cfg_${guildId}`, - time: 600_000, - }); - - collector.on('collect', async selectInteraction => { - const selectedOption = selectInteraction.values[0]; - try { - switch (selectedOption) { - case 'role': - await handleRole(selectInteraction, interaction, guildConfig, guildId, client); - break; - case 'account_age': - await handleAccountAge(selectInteraction, interaction, guildConfig, guildId, client); - break; - } - } catch (error) { - if (error instanceof TitanBotError) { - logger.debug(`Autoverify config validation error: ${error.message}`); - } else { - logger.error('Unexpected autoverify dashboard error:', error); - } - - const errorMessage = - error instanceof TitanBotError - ? error.userMessage || 'An error occurred while processing your selection.' - : 'An unexpected error occurred while updating the configuration.'; - - if (!selectInteraction.replied && !selectInteraction.deferred) { - await selectInteraction.deferUpdate().catch(() => {}); - } - - await selectInteraction - .followUp({ - embeds: [errorEmbed('Configuration Error', errorMessage)], - flags: MessageFlags.Ephemeral, - }) - .catch(() => {}); - } - }); - - // ── Button collector for buttons ───────────────────────────────────── - const btnCollector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.Button, - filter: i => - i.user.id === interaction.user.id && - (i.customId === `autoverify_cfg_toggle_${guildId}` || i.customId === `autoverify_cfg_criteria_${guildId}`), - time: 600_000, - }); - - btnCollector.on('collect', async btnInteraction => { - try { - if (btnInteraction.customId === `autoverify_cfg_criteria_${guildId}`) { - 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; - await setGuildConfig(client, guildId, guildConfig); - - await btnInteraction.followUp({ - embeds: [ - successEmbed( - '✅ Status Updated', - `Auto-verification is now **${guildConfig.verification.autoVerify.enabled ? 'enabled' : 'disabled'}**.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(interaction, guildConfig, guildId, client); - } - } catch (err) { - logger.debug('Button interaction error:', err.message); - } - }); - - collector.on('end', async (collected, reason) => { - if (reason === 'time') { - 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.') - .setColor(getColor('error')); - await InteractionHelper.safeEditReply(interaction, { - embeds: [timeoutEmbed], - components: [], - flags: MessageFlags.Ephemeral, - }); - } catch (error) { - logger.debug('Could not update dashboard on timeout:', error.message); - } - } - }); - } catch (error) { - if (error instanceof TitanBotError) throw error; - logger.error('Unexpected error in autoverify_dashboard:', error); - throw new TitanBotError( - `Auto-verification dashboard failed: ${error.message}`, - ErrorTypes.UNKNOWN, - 'Failed to open the auto-verification dashboard.', - ); - } - }, -}; - -// ─── Handle Criteria ────────────────────────────────────────────────────────── - -async function handleCriteria(selectInteraction, rootInteraction, guildConfig, guildId, client) { - // Defer the interaction if it's a button, otherwise it was already deferred by select menu - if (!selectInteraction.deferred) { - await selectInteraction.deferUpdate().catch(() => null); - } - - const criteriaEmbed = new EmbedBuilder() - .setTitle('🎯 Select Verification Criteria') - .setDescription('Choose the criteria for automatic verification') - .setColor(getColor('info')); - - const criteriaMenu = new StringSelectMenuBuilder() - .setCustomId('autoverify_criteria_select') - .setPlaceholder('Select criteria...') - .addOptions( - new StringSelectMenuOptionBuilder() - .setLabel(`Account Age (older than ${defaultAccountAgeDays} days)`) - .setDescription('Users with older accounts will be auto-verified') - .setValue('account_age'), - new StringSelectMenuOptionBuilder() - .setLabel('No Criteria (verify everyone)') - .setDescription('All users gain the role immediately') - .setValue('none'), - ); - - await selectInteraction.followUp({ - embeds: [criteriaEmbed], - components: [new ActionRowBuilder().addComponents(criteriaMenu)], - flags: MessageFlags.Ephemeral, - }); - - const criteriaCollector = rootInteraction.channel.createMessageComponentCollector({ - componentType: ComponentType.StringSelect, - filter: i => - i.user.id === selectInteraction.user.id && i.customId === 'autoverify_criteria_select', - time: 60_000, - max: 1, - }); - - criteriaCollector.on('collect', async criteriaInteraction => { - await criteriaInteraction.deferUpdate(); - const newCriteria = criteriaInteraction.values[0]; - - guildConfig.verification.autoVerify.criteria = newCriteria; - - // Reset age-related fields if not using them - if (newCriteria !== 'account_age') { - guildConfig.verification.autoVerify.accountAgeDays = null; - } else if (!guildConfig.verification.autoVerify.accountAgeDays) { - guildConfig.verification.autoVerify.accountAgeDays = defaultAccountAgeDays; - } - - await setGuildConfig(client, guildId, guildConfig); - - let criteriaDisplay = ''; - switch (newCriteria) { - case 'account_age': - criteriaDisplay = `Account Age (${guildConfig.verification.autoVerify.accountAgeDays} days)`; - break; - case 'none': - criteriaDisplay = 'No Criteria'; - break; - } - - await criteriaInteraction.followUp({ - embeds: [successEmbed('✅ Criteria Updated', `Auto-verification criteria changed to **${criteriaDisplay}**.`)], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, guildConfig, guildId, client); - }); - - criteriaCollector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - selectInteraction - .followUp({ - embeds: [errorEmbed('Timed Out', 'No criteria selected. The setting was not changed.')], - flags: MessageFlags.Ephemeral, - }) - .catch(() => {}); - } - }); -} - -// ─── Handle Role ────────────────────────────────────────────────────────────── - -async function handleRole(selectInteraction, rootInteraction, guildConfig, guildId, client) { - await selectInteraction.deferUpdate(); - - const roleSelect = new RoleSelectMenuBuilder() - .setCustomId('autoverify_role_select') - .setPlaceholder('Select a role...') - .setMaxValues(1); - - await selectInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('🏷️ Auto-Verification Role') - .setDescription('Select the role to assign to auto-verified users.') - .setColor(getColor('info')), - ], - components: [new ActionRowBuilder().addComponents(roleSelect)], - flags: MessageFlags.Ephemeral, - }); - - const roleCollector = rootInteraction.channel.createMessageComponentCollector({ - componentType: ComponentType.RoleSelect, - filter: i => - i.user.id === selectInteraction.user.id && i.customId === 'autoverify_role_select', - time: 60_000, - max: 1, - }); - - roleCollector.on('collect', async roleInteraction => { - await roleInteraction.deferUpdate(); - const role = roleInteraction.roles.first(); - - if (role.id === rootInteraction.guild.id || role.managed) { - await roleInteraction.followUp({ - embeds: [ - errorEmbed( - 'Invalid Role', - 'Please choose a normal assignable role (not @everyone or a bot-managed role).', - ), - ], - flags: MessageFlags.Ephemeral, - }); - return; - } - - const botMember = rootInteraction.guild.members.me; - if (role.position >= botMember.roles.highest.position) { - await roleInteraction.followUp({ - embeds: [ - errorEmbed( - 'Role Too High', - 'The selected role must be below my highest role in the server role hierarchy.', - ), - ], - flags: MessageFlags.Ephemeral, - }); - return; - } - - guildConfig.verification.autoVerify.roleId = role.id; - await setGuildConfig(client, guildId, guildConfig); - - await roleInteraction.followUp({ - embeds: [successEmbed('✅ Role Updated', `Auto-verification role set to ${role}.`)], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, guildConfig, guildId, client); - }); - - roleCollector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - selectInteraction - .followUp({ - embeds: [errorEmbed('Timed Out', 'No role was selected. The setting was not changed.')], - flags: MessageFlags.Ephemeral, - }) - .catch(() => {}); - } - }); -} - -// ─── Handle Account Age ──────────────────────────────────────────────────────── - -async function handleAccountAge(selectInteraction, rootInteraction, guildConfig, guildId, client) { - const modal = new ModalBuilder() - .setCustomId('autoverify_account_age_modal') - .setTitle('Set Account Age Requirement') - .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()) - .setRequired(true), - ), - ); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => - i.customId === 'autoverify_account_age_modal' && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - const inputValue = submitted.fields.getTextInputValue('age_input').trim(); - const days = parseInt(inputValue, 10); - - if (isNaN(days) || days < minAccountAgeDays || days > maxAccountAgeDays) { - await submitted.reply({ - embeds: [errorEmbed('Invalid Input', `Please enter a number between ${minAccountAgeDays} and ${maxAccountAgeDays}.`)], - flags: MessageFlags.Ephemeral, - }); - return; - } - - guildConfig.verification.autoVerify.accountAgeDays = days; - await setGuildConfig(client, guildId, guildConfig); - - await submitted.reply({ - embeds: [successEmbed('✅ Account Age Updated', `Minimum account age requirement set to **${days} days**.`)], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, guildConfig, guildId, client); -} - -// ─── Handle Member Duration ──────────────────────────────────────────────────── - - diff --git a/src/commands/Verification/modules/verification_dashboard.js b/src/commands/Verification/modules/verification_dashboard.js deleted file mode 100644 index 371701581b..0000000000 --- a/src/commands/Verification/modules/verification_dashboard.js +++ /dev/null @@ -1,699 +0,0 @@ -import { botConfig, getColor } from '../../../config/bot.js'; -import { - ActionRowBuilder, - StringSelectMenuBuilder, - StringSelectMenuOptionBuilder, - ModalBuilder, - TextInputBuilder, - TextInputStyle, - ChannelSelectMenuBuilder, - RoleSelectMenuBuilder, - ButtonBuilder, - ButtonStyle, - ChannelType, - MessageFlags, - ComponentType, - EmbedBuilder, -} from 'discord.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { successEmbed, errorEmbed } from '../../../utils/embeds.js'; -import { logger } from '../../../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../../../utils/errorHandler.js'; -import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; -import { getWelcomeConfig } from '../../../utils/database.js'; -import { botHasPermission } from '../../../utils/permissionGuard.js'; - -// ─── Live Panel Sync ────────────────────────────────────────────────────────── - -async function updateLivePanel(guild, cfg) { - if (!cfg.channelId || !cfg.messageId) return; - try { - const channel = guild.channels.cache.get(cfg.channelId); - if (!channel) return; - const msg = await channel.messages.fetch(cfg.messageId).catch(() => null); - if (!msg) return; - - const verifyEmbed = new EmbedBuilder() - .setTitle('✅ Server Verification') - .setDescription(cfg.message || botConfig.verification.defaultMessage) - .setColor(getColor('success')); - - const verifyButton = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('verify_user') - .setLabel(cfg.buttonText || botConfig.verification.defaultButtonText) - .setStyle(ButtonStyle.Success) - .setEmoji('✅'), - ); - - await msg.edit({ embeds: [verifyEmbed], components: [verifyButton] }); - } catch (error) { - logger.warn('Could not update live verification panel:', error.message); - } -} - -// ─── Embed & Menu Builders ──────────────────────────────────────────────────── - -function buildDashboardEmbed(cfg, guild, verifiedUserCount = 0, conflictSummary = '') { - const channel = cfg.channelId ? `<#${cfg.channelId}>` : '`Not set`'; - const role = cfg.roleId ? `<@&${cfg.roleId}>` : '`Not set`'; - 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 embed = new EmbedBuilder() - .setTitle('🔒 Verification System Dashboard') - .setDescription(`Manage verification settings for **${guild.name}**.\nSelect an option below to modify a setting.`) - .setColor(getColor('info')) - .addFields( - { 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: '\u200B', value: '\u200B', inline: true }, - { name: '💬 Verification Message', value: msgPreview, inline: false }, - ); - - if (conflictSummary) { - embed.addFields({ name: '⚠️ Setup Conflicts', value: conflictSummary, inline: false }); - } - - return embed - .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) - .setTimestamp(); -} - -function buildSelectMenu(guildId) { - return new StringSelectMenuBuilder() - .setCustomId(`verif_cfg_${guildId}`) - .setPlaceholder('Select a setting to configure...') - .addOptions( - new StringSelectMenuOptionBuilder() - .setLabel('Change Verification Channel') - .setDescription('Set the channel where the verification panel is posted') - .setValue('channel') - .setEmoji('📢'), - new StringSelectMenuOptionBuilder() - .setLabel('Change Verified Role') - .setDescription('Set the role assigned when a user verifies') - .setValue('role') - .setEmoji('🏷️'), - new StringSelectMenuOptionBuilder() - .setLabel('Edit Verification Message') - .setDescription('Customise the message shown on the verification panel embed') - .setValue('message') - .setEmoji('💬'), - new StringSelectMenuOptionBuilder() - .setLabel('Edit Button Text') - .setDescription('Change the label on the verify button') - .setValue('button_text') - .setEmoji('🔘'), - ); -} - -function buildButtonRow(cfg, guildId, disabled = false) { - const systemOn = cfg.enabled !== false; - return new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`verif_cfg_toggle_${guildId}`) - .setLabel('Verification') - .setStyle(systemOn ? ButtonStyle.Success : ButtonStyle.Danger) - .setEmoji('🔒') - .setDisabled(disabled), - ); -} - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -async function refreshDashboard(rootInteraction, cfg, guildId, client) { - try { - const selectMenu = buildSelectMenu(guildId); - - // Get verified user count and conflict summary - let verifiedUserCount = 0; - let conflictSummary = ''; - - try { - const verifiedRole = rootInteraction.guild.roles.cache.get(cfg.roleId); - if (verifiedRole) { - verifiedUserCount = verifiedRole.members.size; - } - - const guildConfig = await getGuildConfig(client, guildId); - const welcomeConfig = await getWelcomeConfig(client, guildId); - const autoVerifyEnabled = Boolean(guildConfig.verification?.autoVerify?.enabled); - 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 - ].filter(Boolean); - - if (conflicts.length > 0) { - conflictSummary = conflicts.join('\n'); - } - } catch (error) { - logger.warn('Could not fetch verification dashboard details:', error.message); - } - - await InteractionHelper.safeEditReply(rootInteraction, { - embeds: [buildDashboardEmbed(cfg, rootInteraction.guild, verifiedUserCount, conflictSummary)], - components: [ - buildButtonRow(cfg, guildId), - new ActionRowBuilder().addComponents(selectMenu), - ], - flags: MessageFlags.Ephemeral, - }); - } catch (error) { - logger.debug('Could not refresh verification dashboard (interaction may have expired):', error.message); - } -} - -// ─── Main Export ────────────────────────────────────────────────────────────── - -export default { - async execute(interaction, config, client) { - try { - const guildId = interaction.guild.id; - const guildConfig = await getGuildConfig(client, guildId); - const cfg = guildConfig.verification; - - if (!cfg?.channelId) { - throw new TitanBotError( - 'Verification not configured', - ErrorTypes.CONFIGURATION, - 'The verification system has not been set up yet. Run `/verification setup` first.', - ); - } - - await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - - const selectMenu = buildSelectMenu(guildId); - - // Get verified user count and conflict summary - let verifiedUserCount = 0; - let conflictSummary = ''; - - try { - const verifiedRole = interaction.guild.roles.cache.get(cfg.roleId); - if (verifiedRole) { - verifiedUserCount = verifiedRole.members.size; - } - - const welcomeConfig = await getWelcomeConfig(client, guildId); - const autoVerifyEnabled = Boolean(guildConfig.verification?.autoVerify?.enabled); - 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 - ].filter(Boolean); - - if (conflicts.length > 0) { - conflictSummary = conflicts.join('\n'); - } - } catch (error) { - logger.warn('Could not fetch verification dashboard details:', error.message); - } - - await InteractionHelper.safeEditReply(interaction, { - embeds: [buildDashboardEmbed(cfg, interaction.guild, verifiedUserCount, conflictSummary)], - components: [ - buildButtonRow(cfg, guildId), - new ActionRowBuilder().addComponents(selectMenu), - ], - flags: MessageFlags.Ephemeral, - }); - - const collector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.StringSelect, - filter: i => - i.user.id === interaction.user.id && i.customId === `verif_cfg_${guildId}`, - time: 600_000, - }); - - collector.on('collect', async selectInteraction => { - const selectedOption = selectInteraction.values[0]; - try { - switch (selectedOption) { - case 'channel': - await handleChannel(selectInteraction, interaction, cfg, guildId, client); - break; - case 'role': - await handleRole(selectInteraction, interaction, cfg, guildId, client); - break; - case 'message': - await handleMessage(selectInteraction, interaction, cfg, guildId, client); - break; - case 'button_text': - await handleButtonText(selectInteraction, interaction, cfg, guildId, client); - break; - } - } catch (error) { - if (error instanceof TitanBotError) { - logger.debug(`Verification config validation error: ${error.message}`); - } else { - logger.error('Unexpected verification dashboard error:', error); - } - - const errorMessage = - error instanceof TitanBotError - ? error.userMessage || 'An error occurred while processing your selection.' - : 'An unexpected error occurred while updating the configuration.'; - - if (!selectInteraction.replied && !selectInteraction.deferred) { - await selectInteraction.deferUpdate().catch(() => {}); - } - - await selectInteraction - .followUp({ - embeds: [errorEmbed('Configuration Error', errorMessage)], - flags: MessageFlags.Ephemeral, - }) - .catch(() => {}); - } - }); - - // ── Button collector for toggle ────────────────────────────────── - const btnCollector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.Button, - filter: i => - i.user.id === interaction.user.id && - i.customId === `verif_cfg_toggle_${guildId}`, - time: 600_000, - }); - - btnCollector.on('collect', async btnInteraction => { - try { - await btnInteraction.deferUpdate().catch(() => null); - } catch (err) { - logger.debug('Button interaction already expired:', err.message); - return; - } - - const wasEnabled = cfg.enabled !== false; - const autoVerifyEnabled = Boolean(guildConfig.verification?.autoVerify?.enabled); - - // Prevent enabling Verification if AutoVerify is enabled - if (!wasEnabled && autoVerifyEnabled) { - await btnInteraction.followUp({ - embeds: [errorEmbed( - '❌ Cannot Enable Verification', - 'AutoVerify is currently enabled. Please disable AutoVerify first before enabling the manual Verification system.\n\nRun `/autoverify` to access the AutoVerify dashboard.' - )], - flags: MessageFlags.Ephemeral, - }); - return; - } - - cfg.enabled = !wasEnabled; - - // Disabling — remove the live panel message from the channel - if (!cfg.enabled && cfg.channelId && cfg.messageId) { - const channel = interaction.guild.channels.cache.get(cfg.channelId); - if (channel) { - try { - const msg = await channel.messages.fetch(cfg.messageId).catch(() => null); - if (msg) await msg.delete(); - } catch { - // already gone - } - } - } - - // Re-enabling — re-post the verification panel in the configured channel - if (cfg.enabled && cfg.channelId) { - const channel = interaction.guild.channels.cache.get(cfg.channelId); - if (channel) { - try { - const verifyEmbed = new EmbedBuilder() - .setTitle('✅ Server Verification') - .setDescription(cfg.message || botConfig.verification.defaultMessage) - .setColor(getColor('success')); - - const verifyButton = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('verify_user') - .setLabel(cfg.buttonText || botConfig.verification.defaultButtonText) - .setStyle(ButtonStyle.Success) - .setEmoji('✅'), - ); - - const newMsg = await channel.send({ embeds: [verifyEmbed], components: [verifyButton] }); - cfg.messageId = newMsg.id; - } catch (error) { - logger.warn('Could not re-post verification panel on re-enable:', error.message); - } - } - } - - const latestConfig = await getGuildConfig(client, guildId); - latestConfig.verification = cfg; - await setGuildConfig(client, guildId, latestConfig); - - await btnInteraction.followUp({ - embeds: [ - successEmbed( - '✅ System Updated', - `The verification system is now **${cfg.enabled ? 'enabled' : 'disabled'}**.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(interaction, cfg, guildId, client); - }); - - collector.on('end', async (collected, reason) => { - if (reason === 'time') { - btnCollector.stop(); - try { - 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.') - .setColor(getColor('error')) - ], - components: [], - flags: MessageFlags.Ephemeral, - }); - } catch (error) { - logger.debug('Could not update dashboard on timeout:', error.message); - } - } - }); - } catch (error) { - if (error instanceof TitanBotError) throw error; - logger.error('Unexpected error in verification_dashboard:', error); - throw new TitanBotError( - `Verification dashboard failed: ${error.message}`, - ErrorTypes.UNKNOWN, - 'Failed to open the verification dashboard.', - ); - } - }, -}; - -// ─── Change Verification Channel ───────────────────────────────────────────── - -async function handleChannel(selectInteraction, rootInteraction, cfg, guildId, client) { - await selectInteraction.deferUpdate(); - - const channelSelect = new ChannelSelectMenuBuilder() - .setCustomId('verif_cfg_channel') - .setPlaceholder('Select a text channel...') - .addChannelTypes(ChannelType.GuildText) - .setMaxValues(1); - - await selectInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('📢 Change Verification Channel') - .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.`, - ) - .setColor(getColor('info')), - ], - components: [new ActionRowBuilder().addComponents(channelSelect)], - flags: MessageFlags.Ephemeral, - }); - - const chanCollector = rootInteraction.channel.createMessageComponentCollector({ - componentType: ComponentType.ChannelSelect, - filter: i => - i.user.id === selectInteraction.user.id && i.customId === 'verif_cfg_channel', - time: 60_000, - max: 1, - }); - - chanCollector.on('collect', async chanInteraction => { - await chanInteraction.deferUpdate(); - const newChannel = chanInteraction.channels.first(); - - if (!botHasPermission(newChannel, ['ViewChannel', 'SendMessages', 'EmbedLinks'])) { - await chanInteraction.followUp({ - embeds: [ - errorEmbed( - 'Missing Permissions', - `I need **View Channel**, **Send Messages**, and **Embed Links** permissions in ${newChannel}.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - return; - } - - // Delete old panel if it exists - if (cfg.channelId && cfg.messageId) { - const oldChannel = rootInteraction.guild.channels.cache.get(cfg.channelId); - if (oldChannel) { - try { - const oldMsg = await oldChannel.messages.fetch(cfg.messageId).catch(() => null); - if (oldMsg) await oldMsg.delete(); - } catch { - // already gone - } - } - } - - // Post new panel in the new channel (only if system is enabled) - if (cfg.enabled !== false) { - try { - const verifyEmbed = new EmbedBuilder() - .setTitle('✅ Server Verification') - .setDescription(cfg.message || botConfig.verification.defaultMessage) - .setColor(getColor('success')); - - const verifyButton = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('verify_user') - .setLabel(cfg.buttonText || botConfig.verification.defaultButtonText) - .setStyle(ButtonStyle.Success) - .setEmoji('✅'), - ); - - 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); - } - } - - cfg.channelId = newChannel.id; - const latestConfig = await getGuildConfig(client, guildId); - latestConfig.verification = cfg; - await setGuildConfig(client, guildId, latestConfig); - - await chanInteraction.followUp({ - embeds: [successEmbed('✅ Channel Updated', `Verification panel moved to ${newChannel}.`)], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, cfg, guildId, client); - }); - - chanCollector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - selectInteraction - .followUp({ - embeds: [errorEmbed('Timed Out', 'No channel was selected. The setting was not changed.')], - flags: MessageFlags.Ephemeral, - }) - .catch(() => {}); - } - }); -} - -// ─── Change Verified Role ───────────────────────────────────────────────────── - -async function handleRole(selectInteraction, rootInteraction, cfg, guildId, client) { - await selectInteraction.deferUpdate(); - - const roleSelect = new RoleSelectMenuBuilder() - .setCustomId('verif_cfg_role') - .setPlaceholder('Select a role...') - .setMaxValues(1); - - await selectInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('🏷️ Change Verified Role') - .setDescription( - `**Current:** ${cfg.roleId ? `<@&${cfg.roleId}>` : '`Not set`'}\n\nSelect the role to assign when a user verifies.`, - ) - .setColor(getColor('info')), - ], - components: [new ActionRowBuilder().addComponents(roleSelect)], - flags: MessageFlags.Ephemeral, - }); - - const roleCollector = rootInteraction.channel.createMessageComponentCollector({ - componentType: ComponentType.RoleSelect, - filter: i => - i.user.id === selectInteraction.user.id && i.customId === 'verif_cfg_role', - time: 60_000, - max: 1, - }); - - roleCollector.on('collect', async roleInteraction => { - await roleInteraction.deferUpdate(); - const role = roleInteraction.roles.first(); - const guild = rootInteraction.guild; - const botMember = guild.members.me; - - if (role.id === guild.id || role.managed) { - await roleInteraction.followUp({ - embeds: [ - errorEmbed( - 'Invalid Role', - 'Please choose a normal assignable role (not @everyone or a bot-managed role).', - ), - ], - flags: MessageFlags.Ephemeral, - }); - return; - } - - if (role.position >= botMember.roles.highest.position) { - await roleInteraction.followUp({ - embeds: [ - errorEmbed( - 'Role Too High', - 'The verified role must be below my highest role in the server role hierarchy.', - ), - ], - flags: MessageFlags.Ephemeral, - }); - return; - } - - cfg.roleId = role.id; - const latestConfig = await getGuildConfig(client, guildId); - latestConfig.verification = cfg; - await setGuildConfig(client, guildId, latestConfig); - - await roleInteraction.followUp({ - embeds: [successEmbed('✅ Role Updated', `Verified role set to ${role}.`)], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, cfg, guildId, client); - }); - - roleCollector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - selectInteraction - .followUp({ - embeds: [errorEmbed('Timed Out', 'No role was selected. The setting was not changed.')], - flags: MessageFlags.Ephemeral, - }) - .catch(() => {}); - } - }); -} - -// ─── Edit Verification Message ──────────────────────────────────────────────── - -async function handleMessage(selectInteraction, rootInteraction, cfg, guildId, client) { - try { - const modal = new ModalBuilder() - .setCustomId('verif_cfg_message') - .setTitle('Edit Verification Message') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('message_input') - .setLabel('Message shown on the verification panel embed') - .setStyle(TextInputStyle.Paragraph) - .setValue(cfg.message || botConfig.verification.defaultMessage) - .setMaxLength(2000) - .setMinLength(1) - .setRequired(true), - ), - ); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => - i.customId === 'verif_cfg_message' && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - cfg.message = submitted.fields.getTextInputValue('message_input').trim(); - - const latestConfig = await getGuildConfig(client, guildId); - latestConfig.verification = cfg; - await setGuildConfig(client, guildId, latestConfig); - - await updateLivePanel(rootInteraction.guild, cfg); - - await submitted.reply({ - embeds: [successEmbed('✅ Message Updated', 'The verification panel has been updated with the new message.')], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, cfg, guildId, client); - } catch (error) { - logger.error('Error in handleMessage:', error); - // Silently fail - modal display failed, user can try again - } -} - -// ─── Edit Button Text ───────────────────────────────────────────────────────── - -async function handleButtonText(selectInteraction, rootInteraction, cfg, guildId, client) { - try { - const modal = new ModalBuilder() - .setCustomId('verif_cfg_button_text') - .setTitle('Edit Button Text') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('button_text_input') - .setLabel('Button label (max 80 characters)') - .setStyle(TextInputStyle.Short) - .setValue(cfg.buttonText || botConfig.verification.defaultButtonText) - .setMaxLength(80) - .setMinLength(1) - .setRequired(true), - ), - ); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => - i.customId === 'verif_cfg_button_text' && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - cfg.buttonText = submitted.fields.getTextInputValue('button_text_input').trim(); - - const latestConfig = await getGuildConfig(client, guildId); - latestConfig.verification = cfg; - await setGuildConfig(client, guildId, latestConfig); - - await updateLivePanel(rootInteraction.guild, cfg); - - await submitted.reply({ - embeds: [successEmbed('✅ Button Text Updated', `The verify button now reads **${cfg.buttonText}**.`)], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, cfg, guildId, client); - } catch (error) { - logger.error('Error in handleButtonText:', error); - // Silently fail - modal display failed, user can try again - } -} diff --git a/src/commands/Verification/verification.js b/src/commands/Verification/verification.js deleted file mode 100644 index 5852451833..0000000000 --- a/src/commands/Verification/verification.js +++ /dev/null @@ -1,266 +0,0 @@ -import { botConfig, getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, infoEmbed, successEmbed } from '../../utils/embeds.js'; -import { getGuildConfig, setGuildConfig } from '../../services/guildConfig.js'; -import { handleInteractionError, withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { removeVerification, verifyUser } from '../../services/verificationService.js'; -import { ContextualMessages } from '../../utils/messageTemplates.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'; - -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 () => { - 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); - } -}; - -async function handleSetup(interaction, guild, client) { - const verificationChannel = interaction.options.getChannel("verification_channel"); - 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: [ContextualMessages.configUpdated( - "Verification System", - [ - `Channel: ${verificationChannel}`, - `Verified Role: ${verifiedRole}`, - `Button Text: ${buttonText}` - ] - )] - }); -} - -async function handleRemove(interaction, guild, client) { - const targetUser = interaction.options.getUser("user"); - - try { - const result = await removeVerification(client, guild.id, targetUser.id, { - moderatorId: interaction.user.id, - reason: 'admin_removal' - }); - - if (!result.success) { - if (result.notVerified) { - return await InteractionHelper.safeReply(interaction, { - embeds: [infoEmbed("Not Verified", `${targetUser.tag} does not currently have the verified role.`)], - flags: MessageFlags.Ephemeral - }); - } - } - - logger.info('Verification removed via command', { - guildId: guild.id, - targetUserId: targetUser.id, - moderatorId: interaction.user.id - }); - - return await InteractionHelper.safeReply(interaction, { - embeds: [successEmbed("Verification Removed", `Verification removed from ${targetUser.tag}.`)] - }); - - } catch (error) { - await handleInteractionError( - interaction, - error, - { command: 'verification', subcommand: 'remove' } - ); - } -} - - - - diff --git a/src/commands/Verification/verify.js b/src/commands/Verification/verify.js deleted file mode 100644 index a49ff844a7..0000000000 --- a/src/commands/Verification/verify.js +++ /dev/null @@ -1,50 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { errorEmbed, infoEmbed, successEmbed } from '../../utils/embeds.js'; -import { withErrorHandling } from '../../utils/errorHandler.js'; -import { verifyUser } from '../../services/verificationService.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export default { - data: new SlashCommandBuilder() - .setName('verify') - .setDescription('Verify yourself and gain access to the server'), - - async execute(interaction, config, client) { - const wrappedExecute = withErrorHandling(async () => { - const guild = interaction.guild; - - const result = await verifyUser(client, guild.id, interaction.user.id, { - source: 'command_self', - moderatorId: null - }); - - if (!result.success) { - if (result.alreadyVerified) { - return await InteractionHelper.safeReply(interaction, { - embeds: [infoEmbed("Already Verified", "You are already verified.")], - flags: MessageFlags.Ephemeral - }); - } - - return await InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed( - "Verification Failed", - "An error occurred during verification. Please try again or contact an administrator." - )], - 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! 🎉` - )], - flags: MessageFlags.Ephemeral - }); - }, { command: 'verify' }); - - return await wrappedExecute(interaction, config, client); - } -}; diff --git a/src/commands/Voice/activity.js b/src/commands/Voice/activity.js deleted file mode 100644 index bcc3dc1a27..0000000000 --- a/src/commands/Voice/activity.js +++ /dev/null @@ -1,215 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getColor } from '../../config/bot.js'; - -const ACTIVITIES = { - 'youtube': '880218394199220334', - 'poker': '755827207812677713', - 'chess': '832012774040141894', - 'checkers': '832013003968348200', - 'letter-league': '879863686565621790', - 'spellcast': '852509694341283871', - 'sketch': '902271654783242291', - 'blazing8s': '832025144389533716', - 'puttparty': '945737671223947305', - 'landio': '903769130790969345', - 'bobble': '947957217959759964', - 'knowwhat': '976052223358406656' -}; - -const ACTIVITY_NAMES = { - 'youtube': 'YouTube Together', - 'poker': 'Poker Night', - 'chess': 'Chess in the Park', - 'checkers': 'Checkers in the Park', - 'letter-league': 'Letter League', - 'spellcast': 'SpellCast', - 'sketch': 'Sketch Heads', - 'blazing8s': 'Blazing 8s', - 'puttparty': 'Putt Party', - 'landio': 'Land-io', - 'bobble': 'Bobble League', - 'knowwhat': 'Know What I Mean' -}; - -export default { - data: new SlashCommandBuilder() - .setName('activity') - .setDescription('Start a Discord Activity in your voice channel') - .setDMPermission(false) - .setDefaultMemberPermissions(PermissionFlagsBits.Connect) - - .addSubcommand(subcommand => - subcommand - .setName('youtube') - .setDescription('Watch YouTube videos together in a voice channel') - ) - - .addSubcommand(subcommand => - subcommand - .setName('poker') - .setDescription('Play Poker Night with friends') - ) - - .addSubcommand(subcommand => - subcommand - .setName('chess') - .setDescription('Play Chess in the Park') - ) - - .addSubcommand(subcommand => - subcommand - .setName('checkers') - .setDescription('Play Checkers in the Park') - ) - - .addSubcommand(subcommand => - subcommand - .setName('letter-league') - .setDescription('Play the word-based game Letter League') - ) - - .addSubcommand(subcommand => - subcommand - .setName('spellcast') - .setDescription('Play the magical word game SpellCast') - ) - - .addSubcommand(subcommand => - subcommand - .setName('sketch') - .setDescription('Play Sketch Heads (Pictionary style)') - ) - - .addSubcommand(subcommand => - subcommand - .setName('blazing8s') - .setDescription('Play the card game Blazing 8s') - ) - - .addSubcommand(subcommand => - subcommand - .setName('puttparty') - .setDescription('Play Putt Party (Mini-golf)') - ) - - .addSubcommand(subcommand => - subcommand - .setName('landio') - .setDescription('Play the territory game Land-io') - ) - - .addSubcommand(subcommand => - subcommand - .setName('bobble') - .setDescription('Play Bobble League') - ) - - .addSubcommand(subcommand => - subcommand - .setName('knowwhat') - .setDescription('Play Know What I Mean') - ), - - category: "Voice", - - async execute(interaction, config, client) { - try { - - const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - if (!deferred) { - return; - } - - const { member, options } = interaction; - const activity = options.getSubcommand(); - const activityId = ACTIVITIES[activity]; - const activityName = ACTIVITY_NAMES[activity] || activity; - - if (!member.voice.channel) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Not in Voice Channel', 'You need to be in a voice channel to start an activity!')] - }); - } - - logger.debug('Activity command - validating permissions', { - userId: interaction.user.id, - voiceChannelId: member.voice.channel.id, - voiceChannelName: member.voice.channel.name, - activity: activity - }); - - const permissions = member.voice.channel.permissionsFor(interaction.guild.members.me); - if (!permissions.has('CreateInstantInvite')) { - logger.warn('Activity command - missing permissions', { - userId: interaction.user.id, - voiceChannelId: member.voice.channel.id, - guildId: interaction.guildId, - activity: activity, - missingPermission: 'CreateInstantInvite' - }); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Missing Permissions', 'I need the `Create Invite` permission to start an activity!')] - }); - } - - const invite = await interaction.client.rest.post( - `/channels/${member.voice.channel.id}/invites`, - { - body: { - max_age: 86400, - target_type: 2, - target_application_id: activityId, - }, - } - ); - - logger.info('Activity invite created successfully', { - userId: interaction.user.id, - userTag: interaction.user.tag, - voiceChannelId: member.voice.channel.id, - voiceChannelName: member.voice.channel.name, - guildId: interaction.guildId, - activity: activity, - activityName: activityName, - inviteCode: invite.code, - commandName: 'activity' - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [createEmbed({ - title: `🎮 ${activityName}`, - description: `Click the link below to start **${activityName}** in ${member.voice.channel.name}!\n\n[Join ${activityName} Activity](https://discord.gg/${invite.code})`, - color: 'success' - })] - }); - - } catch (error) { - logger.error('Error creating activity invite', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - voiceChannelId: interaction.member?.voice.channel?.id, - guildId: interaction.guildId, - activity: options.getSubcommand(), - commandName: 'activity' - }); - - if (!interaction.deferred && !interaction.replied) { - await handleInteractionError(interaction, error, { - commandName: 'activity', - source: 'discord_activity_api' - }); - } else { - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Failed to Create Activity', 'An error occurred while trying to create the activity. Please try again later.')] - }); - } - } - }, -}; - - diff --git a/src/commands/Welcome/autorole.js b/src/commands/Welcome/autorole.js deleted file mode 100644 index e8f5627a1f..0000000000 --- a/src/commands/Welcome/autorole.js +++ /dev/null @@ -1,250 +0,0 @@ -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, EmbedBuilder, MessageFlags } from 'discord.js'; -import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; -import { logger } from '../../utils/logger.js'; -import { errorEmbed } from '../../utils/embeds.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getGuildConfig } from '../../services/guildConfig.js'; - -function createAutoroleInfoEmbed(description) { - return new EmbedBuilder() - .setColor(getColor('primary')) - .setDescription(description) - .setFooter({ text: new Date().toLocaleString() }); -} - -export default { - data: new SlashCommandBuilder() - .setName('autorole') - .setDescription('Manage roles that are automatically assigned to new members') - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .addSubcommand(subcommand => - subcommand - .setName('add') - .setDescription('Add a role to be automatically assigned to new members') - .addRoleOption(option => - option.setName('role') - .setDescription('The role to add') - .setRequired(true))) - .addSubcommand(subcommand => - subcommand - .setName('remove') - .setDescription('Remove a role from auto-assignment') - .addRoleOption(option => - option.setName('role') - .setDescription('The role to remove') - .setRequired(true))) - .addSubcommand(subcommand => - subcommand - .setName('list') - .setDescription('List all auto-assigned roles')), - - async execute(interaction) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Autorole interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'autorole' - }); - return; - } - - if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Missing Permissions', 'You need the **Manage Server** permission to use `/autorole`.')], - flags: MessageFlags.Ephemeral - }); - } - - const { options, guild, client } = interaction; - const subcommand = options.getSubcommand(); - - if (subcommand === 'add') { - const role = options.getRole('role'); - - const guildConfig = await getGuildConfig(client, guild.id); - const verificationEnabled = Boolean(guildConfig.verification?.enabled); - const autoVerifyEnabled = Boolean(guildConfig.verification?.autoVerify?.enabled); - - if (verificationEnabled || autoVerifyEnabled) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed( - 'Setup Conflict', - 'You cannot add AutoRole while the verification system or AutoVerify is enabled. Disable those first.' - )], - flags: MessageFlags.Ephemeral - }); - } - - 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 InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Role Too High', "I can't assign roles that are higher than my highest role.")], - flags: MessageFlags.Ephemeral - }); - } - - try { - const config = await getWelcomeConfig(client, guild.id); - const existingRoles = config.roleIds || []; - const currentRoleId = existingRoles[0] || null; - - - if (currentRoleId === role.id) { - logger.info(`[Autorole] User ${interaction.user.tag} tried to add duplicate role ${role.name} (${role.id}) in ${guild.name}`); - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Already Added', `The role ${role} is already set to be auto-assigned.`)], - flags: MessageFlags.Ephemeral - }); - } - - await updateWelcomeConfig(client, guild.id, { - roleIds: [role.id] - }); - - logger.info(`[Autorole] Set single auto-role to ${role.name} (${role.id}) in ${guild.name} by ${interaction.user.tag}`); - await InteractionHelper.safeEditReply(interaction, { - embeds: [createAutoroleInfoEmbed( - currentRoleId - ? `✅ Auto-role updated to ${role}. Only one auto-role is allowed.` - : `✅ Auto-role set to ${role}.` - )], - flags: MessageFlags.Ephemeral - }); - } catch (error) { - logger.error(`[Autorole] Failed to add role for guild ${guild.id}:`, error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed( - 'Add Failed', - 'An error occurred while adding the role. Please try again.', - { showDetails: true } - )], - flags: MessageFlags.Ephemeral - }); - } - } - - else if (subcommand === 'remove') { - const role = options.getRole('role'); - - try { - const config = await getWelcomeConfig(client, guild.id); - const existingRoles = config.roleIds || []; - - 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 InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Not Found', `The role ${role} is not set to be auto-assigned.`)], - flags: MessageFlags.Ephemeral - }); - } - - const updatedRoles = existingRoles.filter(id => id !== role.id); - - await updateWelcomeConfig(client, guild.id, { - roleIds: updatedRoles - }); - - 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.`)], - flags: MessageFlags.Ephemeral - }); - } catch (error) { - logger.error(`[Autorole] Failed to remove role for guild ${guild.id}:`, error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed( - 'Remove Failed', - 'An error occurred while removing the role. Please try again.', - { showDetails: true } - )], - flags: MessageFlags.Ephemeral - }); - } - } - - else if (subcommand === 'list') { - try { - const guildConfig = await getGuildConfig(client, guild.id); - 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 - ].filter(Boolean).join('\n'); - - const config = await getWelcomeConfig(client, guild.id); - const autoRoles = Array.isArray(config.roleIds) ? config.roleIds : []; - - const singleRoleIds = autoRoles.length > 1 ? [autoRoles[0]] : autoRoles; - if (singleRoleIds.length !== autoRoles.length) { - await updateWelcomeConfig(client, guild.id, { - roleIds: singleRoleIds - }); - logger.info(`[Autorole] Trimmed auto-role list to one role in ${interaction.guild.name}`); - } - - 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}` : ''}`)], - flags: MessageFlags.Ephemeral - }); - } - - const roles = await guild.roles.fetch(); - const validRoles = []; - const invalidRoleIds = []; - - for (const roleId of singleRoleIds) { - const role = roles.get(roleId); - if (role) { - validRoles.push(role); - } else { - invalidRoleIds.push(roleId); - } - } - - if (invalidRoleIds.length > 0) { - 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 - }); - } - - 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}` : ''}`)], - 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.' }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - flags: MessageFlags.Ephemeral - }); - - } catch (error) { - logger.error(`[Autorole] Failed to list roles for guild ${guild.id}:`, error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed( - 'List Failed', - 'An error occurred while listing auto-assigned roles. Please try again.', - { showDetails: true } - )], - flags: MessageFlags.Ephemeral - }); - } - } - }, -}; - - - diff --git a/src/commands/Welcome/goodbye.js b/src/commands/Welcome/goodbye.js deleted file mode 100644 index 736924a5a2..0000000000 --- a/src/commands/Welcome/goodbye.js +++ /dev/null @@ -1,152 +0,0 @@ -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, EmbedBuilder, MessageFlags } from 'discord.js'; -import { errorEmbed } from '../../utils/embeds.js'; -import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; -import { formatWelcomeMessage } from '../../utils/welcome.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export default { - data: new SlashCommandBuilder() - .setName('goodbye') - .setDescription('Configure the goodbye message system') - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .addSubcommand(subcommand => - subcommand - .setName('setup') - .setDescription('Set up the goodbye message') - .addChannelOption(option => - option.setName('channel') - .setDescription('The channel to send goodbye messages to') - .addChannelTypes(ChannelType.GuildText) - .setRequired(true)) - .addStringOption(option => - option.setName('message') - .setDescription('Goodbye message. Variables: {user}, {username}, {server}, {memberCount}') - .setRequired(true)) - .addStringOption(option => - option.setName('image') - .setDescription('URL of the image to include in the goodbye message') - .setRequired(false)) - .addBooleanOption(option => - option.setName('ping') - .setDescription('Whether to ping the user in the goodbye message') - .setRequired(false))), - - async execute(interaction) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Goodbye interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'goodbye' - }); - return; - } - - const { options, guild, client } = interaction; - - if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Missing Permissions', 'You need the **Manage Server** permission to use `/goodbye`.')], - flags: MessageFlags.Ephemeral - }); - } - - 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?.goodbyeChannelId) { - logger.info(`[Goodbye] Setup blocked because config already exists in channel ${existingConfig.goodbyeChannelId} for guild ${guild.id}`); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed( - 'Goodbye Setup Already Exists', - `Goodbye is already configured for <#${existingConfig.goodbyeChannelId}>. Use **/goodbye config** to customize channel, message, ping, or image.` - )], - flags: MessageFlags.Ephemeral - }); - } - - - if (!message || message.trim().length === 0) { - logger.warn(`[Goodbye] Empty message provided by ${interaction.user.tag} in ${guild.name}`); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Invalid Input', 'Goodbye message cannot be empty')], - flags: MessageFlags.Ephemeral - }); - } - - - if (image) { - try { - new URL(image); - } catch (e) { - logger.warn(`[Goodbye] Invalid image URL provided by ${interaction.user.tag}: ${image}`); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Invalid Image URL', 'Please provide a valid image URL (must start with http:// or https://')], - flags: MessageFlags.Ephemeral - }); - } - } - - try { - await updateWelcomeConfig(client, guild.id, { - goodbyeEnabled: true, - goodbyeChannelId: channel.id, - leaveMessage: message, - goodbyePing: ping, - leaveEmbed: { - title: "Goodbye {user.tag}", - description: message, - color: getColor('error'), - footer: `Goodbye from ${guild.name}!`, - ...(image && { image: { url: image } }) - } - }); - - logger.info(`[Goodbye] 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('✅ Goodbye System Configured') - .setDescription(`Goodbye messages will now be sent to ${channel}`) - .addFields( - { name: 'Message Preview', value: previewMessage }, - { name: 'Ping User', value: ping ? '✅ Yes' : '❌ No' }, - { name: 'Status', value: '✅ Enabled' } - ) - .setFooter({ text: 'Tip: Use /goodbye config to customize goodbye settings' }); - - if (image) { - embed.setImage(image); - } - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } catch (error) { - logger.error(`[Goodbye] Failed to setup goodbye system for guild ${guild.id}:`, error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed( - 'Setup Failed', - 'An error occurred while configuring the goodbye system. Please try again.', - { showDetails: true } - )], - flags: MessageFlags.Ephemeral - }); - } - } - }, -}; - - - diff --git a/src/commands/Welcome/greet.js b/src/commands/Welcome/greet.js deleted file mode 100644 index ce2b93e17e..0000000000 --- a/src/commands/Welcome/greet.js +++ /dev/null @@ -1,51 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; -import { errorEmbed } from '../../utils/embeds.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, TitanBotError } from '../../utils/errorHandler.js'; -import greetDashboard from './modules/greet_dashboard.js'; - -export default { - data: new SlashCommandBuilder() - .setName('greet') - .setDescription('Manage welcome & goodbye settings') - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .addSubcommand(subcommand => - subcommand - .setName('dashboard') - .setDescription('Open the welcome & goodbye configuration dashboard'), - ), - - async execute(interaction, config, client) { - try { - if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { - return await InteractionHelper.safeReply(interaction, { - embeds: [ - errorEmbed( - 'Missing Permissions', - 'You need the **Manage Server** permission to use `/greet`.', - ), - ], - flags: MessageFlags.Ephemeral, - }); - } - - const subcommand = interaction.options.getSubcommand(); - - switch (subcommand) { - case 'dashboard': - return await greetDashboard.execute(interaction, config, client); - default: - logger.warn(`Unknown /greet subcommand: ${subcommand}`); - } - } catch (error) { - if (error instanceof TitanBotError) { - return await InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Configuration Error', error.userMessage || 'Something went wrong.')], - flags: MessageFlags.Ephemeral, - }); - } - await handleInteractionError(interaction, error, { command: 'greet' }); - } - }, -}; diff --git a/src/commands/Welcome/modules/greet_dashboard.js b/src/commands/Welcome/modules/greet_dashboard.js deleted file mode 100644 index 2296ccd2ef..0000000000 --- a/src/commands/Welcome/modules/greet_dashboard.js +++ /dev/null @@ -1,805 +0,0 @@ -import { getColor } from '../../../config/bot.js'; -import { - ActionRowBuilder, - StringSelectMenuBuilder, - StringSelectMenuOptionBuilder, - ModalBuilder, - TextInputBuilder, - TextInputStyle, - ChannelSelectMenuBuilder, - ButtonBuilder, - ButtonStyle, - ChannelType, - MessageFlags, - ComponentType, - EmbedBuilder, - LabelBuilder, - FileUploadBuilder, - TextDisplayBuilder, -} from 'discord.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { successEmbed, errorEmbed } from '../../../utils/embeds.js'; -import { logger } from '../../../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../../../utils/errorHandler.js'; -import { getWelcomeConfig, saveWelcomeConfig } from '../../../utils/database.js'; -import { botHasPermission } from '../../../utils/permissionGuard.js'; - -// ─── Embed & Menu Builders ──────────────────────────────────────────────────── - -function buildDashboardEmbed(cfg, guild) { - const welcomeChannel = cfg.channelId ? `<#${cfg.channelId}>` : '`Not set`'; - const goodbyeChannel = cfg.goodbyeChannelId ? `<#${cfg.goodbyeChannelId}>` : '`Not set`'; - - const rawWelcome = cfg.welcomeMessage || 'Welcome {user} to {server}!'; - const rawGoodbye = cfg.leaveMessage || '{user.tag} has left the server.'; - 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') - .setDescription( - `Manage welcome & goodbye settings for **${guild.name}**.\nUse the toggles to enable/disable each side, then select an option to edit.`, - ) - .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 }, - ) - .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) - .setTimestamp(); -} - -function buildSelectMenu(guildId) { - return new StringSelectMenuBuilder() - .setCustomId(`greet_cfg_${guildId}`) - .setPlaceholder('Select a setting to configure...') - .addOptions( - new StringSelectMenuOptionBuilder() - .setLabel('Welcome Channel') - .setDescription('Set the channel where welcome messages are sent') - .setValue('welcome_channel') - .setEmoji('🟢'), - new StringSelectMenuOptionBuilder() - .setLabel('Welcome Message') - .setDescription('Edit the text shown when a member joins') - .setValue('welcome_message') - .setEmoji('💬'), - new StringSelectMenuOptionBuilder() - .setLabel('Welcome Image') - .setDescription('Set the image for welcome messages') - .setValue('welcome_image') - .setEmoji('🖼️'), - new StringSelectMenuOptionBuilder() - .setLabel('Goodbye Channel') - .setDescription('Set the channel where goodbye messages are sent') - .setValue('goodbye_channel') - .setEmoji('🔴'), - new StringSelectMenuOptionBuilder() - .setLabel('Goodbye Message') - .setDescription('Edit the text shown when a member leaves') - .setValue('goodbye_message') - .setEmoji('💬'), - new StringSelectMenuOptionBuilder() - .setLabel('Goodbye Image') - .setDescription('Set the image for goodbye messages') - .setValue('goodbye_image') - .setEmoji('🖼️'), - ); -} - -function buildButtonRow(cfg, guildId, 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 ButtonBuilder() - .setCustomId(`greet_cfg_toggle_welcome_${guildId}`) - .setLabel('Welcome') - .setStyle(welcomeOn ? ButtonStyle.Success : ButtonStyle.Danger) - .setEmoji('🟢') - .setDisabled(disabled), - new ButtonBuilder() - .setCustomId(`greet_cfg_toggle_goodbye_${guildId}`) - .setLabel('Goodbye') - .setStyle(goodbyeOn ? ButtonStyle.Success : ButtonStyle.Danger) - .setEmoji('🔴') - .setDisabled(disabled), - ), - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`greet_cfg_ping_welcome_${guildId}`) - .setLabel('Ping Welcome') - .setStyle(welcomePingOn ? ButtonStyle.Primary : ButtonStyle.Secondary) - .setEmoji('🔔') - .setDisabled(disabled), - new ButtonBuilder() - .setCustomId(`greet_cfg_ping_goodbye_${guildId}`) - .setLabel('Ping Goodbye') - .setStyle(goodbyePingOn ? ButtonStyle.Primary : ButtonStyle.Secondary) - .setEmoji('🔔') - .setDisabled(disabled), - ), - ]; -} - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -async function refreshDashboard(rootInteraction, cfg, guildId) { - try { - const selectMenu = buildSelectMenu(guildId); - await InteractionHelper.safeEditReply(rootInteraction, { - embeds: [buildDashboardEmbed(cfg, rootInteraction.guild)], - components: [ - ...buildButtonRow(cfg, guildId), - new ActionRowBuilder().addComponents(selectMenu), - ], - flags: MessageFlags.Ephemeral, - }); - } catch (error) { - logger.debug('Could not refresh greet dashboard (interaction may have expired):', error.message); - } -} - -// ─── Main Export ────────────────────────────────────────────────────────────── - -export default { - async execute(interaction, config, client) { - try { - const guildId = interaction.guild.id; - const cfg = await getWelcomeConfig(client, guildId); - - if (!cfg.channelId && !cfg.goodbyeChannelId) { - throw new TitanBotError( - 'Greet system not configured', - ErrorTypes.CONFIGURATION, - 'Neither Welcome nor Goodbye has been set up yet. Run `/welcome setup` or `/goodbye setup` first.', - ); - } - - await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - - const selectMenu = buildSelectMenu(guildId); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [buildDashboardEmbed(cfg, interaction.guild)], - components: [ - ...buildButtonRow(cfg, guildId), - new ActionRowBuilder().addComponents(selectMenu), - ], - flags: MessageFlags.Ephemeral, - }); - - // ── Select collector ────────────────────────────────────────────── - const collector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.StringSelect, - filter: i => - i.user.id === interaction.user.id && i.customId === `greet_cfg_${guildId}`, - time: 600_000, - }); - - collector.on('collect', async selectInteraction => { - const selectedOption = selectInteraction.values[0]; - try { - switch (selectedOption) { - case 'welcome_channel': - await handleWelcomeChannel(selectInteraction, interaction, cfg, guildId, client); - break; - case 'welcome_message': - await handleWelcomeMessage(selectInteraction, interaction, cfg, guildId, client); - break; - case 'welcome_image': - await handleWelcomeImage(selectInteraction, interaction, cfg, guildId, client); - break; - case 'goodbye_channel': - await handleGoodbyeChannel(selectInteraction, interaction, cfg, guildId, client); - break; - case 'goodbye_message': - await handleGoodbyeMessage(selectInteraction, interaction, cfg, guildId, client); - break; - case 'goodbye_image': - await handleGoodbyeImage(selectInteraction, interaction, cfg, guildId, client); - break; - } - } catch (error) { - if (error instanceof TitanBotError) { - logger.debug(`Greet config validation error: ${error.message}`); - } else { - logger.error('Unexpected greet dashboard error:', error); - } - - const errorMessage = - error instanceof TitanBotError - ? error.userMessage || 'An error occurred while processing your selection.' - : 'An unexpected error occurred while updating the configuration.'; - - if (!selectInteraction.replied && !selectInteraction.deferred) { - await selectInteraction.deferUpdate().catch(() => {}); - } - - await selectInteraction - .followUp({ - embeds: [errorEmbed('Configuration Error', errorMessage)], - flags: MessageFlags.Ephemeral, - }) - .catch(() => {}); - } - }); - - // ── Button collector for toggles ────────────────────────────────── - const btnCollector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.Button, - filter: i => - i.user.id === interaction.user.id && - (i.customId === `greet_cfg_toggle_welcome_${guildId}` || - i.customId === `greet_cfg_toggle_goodbye_${guildId}` || - i.customId === `greet_cfg_ping_welcome_${guildId}` || - i.customId === `greet_cfg_ping_goodbye_${guildId}`), - time: 600_000, - }); - - btnCollector.on('collect', async btnInteraction => { - try { - await btnInteraction.deferUpdate().catch(() => null); - } catch (err) { - logger.debug('Button interaction already expired:', err.message); - return; - } - const customId = btnInteraction.customId; - - if (customId === `greet_cfg_toggle_welcome_${guildId}`) { - cfg.enabled = !cfg.enabled; - await saveWelcomeConfig(client, guildId, cfg); - await btnInteraction.followUp({ - embeds: [ - successEmbed( - '✅ Welcome Updated', - `Welcome messages are now **${cfg.enabled ? 'enabled' : 'disabled'}**.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - } else if (customId === `greet_cfg_toggle_goodbye_${guildId}`) { - cfg.goodbyeEnabled = !cfg.goodbyeEnabled; - await saveWelcomeConfig(client, guildId, cfg); - await btnInteraction.followUp({ - embeds: [ - successEmbed( - '✅ Goodbye Updated', - `Goodbye messages are now **${cfg.goodbyeEnabled ? 'enabled' : 'disabled'}**.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - } else if (customId === `greet_cfg_ping_welcome_${guildId}`) { - cfg.welcomePing = !cfg.welcomePing; - await saveWelcomeConfig(client, guildId, cfg); - await btnInteraction.followUp({ - embeds: [ - successEmbed( - '✅ Welcome Ping Updated', - `Joining users will${cfg.welcomePing ? '' : ' **not**'} be pinged in the welcome message.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - } else if (customId === `greet_cfg_ping_goodbye_${guildId}`) { - cfg.goodbyePing = !cfg.goodbyePing; - await saveWelcomeConfig(client, guildId, cfg); - await btnInteraction.followUp({ - embeds: [ - successEmbed( - '✅ Goodbye Ping Updated', - `Leaving users will${cfg.goodbyePing ? '' : ' **not**'} be pinged in the goodbye message.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - } - - await refreshDashboard(interaction, cfg, guildId); - }); - - collector.on('end', async (collected, reason) => { - if (reason === 'time') { - btnCollector.stop(); - try { - 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.') - .setColor(getColor('error')) - ], - components: [], - flags: MessageFlags.Ephemeral, - }); - } catch (error) { - logger.debug('Could not update dashboard on timeout:', error.message); - } - } - }); - } catch (error) { - if (error instanceof TitanBotError) throw error; - logger.error('Unexpected error in greet_dashboard:', error); - throw new TitanBotError( - `Greet dashboard failed: ${error.message}`, - ErrorTypes.UNKNOWN, - 'Failed to open the greet dashboard.', - ); - } - }, -}; - -// ─── Welcome Channel ────────────────────────────────────────────────────────── - -async function handleWelcomeChannel(selectInteraction, rootInteraction, cfg, guildId, client) { - try { - await selectInteraction.deferUpdate(); - } catch { - return; - } - - const channelSelect = new ChannelSelectMenuBuilder() - .setCustomId('greet_cfg_welcome_channel') - .setPlaceholder('Select a text channel...') - .addChannelTypes(ChannelType.GuildText) - .setMaxValues(1); - - await selectInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('🟢 Welcome Channel') - .setDescription( - `**Current:** ${cfg.channelId ? `<#${cfg.channelId}>` : '`Not set`'}\n\nSelect the channel where welcome messages will be sent.`, - ) - .setColor(getColor('info')), - ], - components: [new ActionRowBuilder().addComponents(channelSelect)], - flags: MessageFlags.Ephemeral, - }); - - const chanCollector = rootInteraction.channel.createMessageComponentCollector({ - componentType: ComponentType.ChannelSelect, - filter: i => - i.user.id === selectInteraction.user.id && i.customId === 'greet_cfg_welcome_channel', - time: 60_000, - max: 1, - }); - - chanCollector.on('collect', async chanInteraction => { - await chanInteraction.deferUpdate(); - const channel = chanInteraction.channels.first(); - - if (!botHasPermission(channel, ['ViewChannel', 'SendMessages', 'EmbedLinks'])) { - await chanInteraction.followUp({ - embeds: [ - errorEmbed( - 'Missing Permissions', - `I need **View Channel**, **Send Messages**, and **Embed Links** in ${channel}.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - return; - } - - cfg.channelId = channel.id; - await saveWelcomeConfig(client, guildId, cfg); - - await chanInteraction.followUp({ - embeds: [successEmbed('✅ Channel Updated', `Welcome messages will now be sent in ${channel}.`)], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, cfg, guildId); - }); - - chanCollector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - selectInteraction - .followUp({ - embeds: [errorEmbed('Timed Out', 'No channel was selected. The setting was not changed.')], - flags: MessageFlags.Ephemeral, - }) - .catch(() => {}); - } - }); -} - -// ─── Welcome Message ────────────────────────────────────────────────────────── - -async function handleWelcomeMessage(selectInteraction, rootInteraction, cfg, guildId, client) { - const modal = new ModalBuilder() - .setCustomId('greet_cfg_welcome_message') - .setTitle('Edit Welcome Message') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('message_input') - .setLabel('Message (variables: {user}, {server}, etc)') - .setStyle(TextInputStyle.Paragraph) - .setValue(cfg.welcomeMessage || 'Welcome {user} to {server}!') - .setMaxLength(2000) - .setMinLength(1) - .setRequired(true), - ), - ); - - try { - await selectInteraction.showModal(modal); - } catch { - return; - } - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => - i.customId === 'greet_cfg_welcome_message' && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - cfg.welcomeMessage = submitted.fields.getTextInputValue('message_input').trim(); - await saveWelcomeConfig(client, guildId, cfg); - - await submitted.reply({ - embeds: [successEmbed('✅ Welcome Message Updated', 'The welcome message has been saved.')], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, cfg, guildId); -} - -// ─── Welcome Image ──────────────────────────────────────────────────────────── - -async function handleWelcomeImage(selectInteraction, rootInteraction, cfg, guildId, client) { - const modal = new ModalBuilder() - .setCustomId('greet_cfg_welcome_image') - .setTitle('Set Welcome Image'); - - const imageHint = new TextDisplayBuilder() - .setContent('Provide a direct image URL **or** upload a file below. If both are given, the uploaded file takes priority. Leave the URL blank and skip the upload to remove the image.'); - - const urlLabel = new LabelBuilder() - .setLabel('Image URL (optional)') - .setTextInputComponent( - new TextInputBuilder() - .setCustomId('image_input') - .setPlaceholder('https://example.com/welcome.png') - .setStyle(TextInputStyle.Short) - .setValue(cfg.welcomeImage || '') - .setRequired(false), - ); - - const uploadLabel = new LabelBuilder() - .setLabel('Or upload an image file (optional)') - .setFileUploadComponent( - new FileUploadBuilder() - .setCustomId('image_upload') - .setRequired(false), - ); - - modal - .addTextDisplayComponents(imageHint) - .addLabelComponents(urlLabel, uploadLabel); - - try { - await selectInteraction.showModal(modal); - } catch { - return; - } - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => - i.customId === 'greet_cfg_welcome_image' && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - // File upload takes priority over URL - const uploadedFiles = submitted.fields.getUploadedFiles('image_upload'); - let imageUrl = uploadedFiles?.at(0)?.url ?? submitted.fields.getTextInputValue('image_input').trim(); - - // Validate URL if provided - if (imageUrl) { - try { - new URL(imageUrl); - if (!['http:', 'https:'].includes(new URL(imageUrl).protocol)) { - await submitted.reply({ - embeds: [errorEmbed('Invalid URL', 'Image URL must start with `http://` or `https://`.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - } catch { - await submitted.reply({ - embeds: [errorEmbed('Invalid URL', 'Please provide a valid image URL.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - } - - cfg.welcomeImage = imageUrl || null; - await saveWelcomeConfig(client, guildId, cfg); - - await submitted.reply({ - embeds: [successEmbed('✅ Welcome Image Updated', `Image ${imageUrl ? 'updated' : 'removed'} successfully.`)], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, cfg, guildId); -} - -// ─── Welcome Ping ───────────────────────────────────────────────────────────── - -async function handleWelcomePing(selectInteraction, rootInteraction, cfg, guildId, client) { - await selectInteraction.deferUpdate(); - - cfg.welcomePing = !cfg.welcomePing; - await saveWelcomeConfig(client, guildId, cfg); - - await selectInteraction.followUp({ - embeds: [ - successEmbed( - '✅ Welcome Ping Updated', - `Joining users will${cfg.welcomePing ? '' : ' **not**'} be pinged in the welcome message.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, cfg, guildId); -} - -// ─── Goodbye Channel ───────────────────────────────────────────────────────── - -async function handleGoodbyeChannel(selectInteraction, rootInteraction, cfg, guildId, client) { - try { - await selectInteraction.deferUpdate(); - } catch { - return; - } - - const channelSelect = new ChannelSelectMenuBuilder() - .setCustomId('greet_cfg_goodbye_channel') - .setPlaceholder('Select a text channel...') - .addChannelTypes(ChannelType.GuildText) - .setMaxValues(1); - - await selectInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('🔴 Goodbye Channel') - .setDescription( - `**Current:** ${cfg.goodbyeChannelId ? `<#${cfg.goodbyeChannelId}>` : '`Not set`'}\n\nSelect the channel where goodbye messages will be sent.`, - ) - .setColor(getColor('info')), - ], - components: [new ActionRowBuilder().addComponents(channelSelect)], - flags: MessageFlags.Ephemeral, - }); - - const chanCollector = rootInteraction.channel.createMessageComponentCollector({ - componentType: ComponentType.ChannelSelect, - filter: i => - i.user.id === selectInteraction.user.id && i.customId === 'greet_cfg_goodbye_channel', - time: 60_000, - max: 1, - }); - - chanCollector.on('collect', async chanInteraction => { - await chanInteraction.deferUpdate(); - const channel = chanInteraction.channels.first(); - - if (!botHasPermission(channel, ['ViewChannel', 'SendMessages', 'EmbedLinks'])) { - await chanInteraction.followUp({ - embeds: [ - errorEmbed( - 'Missing Permissions', - `I need **View Channel**, **Send Messages**, and **Embed Links** in ${channel}.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - return; - } - - cfg.goodbyeChannelId = channel.id; - await saveWelcomeConfig(client, guildId, cfg); - - await chanInteraction.followUp({ - embeds: [successEmbed('✅ Channel Updated', `Goodbye messages will now be sent in ${channel}.`)], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, cfg, guildId); - }); - - chanCollector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - selectInteraction - .followUp({ - embeds: [errorEmbed('Timed Out', 'No channel was selected. The setting was not changed.')], - flags: MessageFlags.Ephemeral, - }) - .catch(() => {}); - } - }); -} - -// ─── Goodbye Message ────────────────────────────────────────────────────────── - -async function handleGoodbyeMessage(selectInteraction, rootInteraction, cfg, guildId, client) { - const modal = new ModalBuilder() - .setCustomId('greet_cfg_goodbye_message') - .setTitle('Edit Goodbye Message') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('message_input') - .setLabel('Message (variables: {user}, {server}, etc)') - .setStyle(TextInputStyle.Paragraph) - .setValue(cfg.leaveMessage || '{user.tag} has left the server.') - .setMaxLength(2000) - .setMinLength(1) - .setRequired(true), - ), - ); - - try { - await selectInteraction.showModal(modal); - } catch { - return; - } - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => - i.customId === 'greet_cfg_goodbye_message' && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - cfg.leaveMessage = submitted.fields.getTextInputValue('message_input').trim(); - await saveWelcomeConfig(client, guildId, cfg); - - await submitted.reply({ - embeds: [successEmbed('✅ Goodbye Message Updated', 'The goodbye message has been saved.')], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, cfg, guildId); -} - -// ─── Goodbye Image ──────────────────────────────────────────────────────────── - -async function handleGoodbyeImage(selectInteraction, rootInteraction, cfg, guildId, client) { - const modal = new ModalBuilder() - .setCustomId('greet_cfg_goodbye_image') - .setTitle('Set Goodbye Image'); - - const imageHint = new TextDisplayBuilder() - .setContent('Provide a direct image URL **or** upload a file below. If both are given, the uploaded file takes priority. Leave the URL blank and skip the upload to remove the image.'); - - const urlLabel = new LabelBuilder() - .setLabel('Image URL (optional)') - .setTextInputComponent( - new TextInputBuilder() - .setCustomId('image_input') - .setPlaceholder('https://example.com/goodbye.png') - .setStyle(TextInputStyle.Short) - .setValue( - typeof cfg.leaveEmbed?.image === 'string' - ? cfg.leaveEmbed.image - : cfg.leaveEmbed?.image?.url || '' - ) - .setRequired(false), - ); - - const uploadLabel = new LabelBuilder() - .setLabel('Or upload an image file (optional)') - .setFileUploadComponent( - new FileUploadBuilder() - .setCustomId('image_upload') - .setRequired(false), - ); - - modal - .addTextDisplayComponents(imageHint) - .addLabelComponents(urlLabel, uploadLabel); - - try { - await selectInteraction.showModal(modal); - } catch { - return; - } - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => - i.customId === 'greet_cfg_goodbye_image' && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - // File upload takes priority over URL - const uploadedFiles = submitted.fields.getUploadedFiles('image_upload'); - let imageUrl = uploadedFiles?.at(0)?.url ?? submitted.fields.getTextInputValue('image_input').trim(); - - // Validate URL if provided - if (imageUrl) { - try { - new URL(imageUrl); - if (!['http:', 'https:'].includes(new URL(imageUrl).protocol)) { - await submitted.reply({ - embeds: [errorEmbed('Invalid URL', 'Image URL must start with `http://` or `https://`.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - } catch { - await submitted.reply({ - embeds: [errorEmbed('Invalid URL', 'Please provide a valid image URL.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - } - - const nextLeaveEmbed = { ...(cfg.leaveEmbed || {}) }; - if (imageUrl) { - nextLeaveEmbed.image = imageUrl; - } else { - delete nextLeaveEmbed.image; - } - - cfg.leaveEmbed = nextLeaveEmbed; - await saveWelcomeConfig(client, guildId, cfg); - - await submitted.reply({ - embeds: [successEmbed('✅ Goodbye Image Updated', `Image ${imageUrl ? 'updated' : 'removed'} successfully.`)], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, cfg, guildId); -} - -// ─── Goodbye Ping ───────────────────────────────────────────────────────────── - -async function handleGoodbyePing(selectInteraction, rootInteraction, cfg, guildId, client) { - await selectInteraction.deferUpdate(); - - cfg.goodbyePing = !cfg.goodbyePing; - await saveWelcomeConfig(client, guildId, cfg); - - await selectInteraction.followUp({ - embeds: [ - successEmbed( - '✅ Goodbye Ping Updated', - `Leaving users will${cfg.goodbyePing ? '' : ' **not**'} be pinged in the goodbye message.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, cfg, guildId); -} diff --git a/src/commands/Welcome/welcome.js b/src/commands/Welcome/welcome.js deleted file mode 100644 index 4461b0ba7e..0000000000 --- a/src/commands/Welcome/welcome.js +++ /dev/null @@ -1,150 +0,0 @@ -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, EmbedBuilder, MessageFlags } from 'discord.js'; -import { errorEmbed } from '../../utils/embeds.js'; -import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; -import { formatWelcomeMessage } from '../../utils/welcome.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -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.message }); - return; - } - - const { options, guild, client } = interaction; - - if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Missing Permissions', 'You need the **Manage Server** permission to use `/welcome`.')], - flags: MessageFlags.Ephemeral - }); - } - - 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 InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed( - 'Welcome Setup Already Exists', - `Welcome is already configured for <#${existingConfig.channelId}>. Use **/welcome config** to customize channel, message, ping, or image.` - )], - flags: MessageFlags.Ephemeral - }); - } - - if (!message || message.trim().length === 0) { - logger.warn(`[Welcome] Empty message provided by ${interaction.user.tag} in ${guild.name}`); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Invalid Input', 'Welcome message cannot be empty')], - flags: MessageFlags.Ephemeral - }); - } - - - if (image) { - try { - new URL(image); - } catch (e) { - logger.warn(`[Welcome] Invalid image URL provided by ${interaction.user.tag}: ${image}`); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Invalid Image URL', 'Please provide a valid image URL (must start with http:// or https://')], - flags: MessageFlags.Ephemeral - }); - } - } - - 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: previewMessage }, - { name: 'Ping User', value: ping ? '✅ Yes' : '❌ No' }, - { name: 'Status', value: '✅ Enabled' } - ) - .setFooter({ text: 'Tip: Use /welcome config 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 InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed( - 'Setup Failed', - 'An error occurred while configuring the welcome system. Please try again.', - { showDetails: true } - )], - flags: MessageFlags.Ephemeral - }); - } - } - }, -}; - - - diff --git a/src/commands/admin/botupdate.js b/src/commands/admin/botupdate.js new file mode 100644 index 0000000000..65c84b02a2 --- /dev/null +++ b/src/commands/admin/botupdate.js @@ -0,0 +1,43 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags, SlashCommandBuilder } from 'discord.js'; +import { buildUpdateEmbed, getUpdateSettings, parseLines } from '../../services/botUpdateService.js'; + +function addContentOptions(sub, versionRequired = true) { + return sub.addStringOption(o=>o.setName('version').setDescription('גרסת הבוט').setRequired(versionRequired).setMaxLength(30)) + .addStringOption(o=>o.setName('title').setDescription('כותרת העדכון').setMaxLength(200)) + .addStringOption(o=>o.setName('new_features').setDescription('תכונות חדשות, שורה לכל פריט').setMaxLength(1000)) + .addStringOption(o=>o.setName('fixes').setDescription('תיקונים, שורה לכל פריט').setMaxLength(1000)) + .addStringOption(o=>o.setName('improvements').setDescription('שיפורים, שורה לכל פריט').setMaxLength(1000)) + .addBooleanOption(o=>o.setName('ping_update_role').setDescription('לתייג את תפקיד העדכונים')) + .addStringOption(o=>o.setName('image').setDescription('קישור לתמונה').setMaxLength(1000)) + .addStringOption(o=>o.setName('changelog').setDescription('קישור ליומן השינויים').setMaxLength(1000)); +} +const row = (action, ownerId, nonce) => new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId(`botupdate_confirm:${action}:${ownerId}:${nonce}`).setLabel('אישור').setStyle(ButtonStyle.Success), + new ButtonBuilder().setCustomId(`botupdate_cancel:${ownerId}`).setLabel('ביטול').setStyle(ButtonStyle.Secondary)); +function readContent(interaction, fallback) { return { ...fallback, + version: interaction.options.getString('version') || fallback.version, + title: interaction.options.getString('title') || fallback.title, + newFeatures: interaction.options.getString('new_features') === null ? fallback.newFeatures : parseLines(interaction.options.getString('new_features')), + fixes: interaction.options.getString('fixes') === null ? fallback.fixes : parseLines(interaction.options.getString('fixes')), + improvements: interaction.options.getString('improvements') === null ? fallback.improvements : parseLines(interaction.options.getString('improvements')), + imageUrl: interaction.options.getString('image') || fallback.imageUrl, + changelogUrl: interaction.options.getString('changelog') || fallback.changelogUrl } } + +export default { data: new SlashCommandBuilder().setName('botupdate').setDescription('ניהול עדכוני הבוט') + .addSubcommand(s=>addContentOptions(s.setName('post').setDescription('הצגת תצוגה מקדימה ופרסום עדכון'))) + .addSubcommand(s=>s.setName('preview').setDescription('תצוגה מקדימה של העדכון הנוכחי')) + .addSubcommand(s=>s.setName('status').setDescription('מצב מערכת העדכונים')) + .addSubcommand(s=>s.setName('resend').setDescription('פרסום חוזר של העדכון הנוכחי')) + .addSubcommand(s=>addContentOptions(s.setName('edit').setDescription('עריכת הודעת העדכון האחרונה'), false)) + .addSubcommand(s=>s.setName('delete').setDescription('מחיקת הודעת העדכון האחרונה')), + async execute(interaction, client) { + if (!interaction.inGuild() || interaction.user.id !== interaction.guild.ownerId) return interaction.reply({ content:'רק בעל השרת יכול להשתמש בפקודה הזאת.', flags:MessageFlags.Ephemeral }); + const sub=interaction.options.getSubcommand(), settings=await getUpdateSettings(client,interaction.guildId); + if(sub==='preview') return interaction.reply({embeds:[buildUpdateEmbed(client,settings.content)],flags:MessageFlags.Ephemeral}); + if(sub==='status'){const link=settings.lastMessageId?`https://discord.com/channels/${interaction.guildId}/${settings.channelId}/${settings.lastMessageId}`:'לא קיים';return interaction.reply({content:`**מצב עדכוני הבוט**\nגרסה נוכחית: \`${settings.currentVersion}\`\nגרסה שפורסמה: \`${settings.lastAnnouncedVersion||'טרם פורסמה'}\`\nערוץ: <#${settings.channelId}>\nתפקיד: ${settings.roleId?`<@&${settings.roleId}>`:'לא הוגדר'}\nפרסום אוטומטי: ${settings.automaticEnabled?'פעיל':'כבוי'}\nפרסום אחרון: ${settings.lastAnnouncementAt?``:'אין'}\nהודעה אחרונה: ${link}`,flags:MessageFlags.Ephemeral});} + if(['resend','delete','edit'].includes(sub)&&!settings.lastMessageId)return interaction.reply({content:'לא נמצא עדכון לפרסום.',flags:MessageFlags.Ephemeral}); + const content=['post','edit'].includes(sub)?readContent(interaction,settings.content):settings.content; + const nonce=`${Date.now()}-${Math.random().toString(36).slice(2,8)}`; + await client.db.set(`botupdate:pending:${interaction.guildId}:${nonce}`,{action:sub,content,pingRole:interaction.options.getBoolean('ping_update_role')??true,userId:interaction.user.id},600); + return interaction.reply({content:sub==='delete'?'האם למחוק את הודעת העדכון האחרונה?':'יש לאשר את הפעולה:',embeds:sub==='delete'?[]:[buildUpdateEmbed(client,content,{repeated:sub==='resend'})],components:[row(sub,interaction.user.id,nonce)],flags:MessageFlags.Ephemeral}); + }}; diff --git a/src/commands/admin/level.js b/src/commands/admin/level.js new file mode 100644 index 0000000000..d7155ac742 --- /dev/null +++ b/src/commands/admin/level.js @@ -0,0 +1,2 @@ +import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, MessageFlags } from 'discord.js'; import { getConfig, updateConfig, levelKey } from '../../modules/community/store.js'; import { success, info } from '../../modules/community/ui.js'; +export default { data:new SlashCommandBuilder().setName('level').setDescription('Configure and view levels').addSubcommand(s=>s.setName('rank').setDescription('View a member rank').addUserOption(o=>o.setName('member').setDescription('Member'))).addSubcommand(s=>s.setName('setup').setDescription('Enable leveling').addChannelOption(o=>o.setName('channel').setDescription('Level-up channel').addChannelTypes(ChannelType.GuildText).setRequired(true))).addSubcommand(s=>s.setName('disable').setDescription('Disable leveling')).setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild),async execute(i,client){const s=i.options.getSubcommand();if(s==='rank'){const user=i.options.getUser('member')||i.user,data=await client.db.get(levelKey(i.guildId,user.id),{xp:0,level:0});return i.reply({...info('דרגה',`${user} נמצא ברמה **${data.level}** עם **${data.xp}** נקודות ניסיון.`),flags:MessageFlags.Ephemeral});}const ch=i.options.getChannel('channel');await updateConfig(client,i.guildId,{leveling:s==='disable'?{enabled:false}:{enabled:true,announceChannelId:ch.id}});await i.reply({...success('מערכת רמות',s==='disable'?'מערכת הרמות כובתה.':`מערכת הרמות הופעלה. הודעות על עלייה ברמה יישלחו אל ${ch}.`),flags:MessageFlags.Ephemeral});} }; diff --git a/src/commands/admin/logging.js b/src/commands/admin/logging.js new file mode 100644 index 0000000000..7b486b8c9d --- /dev/null +++ b/src/commands/admin/logging.js @@ -0,0 +1,2 @@ +import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, MessageFlags } from 'discord.js'; import { updateConfig } from '../../modules/community/store.js'; import { success } from '../../modules/community/ui.js'; +export default { data:new SlashCommandBuilder().setName('logging').setDescription('Configure server logs').setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild).addSubcommand(s=>s.setName('setup').setDescription('Set the log channel').addChannelOption(o=>o.setName('channel').setDescription('Log channel').addChannelTypes(ChannelType.GuildText).setRequired(true))).addSubcommand(s=>s.setName('disable').setDescription('Disable server logs')),async execute(i,client){const s=i.options.getSubcommand(),ch=i.options.getChannel('channel');await updateConfig(client,i.guildId,{logging:s==='disable'?{enabled:false}:{enabled:true,channelId:ch.id}});await i.reply({...success('לוגים',s==='disable'?'מערכת הלוגים כובתה.':`הלוגים יישלחו אל ${ch}.`),flags:MessageFlags.Ephemeral});} }; diff --git a/src/commands/admin/moderation.js b/src/commands/admin/moderation.js new file mode 100644 index 0000000000..64cf7abe12 --- /dev/null +++ b/src/commands/admin/moderation.js @@ -0,0 +1,3 @@ +import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; import { createEmbed } from '../../utils/embeds.js'; +const reason=o=>o.getString('reason')||'לא צוינה סיבה'; +export default { data:new SlashCommandBuilder().setName('moderation').setDescription('Moderation actions').setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers).addSubcommand(s=>s.setName('timeout').setDescription('Timeout a member').addUserOption(o=>o.setName('member').setDescription('Member').setRequired(true)).addIntegerOption(o=>o.setName('minutes').setDescription('Minutes, up to 40320').setRequired(true).setMinValue(1).setMaxValue(40320)).addStringOption(o=>o.setName('reason').setDescription('Reason'))).addSubcommand(s=>s.setName('kick').setDescription('Kick a member').addUserOption(o=>o.setName('member').setDescription('Member').setRequired(true)).addStringOption(o=>o.setName('reason').setDescription('Reason'))).addSubcommand(s=>s.setName('ban').setDescription('Ban a member').addUserOption(o=>o.setName('member').setDescription('Member').setRequired(true)).addStringOption(o=>o.setName('reason').setDescription('Reason'))).addSubcommand(s=>s.setName('clear').setDescription('Delete recent messages').addIntegerOption(o=>o.setName('amount').setDescription('1–100').setRequired(true).setMinValue(1).setMaxValue(100))),async execute(i){const sub=i.options.getSubcommand();if(sub==='clear'){const n=i.options.getInteger('amount');const messages=await i.channel.bulkDelete(n,true);return i.reply({embeds:[createEmbed({title:'ניקוי הודעות',description:`נמחקו **${messages.size}** הודעות.`,color:'success'})],ephemeral:true});}const member=await i.guild.members.fetch(i.options.getUser('member').id);if(member.id===i.user.id||member.roles.highest.position>=i.member.roles.highest.position)return i.reply({embeds:[createEmbed({title:'שגיאה',description:'אין לך אפשרות לנהל חבר זה.',color:'error'})],ephemeral:true});const r=reason(i);if(sub==='timeout')await member.timeout(i.options.getInteger('minutes')*60_000,r);if(sub==='kick')await member.kick(r);if(sub==='ban')await member.ban({reason:r});await i.reply({embeds:[createEmbed({title:'פעולת ניהול בוצעה',description:`בוצעה הפעולה **${sub}** על ${member.user}.\nסיבה: ${r}`,color:'success'})]});} }; diff --git a/src/commands/admin/rolepanel.js b/src/commands/admin/rolepanel.js new file mode 100644 index 0000000000..7c34b6ee7d --- /dev/null +++ b/src/commands/admin/rolepanel.js @@ -0,0 +1,23 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { createEmbed } from '../../utils/embeds.js'; +import { getConfig, updateConfig } from '../../modules/community/store.js'; +import { getPanel, panelKey, panelPayload, PANEL_CATEGORIES, validateRoleAction } from '../../services/roleSystemService.js'; +import { logEvent, EVENT_TYPES } from '../../services/loggingService.js'; + +const categoryChoices=Object.entries(PANEL_CATEGORIES).map(([value,name])=>({name,value})); +const addPanelFields=s=>s.addChannelOption(o=>o.setName('channel').setDescription('ערוץ יעד').addChannelTypes(ChannelType.GuildText).setRequired(true)).addStringOption(o=>o.setName('title').setDescription('כותרת').setRequired(true).setMaxLength(256)).addStringOption(o=>o.setName('description').setDescription('תיאור').setRequired(true).setMaxLength(1000)).addStringOption(o=>o.setName('category').setDescription('קטגוריה').setRequired(true).addChoices(...categoryChoices)).addStringOption(o=>o.setName('selection_type').setDescription('סוג בחירה').setRequired(true).addChoices({name:'buttons',value:'buttons'},{name:'select menu',value:'select_menu'})).addIntegerOption(o=>o.setName('max_selections').setDescription('מספר בחירות מרבי').setRequired(true).setMinValue(1).setMaxValue(8)).addRoleOption(o=>o.setName('role_1').setDescription('תפקיד').setRequired(true)).addRoleOption(o=>o.setName('role_2').setDescription('תפקיד').setRequired(true)).addRoleOption(o=>o.setName('role_3').setDescription('תפקיד').setRequired(true)).addRoleOption(o=>o.setName('role_4').setDescription('תפקיד').setRequired(true)).addRoleOption(o=>o.setName('role_5').setDescription('תפקיד').setRequired(true)).addRoleOption(o=>o.setName('role_6').setDescription('תפקיד')).addRoleOption(o=>o.setName('role_7').setDescription('תפקיד')).addRoleOption(o=>o.setName('role_8').setDescription('תפקיד')); +const data=new SlashCommandBuilder().setName('rolepanel').setDescription('ניהול פאנלים לבחירת תפקידים').setDefaultMemberPermissions(PermissionFlagsBits.Administrator).setDMPermission(false) + .addSubcommand(s=>addPanelFields(s.setName('create').setDescription('יצירת פאנל'))) + .addSubcommand(s=>s.setName('edit').setDescription('עריכת פאנל').addStringOption(o=>o.setName('panel').setDescription('מזהה פאנל או קישור להודעה').setRequired(true)).addChannelOption(o=>o.setName('channel').setDescription('ערוץ יעד').addChannelTypes(ChannelType.GuildText)).addStringOption(o=>o.setName('title').setDescription('כותרת').setMaxLength(256)).addStringOption(o=>o.setName('description').setDescription('תיאור').setMaxLength(1000)).addStringOption(o=>o.setName('category').setDescription('קטגוריה').addChoices(...categoryChoices)).addStringOption(o=>o.setName('selection_type').setDescription('סוג בחירה').addChoices({name:'buttons',value:'buttons'},{name:'select menu',value:'select_menu'})).addIntegerOption(o=>o.setName('max_selections').setDescription('בחירות מרביות').setMinValue(1).setMaxValue(8)).addRoleOption(o=>o.setName('role_1').setDescription('תפקיד')).addRoleOption(o=>o.setName('role_2').setDescription('תפקיד')).addRoleOption(o=>o.setName('role_3').setDescription('תפקיד')).addRoleOption(o=>o.setName('role_4').setDescription('תפקיד')).addRoleOption(o=>o.setName('role_5').setDescription('תפקיד')).addRoleOption(o=>o.setName('role_6').setDescription('תפקיד')).addRoleOption(o=>o.setName('role_7').setDescription('תפקיד')).addRoleOption(o=>o.setName('role_8').setDescription('תפקיד'))) + .addSubcommand(s=>s.setName('delete').setDescription('מחיקת פאנל').addStringOption(o=>o.setName('panel').setDescription('מזהה או קישור').setRequired(true))) + .addSubcommand(s=>s.setName('list').setDescription('הצגת כל הפאנלים')) + .addSubcommand(s=>s.setName('refresh').setDescription('רענון ותיקון פאנל').addStringOption(o=>o.setName('panel').setDescription('מזהה או קישור').setRequired(true))); + +export default {data,async execute(i,client){if(i.user.id!==i.guild.ownerId&&!i.member.permissions.has(PermissionFlagsBits.Administrator))return i.reply({content:'אין לך הרשאה להשתמש בפקודה הזאת.',flags:MessageFlags.Ephemeral});const sub=i.options.getSubcommand();const config=await getConfig(client,i.guildId); + if(sub==='list'){const panels=await Promise.all((config.roles.panels||[]).map(id=>client.db.get(panelKey(i.guildId,id))));const valid=panels.filter(Boolean);return i.reply({embeds:[createEmbed({title:'פאנלים של תפקידים',description:valid.length?valid.map(p=>`**#${p.id} — ${p.title}**\n<#${p.channelId}> • ${PANEL_CATEGORIES[p.category]||p.category} • ${p.roleIds.length} תפקידים • ${p.selectionType}\nhttps://discord.com/channels/${i.guildId}/${p.channelId}/${p.messageId}`).join('\n\n'):'לא הוגדרו פאנלים.',color:'primary'})],flags:MessageFlags.Ephemeral});} + if(sub==='create'){const roles=Array.from({length:8},(_,n)=>i.options.getRole(`role_${n+1}`)).filter(Boolean);if(new Set(roles.map(r=>r.id)).size!==roles.length)return i.reply({content:'לא ניתן לבחור אותו תפקיד יותר מפעם אחת.',flags:MessageFlags.Ephemeral});for(const role of roles){const error=await validateRoleAction(i.guild,i.member,role,{selfAssignable:true});if(error)return i.reply({content:error,flags:MessageFlags.Ephemeral});}const id=String(await client.db.increment(`community:${i.guildId}:sequence:rolepanel`));const panel={id,title:i.options.getString('title'),description:i.options.getString('description'),category:i.options.getString('category'),selectionType:i.options.getString('selection_type'),maxSelections:Math.min(i.options.getInteger('max_selections'),roles.length),roleIds:roles.map(r=>r.id),channelId:i.options.getChannel('channel').id,createdBy:i.user.id,createdAt:Date.now()};const message=await i.options.getChannel('channel').send(panelPayload(panel,i.guild));panel.messageId=message.id;await client.db.set(panelKey(i.guildId,id),panel);await updateConfig(client,i.guildId,{roles:{panels:[...new Set([...(config.roles.panels||[]),id])]}});await logEvent({client,guildId:i.guildId,eventType:EVENT_TYPES.ROLE_CREATE,data:{title:`פאנל תפקידים #${id} נוצר`,description:`נוצר על ידי ${i.user} ב־${message.channel}.`}});return i.reply({content:`הפאנל #${id} נוצר בהצלחה ב־${message.channel}.`,flags:MessageFlags.Ephemeral});} + const panel=await getPanel(client,i.guildId,i.options.getString('panel'));if(!panel)return i.reply({content:'פאנל התפקידים לא נמצא.',flags:MessageFlags.Ephemeral});const channel=i.guild.channels.cache.get(panel.channelId);const message=channel?.isTextBased()?await channel.messages.fetch(panel.messageId).catch(()=>null):null; + if(sub==='delete')return i.reply({embeds:[createEmbed({title:'אישור מחיקת פאנל',description:`האם למחוק את פאנל **#${panel.id} — ${panel.title}**? התפקידים עצמם לא יימחקו.`,color:'error'})],components:[new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId(`role_panel_delete:${panel.id}:${i.user.id}`).setLabel('מחיקת הפאנל').setStyle(ButtonStyle.Danger),new ButtonBuilder().setCustomId(`role_cancel:panel`).setLabel('ביטול').setStyle(ButtonStyle.Secondary))],flags:MessageFlags.Ephemeral}); + if(!message)return i.reply({content:'הודעת הפאנל חסרה או נמחקה. ניתן למחוק את ההגדרה וליצור פאנל חדש.',flags:MessageFlags.Ephemeral}); + if(sub==='refresh'){const invalid=panel.roleIds.filter(id=>!i.guild.roles.cache.has(id));await message.edit(panelPayload(panel,i.guild));return i.reply({content:invalid.length?`הפאנל רוענן. תפקידים חסרים: ${invalid.join(', ')}`:'הפאנל רוענן ונמצא תקין.',flags:MessageFlags.Ephemeral});} + const newRoles=Array.from({length:8},(_,n)=>i.options.getRole(`role_${n+1}`)).filter(Boolean);if(newRoles.length){if(new Set(newRoles.map(r=>r.id)).size!==newRoles.length)return i.reply({content:'נבחרו תפקידים כפולים.',flags:MessageFlags.Ephemeral});for(const role of newRoles){const error=await validateRoleAction(i.guild,i.member,role,{selfAssignable:true});if(error)return i.reply({content:error,flags:MessageFlags.Ephemeral});}panel.roleIds=newRoles.map(r=>r.id);}panel.title=i.options.getString('title')||panel.title;panel.description=i.options.getString('description')||panel.description;panel.category=i.options.getString('category')||panel.category;panel.selectionType=i.options.getString('selection_type')||panel.selectionType;panel.maxSelections=Math.min(i.options.getInteger('max_selections')||panel.maxSelections,panel.roleIds.length);const destination=i.options.getChannel('channel');if(destination&&destination.id!==panel.channelId){const replacement=await destination.send(panelPayload(panel,i.guild));await message.delete().catch(()=>null);panel.channelId=destination.id;panel.messageId=replacement.id;}else await message.edit(panelPayload(panel,i.guild));panel.updatedAt=Date.now();await client.db.set(panelKey(i.guildId,panel.id),panel);await logEvent({client,guildId:i.guildId,eventType:EVENT_TYPES.ROLE_UPDATE,data:{title:`פאנל #${panel.id} עודכן`,description:`עודכן על ידי ${i.user}.`}});return i.reply({content:`פאנל #${panel.id} עודכן בהצלחה.`,flags:MessageFlags.Ephemeral});}} diff --git a/src/commands/admin/settings.js b/src/commands/admin/settings.js new file mode 100644 index 0000000000..b8082f1292 --- /dev/null +++ b/src/commands/admin/settings.js @@ -0,0 +1,114 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, SlashCommandBuilder, PermissionFlagsBits, ChannelType, MessageFlags } from 'discord.js'; +import { getConfig, resetConfig, updateConfig } from '../../modules/community/store.js'; +import { EVENT_TYPES, logEvent } from '../../services/loggingService.js'; +import { info, success } from '../../modules/community/ui.js'; +import { createSettingsPage, createSettingsComponents } from '../../services/settingsOverview.js'; +import { getUpdateSettings, saveUpdateSettings } from '../../services/botUpdateService.js'; + +const groups = { + message_sent: [EVENT_TYPES.MESSAGE_CREATE], message_edit: [EVENT_TYPES.MESSAGE_EDIT], message_delete: [EVENT_TYPES.MESSAGE_DELETE], bulk_delete: [EVENT_TYPES.MESSAGE_BULK_DELETE], + member_join: [EVENT_TYPES.MEMBER_JOIN], member_leave: [EVENT_TYPES.MEMBER_LEAVE], moderation: [EVENT_TYPES.MODERATION_BAN, EVENT_TYPES.MODERATION_UNBAN, EVENT_TYPES.MODERATION_KICK, EVENT_TYPES.MODERATION_MUTE], + member_update: [EVENT_TYPES.MEMBER_UPDATE], channels: [EVENT_TYPES.CHANNEL_CHANGE], roles: [EVENT_TYPES.ROLE_CREATE, EVENT_TYPES.ROLE_DELETE, EVENT_TYPES.ROLE_UPDATE], voice: [EVENT_TYPES.VOICE_CHANGE], invites: [EVENT_TYPES.INVITE_CHANGE], emoji_stickers: [EVENT_TYPES.EMOJI_STICKER_CHANGE], server_update: [EVENT_TYPES.SERVER_UPDATE] +}; +const choices = Object.keys(groups).map(name => ({ name, value: name })); + +export default { data: new SlashCommandBuilder().setName('settings').setDescription('Manage bot settings').setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommand(s => s.setName('view').setDescription('View current settings')) + .addSubcommand(s => s.setName('reset').setDescription('Reset bot settings for this server')) + .addSubcommand(s => s.setName('logging').setDescription('View or change individual logging events') + .addStringOption(o => o.setName('action').setDescription('Action').setRequired(true).addChoices({name:'view',value:'view'},{name:'enable',value:'enable'},{name:'disable',value:'disable'},{name:'channel',value:'channel'},{name:'test',value:'test'})) + .addStringOption(o => o.setName('event').setDescription('Event group for enable/disable').addChoices(...choices)) + .addChannelOption(o => o.setName('channel').setDescription('Logging channel').addChannelTypes(ChannelType.GuildText))) + .addSubcommandGroup(g => g.setName('community').setDescription('Community module settings') + .addSubcommand(s => s.setName('view').setDescription('View community settings')) + .addSubcommand(s => s.setName('channel').setDescription('Configure a community channel') + .addStringOption(o => o.setName('type').setDescription('Channel purpose').setRequired(true).addChoices( + {name:'suggestions',value:'suggestions'},{name:'feedback',value:'feedback'},{name:'reports',value:'reports'},{name:'self-promotion',value:'selfPromotion'},{name:'looking-for-editor',value:'lookingForEditor'},{name:'looking-for-team',value:'lookingForTeam'})) + .addChannelOption(o => o.setName('channel').setDescription('Selected channel').addChannelTypes(ChannelType.GuildText).setRequired(true))) + .addSubcommand(s => s.setName('cooldown').setDescription('Configure a command cooldown') + .addStringOption(o => o.setName('command').setDescription('Command').setRequired(true).addChoices(...['suggest','feedback','report','selfpromo','lookingforeditor','lookingforteam'].map(value=>({name:value,value})))) + .addIntegerOption(o => o.setName('seconds').setDescription('Cooldown in seconds').setRequired(true).setMinValue(0).setMaxValue(604800))) + .addSubcommand(s => s.setName('toggles').setDescription('Enable or disable a community option') + .addStringOption(o => o.setName('setting').setDescription('Setting').setRequired(true).addChoices({name:'anonymous-feedback',value:'anonymousFeedback'},{name:'public-polls',value:'publicPolls'},{name:'discord-invites',value:'allowDiscordInvites'},{name:'public-vote-totals',value:'publicVoteTotals'},{name:'community-module',value:'enabled'})) + .addBooleanOption(o => o.setName('enabled').setDescription('Enabled state').setRequired(true))) + .addSubcommand(s => s.setName('roles').setDescription('Configure self-assignable editing roles') + .addRoleOption(o => o.setName('role_1').setDescription('Editing role').setRequired(true)) + .addRoleOption(o => o.setName('role_2').setDescription('Editing role')).addRoleOption(o => o.setName('role_3').setDescription('Editing role')) + .addRoleOption(o => o.setName('role_4').setDescription('Editing role')).addRoleOption(o => o.setName('role_5').setDescription('Editing role')) + .addRoleOption(o => o.setName('role_6').setDescription('Editing role')).addRoleOption(o => o.setName('role_7').setDescription('Editing role')) + .addRoleOption(o => o.setName('role_8').setDescription('Editing role')))) + .addSubcommandGroup(g => g.setName('roles').setDescription('Role system settings') + .addSubcommand(s=>s.setName('view').setDescription('View role settings')) + .addSubcommand(s=>s.setName('set').setDescription('Configure an essential role').addStringOption(o=>o.setName('type').setDescription('Role type').setRequired(true).addChoices(...['verified','newMember','helper','moderator','administrator','ticketStaff','booster','botDeveloper','supplier','botUpdates','friend'].map(value=>({name:value,value})))).addRoleOption(o=>o.setName('role').setDescription('Selected role').setRequired(true))) + .addSubcommand(s=>s.setName('category').setDescription('Configure a self-role category').addStringOption(o=>o.setName('category').setDescription('Category').setRequired(true).addChoices({name:'editing software',value:'software'},{name:'editing types',value:'editing'},{name:'notifications',value:'notifications'},{name:'languages',value:'languages'})).addStringOption(o=>o.setName('action').setDescription('Action').setRequired(true).addChoices({name:'create/update',value:'update'},{name:'add role',value:'add'},{name:'remove role',value:'remove'})).addStringOption(o=>o.setName('name').setDescription('Hebrew display name').setMaxLength(100)).addRoleOption(o=>o.setName('role').setDescription('Role for add/remove')).addIntegerOption(o=>o.setName('max_selections').setDescription('Maximum selections').setMinValue(1).setMaxValue(25)).addBooleanOption(o=>o.setName('enabled').setDescription('Category enabled'))) + .addSubcommand(s=>s.setName('permissions').setDescription('Configure role command access').addStringOption(o=>o.setName('command').setDescription('Command').setRequired(true).addChoices(...['role.add','role.remove','rolepanel.create','rolepanel.edit','rolepanel.delete','role.create','role.delete'].map(value=>({name:value,value})))).addIntegerOption(o=>o.setName('level').setDescription('2 Helper, 3 Moderator, 4 Admin, 5 Owner').setRequired(true).setMinValue(2).setMaxValue(5)))) + .addSubcommandGroup(g=>g.setName('tickets').setDescription('Ticket system settings') + .addSubcommand(s=>s.setName('view').setDescription('View ticket settings')) + .addSubcommand(s=>s.setName('channel').setDescription('Configure ticket channel').addStringOption(o=>o.setName('type').setDescription('Purpose').setRequired(true).addChoices({name:'panel',value:'panelChannelId'},{name:'ticket category',value:'categoryId'},{name:'archive category',value:'archiveCategoryId'},{name:'transcripts',value:'transcriptChannelId'},{name:'logs',value:'logsChannelId'})).addChannelOption(o=>o.setName('channel').setDescription('Channel/category').setRequired(true))) + .addSubcommand(s=>s.setName('role').setDescription('Configure ticket role').addStringOption(o=>o.setName('type').setDescription('Role type').setRequired(true).addChoices({name:'ticket staff',value:'supportRoleId'},{name:'moderator',value:'moderator'},{name:'administrator',value:'administrator'})).addRoleOption(o=>o.setName('role').setDescription('Role').setRequired(true))) + .addSubcommand(s=>s.setName('limits').setDescription('Configure ticket limits').addIntegerOption(o=>o.setName('max_open').setDescription('Maximum open tickets').setMinValue(1).setMaxValue(10)).addIntegerOption(o=>o.setName('close_delay').setDescription('Close delay seconds').setMinValue(1).setMaxValue(86400)).addIntegerOption(o=>o.setName('alert_cooldown').setDescription('Alert cooldown seconds').setMinValue(10).setMaxValue(86400)).addIntegerOption(o=>o.setName('max_users').setDescription('Maximum added users').setMinValue(1).setMaxValue(25))) + .addSubcommand(s=>s.setName('types').setDescription('Enable or disable ticket type').addStringOption(o=>o.setName('type').setDescription('Ticket type').setRequired(true).addChoices(...['general','editing','report','partnership','bot_bug','resource','paid_work','management'].map(value=>({name:value,value})))).addBooleanOption(o=>o.setName('enabled').setDescription('Enabled').setRequired(true))) + .addSubcommand(s=>s.setName('toggles').setDescription('Configure ticket behavior').addStringOption(o=>o.setName('setting').setDescription('Setting').setRequired(true).addChoices(...['transcriptsEnabled','creatorCanClose','creatorCanAdd','creatorCanRename','claimEnabled','priorityEnabled','archiveEnabled','dmNotifications','allowDuplicateTypes'].map(value=>({name:value,value})))).addBooleanOption(o=>o.setName('enabled').setDescription('Enabled').setRequired(true)))) + .addSubcommandGroup(g=>g.setName('botupdates').setDescription('הגדרות עדכוני הבוט') + .addSubcommand(s=>s.setName('view').setDescription('הצגת הגדרות עדכוני הבוט')) + .addSubcommand(s=>s.setName('channel').setDescription('בחירת ערוץ העדכונים').addChannelOption(o=>o.setName('channel').setDescription('ערוץ טקסט או הכרזות').setRequired(true).addChannelTypes(ChannelType.GuildText,ChannelType.GuildAnnouncement))) + .addSubcommand(s=>s.setName('role').setDescription('בחירת תפקיד ההתראות').addRoleOption(o=>o.setName('role').setDescription('תפקיד העדכונים').setRequired(true))) + .addSubcommand(s=>s.setName('toggle').setDescription('הפעלה או כיבוי של פרסום אוטומטי').addBooleanOption(o=>o.setName('enabled').setDescription('מצב הפרסום האוטומטי').setRequired(true))) + .addSubcommand(s=>s.setName('version').setDescription('שינוי הגרסה הנוכחית').addStringOption(o=>o.setName('version').setDescription('גרסה חדשה').setRequired(true).setMaxLength(30)).addBooleanOption(o=>o.setName('announce').setDescription('לשמור ולפרסם מיד')))), + async execute(interaction, client) { + const sub = interaction.options.getSubcommand(); + const group = interaction.options.getSubcommandGroup(false); + if(group==='botupdates'){const s=await getUpdateSettings(client,interaction.guildId);if(sub==='view')return interaction.reply({content:`**הגדרות עדכוני הבוט**\nערוץ: <#${s.channelId}>\nתפקיד: ${s.roleId?`<@&${s.roleId}>`:'לא הוגדר'}\nגרסה נוכחית: \`${s.currentVersion}\`\nגרסה שפורסמה: \`${s.lastAnnouncedVersion||'טרם פורסמה'}\`\nפרסום אוטומטי: ${s.automaticEnabled?'פעיל':'כבוי'}\nמזהה הודעה אחרונה: \`${s.lastMessageId||'אין'}\`\nזמן פרסום אחרון: ${s.lastAnnouncementAt||'אין'}`,flags:MessageFlags.Ephemeral});if(interaction.user.id!==interaction.guild.ownerId)return interaction.reply({content:'רק בעל השרת יכול לשנות את הגדרות עדכוני הבוט.',flags:MessageFlags.Ephemeral});if(sub==='version'){const version=interaction.options.getString('version'),announce=interaction.options.getBoolean('announce')??false,nonce=`${Date.now()}-${Math.random().toString(36).slice(2,8)}`,content={...s.content,version};await client.db.set(`botupdate:pending:${interaction.guildId}:${nonce}`,{action:announce?'save_announce':'save_version',content,pingRole:true,userId:interaction.user.id},600);const components=[new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId(`botupdate_confirm:${announce?'save_announce':'save_version'}:${interaction.user.id}:${nonce}`).setLabel(announce?'שמירה ופרסום':'שמירה').setStyle(ButtonStyle.Success),new ButtonBuilder().setCustomId(`botupdate_cancel:${interaction.user.id}`).setLabel('ביטול').setStyle(ButtonStyle.Secondary))];return interaction.reply({content:`לאשר שינוי גרסה ל־\`${version}\`?`,components,flags:MessageFlags.Ephemeral});}let patch={};if(sub==='channel')patch.channelId=interaction.options.getChannel('channel').id;if(sub==='role')patch.roleId=interaction.options.getRole('role').id;if(sub==='toggle')patch.automaticEnabled=interaction.options.getBoolean('enabled');await saveUpdateSettings(client,interaction.guildId,patch);return interaction.reply({content:'הגדרות עדכוני הבוט נשמרו בהצלחה.',flags:MessageFlags.Ephemeral});} + if(group==='tickets'){const config=await getConfig(client,interaction.guildId),t=config.tickets;if(sub==='view')return interaction.reply({...info('הגדרות כרטיסים',`קטגוריה: ${t.categoryId?`<#${t.categoryId}>`:'לא הוגדרה'}\nארכיון: ${t.archiveCategoryId?`<#${t.archiveCategoryId}>`:'לא הוגדר'}\nצוות: ${t.supportRoleId?`<@&${t.supportRoleId}>`:'לא הוגדר'}\nתמלולים: ${t.transcriptChannelId?`<#${t.transcriptChannelId}>`:'לא הוגדר'}\nלוגים: ${t.logsChannelId?`<#${t.logsChannelId}>`:'לא הוגדר'}\nמקסימום פתוחים: ${t.maxOpenPerUser}\nעיכוב סגירה: ${t.closeDelaySeconds}s\nהתראת צוות: ${t.staffAlertCooldownSeconds}s\nסוגים פעילים: ${t.enabledTypes.join(', ')}\nלוחות: ${(t.panels||[]).join(', ')||'אין'}`),flags:MessageFlags.Ephemeral});if(interaction.user.id!==interaction.guild.ownerId)return interaction.reply({content:'רק בעל השרת יכול לשנות הגדרות כרטיסים.',flags:MessageFlags.Ephemeral});if(sub==='channel'){const type=interaction.options.getString('type'),channel=interaction.options.getChannel('channel');await updateConfig(client,interaction.guildId,{tickets:{[type]:channel.id}});}else if(sub==='role'){const type=interaction.options.getString('type'),role=interaction.options.getRole('role');if(type==='supportRoleId')await updateConfig(client,interaction.guildId,{tickets:{supportRoleId:role.id}});else await updateConfig(client,interaction.guildId,{staffRoles:{[type]:role.id}});}else if(sub==='limits')await updateConfig(client,interaction.guildId,{tickets:{maxOpenPerUser:interaction.options.getInteger('max_open')||t.maxOpenPerUser,closeDelaySeconds:interaction.options.getInteger('close_delay')||t.closeDelaySeconds,staffAlertCooldownSeconds:interaction.options.getInteger('alert_cooldown')||t.staffAlertCooldownSeconds,maxAddedUsers:interaction.options.getInteger('max_users')||t.maxAddedUsers}});else if(sub==='types'){const type=interaction.options.getString('type'),enabled=interaction.options.getBoolean('enabled'),types=new Set(t.enabledTypes);enabled?types.add(type):types.delete(type);await updateConfig(client,interaction.guildId,{tickets:{enabledTypes:[...types]}});}else{const setting=interaction.options.getString('setting');await updateConfig(client,interaction.guildId,{tickets:{[setting]:interaction.options.getBoolean('enabled')}});}return interaction.reply({...success('הגדרות הכרטיסים עודכנו','השינוי נשמר בהצלחה.'),flags:MessageFlags.Ephemeral});} + if (group === 'roles') { + const config = await getConfig(client, interaction.guildId); + if (sub === 'view') return interaction.reply({ ...info('הגדרות מערכת התפקידים', `${Object.entries(config.staffRoles).map(([name,id])=>`${name}: ${id?`<@&${id}>`:'לא הוגדר'}`).join('\n')}\n\n**קטגוריות בחירה עצמית**\n${Object.entries(config.roles.categories||{}).map(([id,c])=>`${id}: ${c.enabled===false?'כבויה':'פעילה'} • ${(c.roleIds||[]).length} תפקידים • עד ${c.maxSelections||1}`).join('\n')||'לא הוגדרו'}\n\nפאנלים: ${(config.roles.panels||[]).length}`), flags: MessageFlags.Ephemeral }); + if (interaction.user.id !== interaction.guild.ownerId) return interaction.reply({ content:'רק בעל השרת יכול לשנות את הגדרות התפקידים.', flags:MessageFlags.Ephemeral }); + if (sub === 'set') { const type=interaction.options.getString('type'),role=interaction.options.getRole('role');if(role.managed||role.position>=interaction.guild.members.me.roles.highest.position)return interaction.reply({content:'לא ניתן לשמור תפקיד מנוהל או תפקיד שמעל הבוט.',flags:MessageFlags.Ephemeral});await updateConfig(client,interaction.guildId,{staffRoles:{[type]:role.id}});return interaction.reply({...success('התפקיד נשמר',`${type}: ${role}`),flags:MessageFlags.Ephemeral}); } + if (sub === 'permissions') { const command=interaction.options.getString('command'),level=interaction.options.getInteger('level'),minimum=command==='role.delete'?5:command.startsWith('rolepanel.')||command==='role.create'?4:3;if(level=interaction.guild.members.me.roles.highest.position||Object.values(config.staffRoles).includes(role.id)))return interaction.reply({content:'לא ניתן להוסיף תפקיד מוגן, מנוהל או גבוה מהבוט.',flags:MessageFlags.Ephemeral}); + let roleIds=[...(current.roleIds||[])];if(action==='add')roleIds=[...new Set([...roleIds,role.id])];if(action==='remove')roleIds=roleIds.filter(value=>value!==role.id);const category={...current,roleIds,name:interaction.options.getString('name')||current.name,maxSelections:Math.min(interaction.options.getInteger('max_selections')||current.maxSelections||1,Math.max(1,roleIds.length)),enabled:interaction.options.getBoolean('enabled')??current.enabled};await updateConfig(client,interaction.guildId,{roles:{categories:{...config.roles.categories,[id]:category}}});return interaction.reply({...success('קטגוריית התפקידים עודכנה',`${category.name}: ${roleIds.length} תפקידים.`),flags:MessageFlags.Ephemeral}); + } + if (group === 'community') { + const config = await getConfig(client, interaction.guildId); + if (sub === 'view') { + const c = config.community, channels = config.channels; + return interaction.reply({ ...info('הגדרות קהילה', `הצעות: ${channels.suggestions ? `<#${channels.suggestions}>` : 'לא הוגדר'}\nמשוב: ${channels.feedback ? `<#${channels.feedback}>` : 'לא הוגדר'}\nדיווחים: ${channels.reports ? `<#${channels.reports}>` : 'לא הוגדר'}\nפרסום עצמי: ${channels.selfPromotion ? `<#${channels.selfPromotion}>` : 'לא הוגדר'}\nמחפש עורך: ${channels.lookingForEditor ? `<#${channels.lookingForEditor}>` : 'לא הוגדר'}\nמחפש צוות: ${channels.lookingForTeam ? `<#${channels.lookingForTeam}>` : 'לא הוגדר'}\n\n**זמני המתנה (שניות)**\n${Object.entries(c.cooldowns).map(([name,value])=>`${name}: ${value}`).join('\n')}\n\nמשוב אנונימי: ${c.anonymousFeedback?'פעיל':'כבוי'}\nסקרים ציבוריים: ${c.publicPolls?'פעיל':'כבוי'}\nקישורי Discord: ${c.allowDiscordInvites?'פעיל':'כבוי'}\nתוצאות הצבעה ציבוריות: ${c.publicVoteTotals?'פעיל':'כבוי'}\nמודול קהילה: ${c.enabled?'פעיל':'כבוי'}\nתפקידי עריכה: ${c.editingRoleIds.length}`), flags: MessageFlags.Ephemeral }); + } + if (interaction.user.id !== interaction.guild.ownerId) return interaction.reply({ content: 'רק בעל השרת יכול לשנות את הגדרות הקהילה.', flags: MessageFlags.Ephemeral }); + if (sub === 'channel') { + const type = interaction.options.getString('type'), channel = interaction.options.getChannel('channel'); + await updateConfig(client, interaction.guildId, { channels: { [type]: channel.id } }); + return interaction.reply({ ...success('ערוץ הקהילה עודכן', `${type}: ${channel}`), flags: MessageFlags.Ephemeral }); + } + if (sub === 'cooldown') { + const command = interaction.options.getString('command'), seconds = interaction.options.getInteger('seconds'); + await updateConfig(client, interaction.guildId, { community: { cooldowns: { [command]: seconds } } }); + return interaction.reply({ ...success('זמן ההמתנה עודכן', `/${command}: ${seconds} שניות`), flags: MessageFlags.Ephemeral }); + } + if (sub === 'toggles') { + const setting = interaction.options.getString('setting'), enabled = interaction.options.getBoolean('enabled'); + await updateConfig(client, interaction.guildId, { community: { [setting]: enabled } }); + return interaction.reply({ ...success('הגדרת הקהילה עודכנה', `${setting}: ${enabled ? 'פעיל' : 'כבוי'}`), flags: MessageFlags.Ephemeral }); + } + const roles = Array.from({length:8},(_,index)=>interaction.options.getRole(`role_${index+1}`)).filter(Boolean); + const invalid = roles.some(role => role.managed || role.position >= interaction.guild.members.me.roles.highest.position || Object.values(config.staffRoles).includes(role.id)); + if (invalid) return interaction.reply({ content: 'אחד התפקידים מנוהל, מוגן או גבוה מתפקיד הבוט.', flags: MessageFlags.Ephemeral }); + await updateConfig(client, interaction.guildId, { community: { editingRoleIds: roles.map(role=>role.id) } }); + return interaction.reply({ ...success('תפקידי העריכה עודכנו', `נשמרו ${roles.length} תפקידים לבחירה עצמית.`), flags: MessageFlags.Ephemeral }); + } + if (sub === 'reset') { await resetConfig(client, interaction.guildId); return interaction.reply({ ...success('ההגדרות אופסו', 'הבוט חזר להגדרות ברירת המחדל.'), flags: MessageFlags.Ephemeral }); } + const config = await getConfig(client, interaction.guildId); + if (sub === 'view') return interaction.reply({ embeds: [createSettingsPage(config)], components: createSettingsComponents(interaction.user.id), flags: MessageFlags.Ephemeral }); + if (interaction.user.id !== interaction.guild.ownerId) return interaction.reply({ content: 'רק בעל השרת יכול לשנות את הגדרות הלוגים.', flags: MessageFlags.Ephemeral }); + const action = interaction.options.getString('action'), event = interaction.options.getString('event'), channel = interaction.options.getChannel('channel'); + if (action === 'view') { const lines = Object.entries(groups).map(([name, types]) => `${types.every(t => config.logging.enabledEvents?.[t] !== false) ? '✅' : '❌'} ${name}`); return interaction.reply({ ...info('הגדרות לוגים', `ערוץ: ${config.logging.channelId ? `<#${config.logging.channelId}>` : 'לא הוגדר'}\n${lines.join('\n')}`), flags: MessageFlags.Ephemeral }); } + if (action === 'channel') { if (!channel) return interaction.reply({ content: 'יש לבחור ערוץ.', flags: MessageFlags.Ephemeral }); await updateConfig(client, interaction.guildId, { logging: { enabled: true, channelId: channel.id } }); return interaction.reply({ ...success('ערוץ הלוגים עודכן', `הלוגים יישלחו אל ${channel}.`), flags: MessageFlags.Ephemeral }); } + if (action === 'test') { const result = await logEvent({ client, guildId: interaction.guildId, eventType: EVENT_TYPES.SETTINGS_CHANGE, data: { title: '✅ בדיקת מערכת הלוגים', description: 'מערכת הלוגים פועלת והערוץ מוגדר כראוי.' } }); return interaction.reply({ content: result.ok ? 'בדיקת הלוגים נשלחה.' : `הבדיקה נכשלה: ${result.reason}`, flags: MessageFlags.Ephemeral }); } + if (!event) return interaction.reply({ content: 'יש לבחור סוג אירוע להפעלה או כיבוי.', flags: MessageFlags.Ephemeral }); + const enabledEvents = { ...(config.logging.enabledEvents || {}) }; for (const type of groups[event]) enabledEvents[type] = action === 'enable'; + await updateConfig(client, interaction.guildId, { logging: { enabled: true, enabledEvents } }); + return interaction.reply({ ...success('הגדרות הלוגים עודכנו', `${event}: ${action === 'enable' ? 'פעיל' : 'כבוי'}`), flags: MessageFlags.Ephemeral }); + } +}; diff --git a/src/commands/admin/ticket.js b/src/commands/admin/ticket.js new file mode 100644 index 0000000000..85c673b81c --- /dev/null +++ b/src/commands/admin/ticket.js @@ -0,0 +1,6 @@ +import { ChannelType, MessageFlags, SlashCommandBuilder } from 'discord.js'; +import { getConfig, updateConfig } from '../../modules/community/store.js'; +import ticketOpen from '../../modules/interactions/buttons/ticket_open.js'; +import { requireAccess, AccessLevel } from '../../modules/community/permissions.js'; +import { ticketPanelPayload } from '../../services/ticketSystemService.js'; +export default{data:new SlashCommandBuilder().setName('ticket').setDescription('פתיחה או הגדרה של מערכת הכרטיסים').setDMPermission(false).addSubcommand(s=>s.setName('open').setDescription('פתיחת כרטיס תמיכה')).addSubcommand(s=>s.setName('setup').setDescription('הגדרת המערכת').addChannelOption(o=>o.setName('channel').setDescription('ערוץ הלוח').addChannelTypes(ChannelType.GuildText).setRequired(true)).addChannelOption(o=>o.setName('category').setDescription('קטגוריית הכרטיסים').addChannelTypes(ChannelType.GuildCategory).setRequired(true)).addRoleOption(o=>o.setName('support_role').setDescription('תפקיד צוות').setRequired(true))).addSubcommand(s=>s.setName('disable').setDescription('כיבוי המערכת')),async execute(i,client){const sub=i.options.getSubcommand();if(!await requireAccess(i,client,sub==='open'?AccessLevel.VERIFIED:AccessLevel.ADMIN,`ticket.${sub}`))return;if(sub==='open')return ticketOpen.execute(i,client);if(sub==='disable'){await updateConfig(client,i.guildId,{tickets:{enabled:false}});return i.reply({content:'מערכת הכרטיסים כובתה.',flags:MessageFlags.Ephemeral});}const channel=i.options.getChannel('channel'),category=i.options.getChannel('category'),role=i.options.getRole('support_role');await updateConfig(client,i.guildId,{tickets:{enabled:true,panelChannelId:channel.id,categoryId:category.id,supportRoleId:role.id}});const config=await getConfig(client,i.guildId);await channel.send(ticketPanelPayload(config));return i.reply({content:'לוח הכרטיסים נשלח והמערכת הופעלה.',flags:MessageFlags.Ephemeral});}}; diff --git a/src/commands/admin/ticketpanel.js b/src/commands/admin/ticketpanel.js new file mode 100644 index 0000000000..d6dac4b38c --- /dev/null +++ b/src/commands/admin/ticketpanel.js @@ -0,0 +1,5 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { getConfig, updateConfig } from '../../modules/community/store.js'; +import { ticketPanelPayload, TICKET_TYPES } from '../../services/ticketSystemService.js'; +const data=new SlashCommandBuilder().setName('ticketpanel').setDescription('ניהול לוחות כרטיסים').setDefaultMemberPermissions(PermissionFlagsBits.Administrator).setDMPermission(false).addSubcommand(s=>s.setName('create').setDescription('יצירת לוח').addChannelOption(o=>o.setName('channel').setDescription('ערוץ').addChannelTypes(ChannelType.GuildText).setRequired(true)).addStringOption(o=>o.setName('title').setDescription('כותרת').setRequired(true)).addStringOption(o=>o.setName('description').setDescription('תיאור').setRequired(true)).addStringOption(o=>o.setName('types').setDescription('סוגים מופרדים בפסיק').setRequired(true)).addStringOption(o=>o.setName('style').setDescription('סגנון').setRequired(true).addChoices({name:'select menu',value:'select'},{name:'buttons',value:'buttons'})).addStringOption(o=>o.setName('color').setDescription('צבע').setRequired(true))).addSubcommand(s=>s.setName('edit').setDescription('עריכת לוח').addStringOption(o=>o.setName('panel').setDescription('מזהה').setRequired(true)).addChannelOption(o=>o.setName('channel').setDescription('ערוץ').addChannelTypes(ChannelType.GuildText)).addStringOption(o=>o.setName('title').setDescription('כותרת')).addStringOption(o=>o.setName('description').setDescription('תיאור')).addStringOption(o=>o.setName('types').setDescription('סוגים')).addIntegerOption(o=>o.setName('max_open').setDescription('מקסימום פתוחים').setMinValue(1).setMaxValue(10)).addChannelOption(o=>o.setName('category').setDescription('קטגוריה').addChannelTypes(ChannelType.GuildCategory)).addRoleOption(o=>o.setName('staff_role').setDescription('צוות'))).addSubcommand(s=>s.setName('delete').setDescription('מחיקת לוח').addStringOption(o=>o.setName('panel').setDescription('מזהה').setRequired(true))); +export default{data,async execute(i,client){if(i.user.id!==i.guild.ownerId&&!i.member.permissions.has(PermissionFlagsBits.Administrator))return i.reply({content:'אין לך הרשאה.',flags:MessageFlags.Ephemeral});const config=await getConfig(client,i.guildId),sub=i.options.getSubcommand(),ref=i.options.getString('panel');if(sub==='delete'){const panel=await client.db.get(`community:${i.guildId}:ticketpanel:${ref}`);if(!panel)return i.reply({content:'לוח הכרטיסים לא נמצא.',flags:MessageFlags.Ephemeral});return i.reply({content:`האם למחוק את לוח #${ref}? כרטיסים קיימים לא יימחקו.`,components:[new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId(`ticket_panel_delete:${ref}:${i.user.id}`).setLabel('אישור מחיקה').setStyle(ButtonStyle.Danger))],flags:MessageFlags.Ephemeral});}if(sub==='create'){const types=i.options.getString('types').split(',').map(v=>v.trim()).filter(v=>TICKET_TYPES[v]);if(!types.length)return i.reply({content:'לא נבחרו סוגים תקינים.',flags:MessageFlags.Ephemeral});const id=String(await client.db.increment(`community:${i.guildId}:sequence:ticketpanel`)),panel={id,title:i.options.getString('title'),description:i.options.getString('description'),types,style:i.options.getString('style'),color:i.options.getString('color'),channelId:i.options.getChannel('channel').id};const message=await i.options.getChannel('channel').send(ticketPanelPayload(config,panel));panel.messageId=message.id;await client.db.set(`community:${i.guildId}:ticketpanel:${id}`,panel);await updateConfig(client,i.guildId,{tickets:{panels:[...new Set([...(config.tickets.panels||[]),id])]}});return i.reply({content:`לוח #${id} נוצר.`,flags:MessageFlags.Ephemeral});}const panel=await client.db.get(`community:${i.guildId}:ticketpanel:${ref}`);if(!panel)return i.reply({content:'לוח הכרטיסים לא נמצא.',flags:MessageFlags.Ephemeral});panel.title=i.options.getString('title')||panel.title;panel.description=i.options.getString('description')||panel.description;const types=i.options.getString('types');if(types)panel.types=types.split(',').map(v=>v.trim()).filter(v=>TICKET_TYPES[v]);const old=i.guild.channels.cache.get(panel.channelId),oldMessage=await old?.messages.fetch(panel.messageId).catch(()=>null),destination=i.options.getChannel('channel');if(destination&&destination.id!==panel.channelId){const msg=await destination.send(ticketPanelPayload(config,panel));await oldMessage?.delete().catch(()=>null);panel.channelId=destination.id;panel.messageId=msg.id;}else await oldMessage?.edit(ticketPanelPayload(config,panel));await client.db.set(`community:${i.guildId}:ticketpanel:${ref}`,panel);await updateConfig(client,i.guildId,{tickets:{categoryId:i.options.getChannel('category')?.id||config.tickets.categoryId,supportRoleId:i.options.getRole('staff_role')?.id||config.tickets.supportRoleId,maxOpenPerUser:i.options.getInteger('max_open')||config.tickets.maxOpenPerUser}});return i.reply({content:`לוח #${ref} עודכן.`,flags:MessageFlags.Ephemeral});}}; diff --git a/src/commands/admin/verification.js b/src/commands/admin/verification.js new file mode 100644 index 0000000000..ccd566c45f --- /dev/null +++ b/src/commands/admin/verification.js @@ -0,0 +1,2 @@ +import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, MessageFlags } from 'discord.js'; import { updateConfig } from '../../modules/community/store.js'; import { verifyPanel, success } from '../../modules/community/ui.js'; +export default { data:new SlashCommandBuilder().setName('verification').setDescription('Configure member verification').setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild).addSubcommand(s=>s.setName('setup').setDescription('Create verification panel').addChannelOption(o=>o.setName('channel').setDescription('Panel channel').addChannelTypes(ChannelType.GuildText).setRequired(true)).addRoleOption(o=>o.setName('role').setDescription('Role after verification').setRequired(true))).addSubcommand(s=>s.setName('disable').setDescription('Disable verification')), async execute(i,client){const sub=i.options.getSubcommand(); if(sub==='disable'){await updateConfig(client,i.guildId,{verification:{enabled:false}});return i.reply({...success('אימות','מערכת האימות כובתה.'),flags:MessageFlags.Ephemeral});} const channel=i.options.getChannel('channel'),role=i.options.getRole('role'); await updateConfig(client,i.guildId,{verification:{enabled:true,channelId:channel.id,roleId:role.id}}); await channel.send(verifyPanel()); await i.reply({...success('אימות','לוח האימות נשלח והמערכת הופעלה.'),flags:MessageFlags.Ephemeral});} }; diff --git a/src/commands/admin/welcome.js b/src/commands/admin/welcome.js new file mode 100644 index 0000000000..9dd2815f88 --- /dev/null +++ b/src/commands/admin/welcome.js @@ -0,0 +1,3 @@ +import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, MessageFlags } from 'discord.js'; +import { updateConfig } from '../../modules/community/store.js'; import { success } from '../../modules/community/ui.js'; +export default { data: new SlashCommandBuilder().setName('welcome').setDescription('Configure the welcome system').setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild).addSubcommand(s=>s.setName('setup').setDescription('Set welcome channel and message').addChannelOption(o=>o.setName('channel').setDescription('Welcome channel').addChannelTypes(ChannelType.GuildText).setRequired(true)).addStringOption(o=>o.setName('message').setDescription('Use {member}, {server}, {memberCount}').setRequired(true))).addSubcommand(s=>s.setName('disable').setDescription('Disable welcome messages')), async execute(i, client) { const sub=i.options.getSubcommand(); await updateConfig(client,i.guildId,{welcome:sub==='disable'?{enabled:false}:{enabled:true,channelId:i.options.getChannel('channel').id,message:i.options.getString('message') }}); await i.reply({...success('מערכת קבלת פנים',sub==='disable'?'המערכת כובתה.':'המערכת הוגדרה בהצלחה.'),flags:MessageFlags.Ephemeral}); } }; diff --git a/src/commands/community/editingtype.js b/src/commands/community/editingtype.js new file mode 100644 index 0000000000..9c41f578c5 --- /dev/null +++ b/src/commands/community/editingtype.js @@ -0,0 +1 @@ +import { communityCommand } from './factory.js'; export default communityCommand('editingtype'); diff --git a/src/commands/community/factory.js b/src/commands/community/factory.js new file mode 100644 index 0000000000..8e959e37e2 --- /dev/null +++ b/src/commands/community/factory.js @@ -0,0 +1,98 @@ +import { + ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags, ModalBuilder, + PollLayoutType, SlashCommandBuilder, StringSelectMenuBuilder, TextInputBuilder, TextInputStyle +} from 'discord.js'; +import { createEmbed } from '../../utils/embeds.js'; +import { getConfig } from '../../modules/community/store.js'; +import { requireAccess, AccessLevel, memberAccessLevel } from '../../modules/community/permissions.js'; +import { schedulePollClosure } from '../../services/communityPollService.js'; +import { OWNER_INBOX_GUILD_ID } from '../../services/ownerInboxService.js'; + +const descriptions = { + suggest: 'שליחת הצעה לקהילה', feedback: 'שליחת משוב על השרת או הבוט', report: 'שליחת דיווח פרטי לצוות', + poll: 'יצירת סקר קהילתי', selfpromo: 'פרסום עמוד היוצר שלך', lookingforeditor: 'פרסום חיפוש אחר עורך', + lookingforteam: 'פרסום חיפוש אחר צוות', editingtype: 'בחירת תחומי העריכה שלך' +}; +const modalFields = { + suggest: [['title', 'כותרת ההצעה', TextInputStyle.Short, true], ['description', 'פירוט ההצעה', TextInputStyle.Paragraph, true]], + feedback: [['category', 'סוג המשוב', TextInputStyle.Short, true, 'השרת / הבוט / הצוות / רעיון לשיפור / אחר'], ['content', 'תוכן המשוב', TextInputStyle.Paragraph, true]], + report: [['type', 'סוג הדיווח', TextInputStyle.Short, true, 'משתמש / הודעה / הטרדה / ספאם / גניבת תוכן / בעיה טכנית / אחר'], ['reported_user', 'מזהה המשתמש המדווח (אם רלוונטי)', TextInputStyle.Short, false], ['description', 'תיאור המקרה', TextInputStyle.Paragraph, true], ['evidence', 'קישור להודעה או הוכחה (אופציונלי)', TextInputStyle.Short, false]], + selfpromo: [['platform', 'פלטפורמה', TextInputStyle.Short, true, 'TikTok / YouTube / Instagram / Twitch / Portfolio / Other'], ['display_name', 'שם תצוגה', TextInputStyle.Short, true], ['link', 'קישור', TextInputStyle.Short, true], ['description', 'תיאור קצר', TextInputStyle.Paragraph, true]], + lookingforeditor: [['video_type', 'סוג הסרטון והפלטפורמה', TextInputStyle.Short, true], ['deadline', 'מועד אחרון', TextInputStyle.Short, true], ['style', 'סגנון עריכה', TextInputStyle.Short, true], ['contact', 'דרך ליצירת קשר', TextInputStyle.Short, true], ['description', 'תיאור מלא', TextInputStyle.Paragraph, true]], + lookingforteam: [['project', 'סוג הפרויקט והתפקידים הדרושים', TextInputStyle.Paragraph, true], ['deadline', 'מועד אחרון', TextInputStyle.Short, true], ['experience', 'ניסיון נדרש', TextInputStyle.Short, true], ['contact', 'דרך ליצירת קשר', TextInputStyle.Short, true], ['description', 'תיאור מלא', TextInputStyle.Paragraph, true]] +}; + +export function createModal(name, suffix = '') { + const modal = new ModalBuilder().setCustomId(`community_form:${name}${suffix ? `:${suffix}` : ''}`).setTitle(descriptions[name]); + modal.addComponents(...modalFields[name].map(([id, label, style, required, placeholder]) => + new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId(id).setLabel(label).setStyle(style).setRequired(required).setMaxLength(style === TextInputStyle.Paragraph ? 1500 : 200).setPlaceholder(placeholder || label)) + )); + return modal; +} + +export function communityCommand(name) { + const data = new SlashCommandBuilder().setName(name).setDescription(descriptions[name]).setDMPermission(false); + if (name === 'feedback') data.addBooleanOption(o => o.setName('anonymous').setDescription('שליחת המשוב באופן אנונימי')); + if (name === 'poll') { + data.addStringOption(o => o.setName('question').setDescription('שאלת הסקר').setRequired(true).setMaxLength(300)); + for (let n = 1; n <= 2; n++) data.addStringOption(o => o.setName(`option_${n}`).setDescription(`אפשרות ${n}`).setRequired(true).setMaxLength(100)); + data.addStringOption(o => o.setName('duration').setDescription('משך: 10m, 2h, 1d (עד 7 ימים)').setRequired(true)); + data.addBooleanOption(o => o.setName('allow_multiple_choices').setDescription('לאפשר מספר בחירות').setRequired(true)); + for (let n = 3; n <= 5; n++) data.addStringOption(o => o.setName(`option_${n}`).setDescription(`אפשרות ${n}`).setMaxLength(100)); + } + if (['lookingforeditor', 'lookingforteam'].includes(name)) { + data.addBooleanOption(o => o.setName('paid').setDescription('האם העבודה בתשלום?').setRequired(true)); + if (name === 'lookingforeditor') data.addStringOption(o => o.setName('budget').setDescription('תקציב או טווח תקציב (חובה לעבודה בתשלום)').setMaxLength(100)); + } + return { data, async execute(interaction, client) { + const config = await getConfig(client, interaction.guildId); + if (config.community.enabled === false) return interaction.reply({ content: 'מודול פקודות הקהילה מושבת.', flags: MessageFlags.Ephemeral }); + const privateInboxCommand = interaction.guildId === OWNER_INBOX_GUILD_ID && ['suggest', 'report'].includes(name); + let required = privateInboxCommand || (name === 'poll' && config.community.publicPolls) + ? AccessLevel.EVERYONE + : name === 'poll' ? AccessLevel.HELPER : AccessLevel.VERIFIED; + if (!await requireAccess(interaction, client, required)) return; + if (name === 'editingtype') { + const roles = (config.community.editingRoleIds || []).map(id => interaction.guild.roles.cache.get(id)).filter(role => role && !role.managed && role.position < interaction.guild.members.me.roles.highest.position); + if (!roles.length) return interaction.reply({ content: 'תפקידי העריכה עדיין לא הוגדרו.', flags: MessageFlags.Ephemeral }); + const menu = new StringSelectMenuBuilder().setCustomId(`editing_roles:${interaction.user.id}`).setPlaceholder('בחרו את תחומי העריכה שלכם').setMinValues(0).setMaxValues(roles.length).addOptions(roles.map(role => ({ label: role.name, value: role.id, default: interaction.member.roles.cache.has(role.id) }))); + return interaction.reply({ embeds: [createEmbed({ title: '🎨 תחומי עריכה', description: 'בחרו את כל תחומי העריכה המתאימים לכם. הבחירה תחליף את הבחירה הקודמת.', color: 'primary' })], components: [new ActionRowBuilder().addComponents(menu)], flags: MessageFlags.Ephemeral }); + } + if (name === 'poll') return createPoll(interaction, client, config); + if (name === 'feedback' && interaction.options.getBoolean('anonymous') && !config.community.anonymousFeedback) return interaction.reply({ content: 'משוב אנונימי אינו מופעל בשרת זה.', flags: MessageFlags.Ephemeral }); + let suffix = name === 'feedback' && interaction.options.getBoolean('anonymous') ? 'anonymous' : ''; + if (['lookingforeditor', 'lookingforteam'].includes(name)) { + const paid = interaction.options.getBoolean('paid'); + const budget = interaction.options.getString('budget'); + if (name === 'lookingforeditor' && paid && !budget) return interaction.reply({ content: 'בעבודה בתשלום חובה לציין תקציב או טווח תקציב.', flags: MessageFlags.Ephemeral }); + suffix = Buffer.from(JSON.stringify({ paid, budget })).toString('base64url'); + } + return interaction.showModal(createModal(name, suffix)); + } }; +} + +export function parseDuration(value) { + const match = /^(\d+)(m|h|d)$/i.exec(value || ''); + if (!match) return null; + const ms = Number(match[1]) * ({ m: 60000, h: 3600000, d: 86400000 })[match[2].toLowerCase()]; + return ms >= 600000 && ms <= 604800000 ? ms : null; +} + +async function createPoll(interaction, client) { + const duration = parseDuration(interaction.options.getString('duration')); + if (!duration) return interaction.reply({ content: 'משך הסקר אינו תקין. השתמשו לדוגמה ב־10m, 2h או 1d (עד 7 ימים).', flags: MessageFlags.Ephemeral }); + const options = Array.from({ length: 5 }, (_, index) => interaction.options.getString(`option_${index + 1}`)?.trim()).filter(Boolean); + if (new Set(options.map(v => v.toLowerCase())).size !== options.length) return interaction.reply({ content: 'אפשרויות הסקר חייבות להיות שונות זו מזו.', flags: MessageFlags.Ephemeral }); + const id = String(await client.db.increment(`community:${interaction.guildId}:sequence:poll`)); + const nativePoll = Boolean(PollLayoutType?.Default) && duration >= 3600000; + const record = { id, authorId: interaction.user.id, question: interaction.options.getString('question'), options, votes: {}, multiple: interaction.options.getBoolean('allow_multiple_choices'), nativePoll, status: 'open', createdAt: Date.now(), closesAt: Date.now() + duration }; + await client.db.set(`community:${interaction.guildId}:poll:${id}`, record); + const buttons = options.map((option, index) => new ButtonBuilder().setCustomId(`community_vote:poll:${id}:${index}`).setLabel(`${index + 1}`).setStyle(ButtonStyle.Primary)); + const message = await interaction.channel.send(nativePoll ? { + content: `📊 **סקר #${id}** — נוצר על ידי ${interaction.user}`, + poll: { question: { text: record.question }, answers: options.map(text => ({ text })), duration: Math.ceil(duration / 3600000), allowMultiselect: record.multiple, layoutType: PollLayoutType.Default } + } : { embeds: [createEmbed({ title: `📊 ${record.question}`, description: options.map((option, index) => `**${index + 1}.** ${option}`).join('\n'), footer: { text: `סקר #${id} • נסגר בעוד ${interaction.options.getString('duration')}` }, color: 'primary' })], components: [new ActionRowBuilder().addComponents(buttons)] }); + record.messageId = message.id; record.channelId = message.channelId; await client.db.set(`community:${interaction.guildId}:poll:${id}`, record); + schedulePollClosure(client, interaction.guildId, id, record.closesAt); + return interaction.reply({ content: `הסקר פורסם בהצלחה ב־${interaction.channel}.`, flags: MessageFlags.Ephemeral }); +} diff --git a/src/commands/community/feedback.js b/src/commands/community/feedback.js new file mode 100644 index 0000000000..ccf8e4af6d --- /dev/null +++ b/src/commands/community/feedback.js @@ -0,0 +1 @@ +import { communityCommand } from './factory.js'; export default communityCommand('feedback'); diff --git a/src/commands/community/lookingforeditor.js b/src/commands/community/lookingforeditor.js new file mode 100644 index 0000000000..6c6506207f --- /dev/null +++ b/src/commands/community/lookingforeditor.js @@ -0,0 +1 @@ +import { communityCommand } from './factory.js'; export default communityCommand('lookingforeditor'); diff --git a/src/commands/community/lookingforteam.js b/src/commands/community/lookingforteam.js new file mode 100644 index 0000000000..7306bcf271 --- /dev/null +++ b/src/commands/community/lookingforteam.js @@ -0,0 +1 @@ +import { communityCommand } from './factory.js'; export default communityCommand('lookingforteam'); diff --git a/src/commands/community/poll.js b/src/commands/community/poll.js new file mode 100644 index 0000000000..7695d5201e --- /dev/null +++ b/src/commands/community/poll.js @@ -0,0 +1 @@ +import { communityCommand } from './factory.js'; export default communityCommand('poll'); diff --git a/src/commands/community/report.js b/src/commands/community/report.js new file mode 100644 index 0000000000..7c84832366 --- /dev/null +++ b/src/commands/community/report.js @@ -0,0 +1 @@ +import { communityCommand } from './factory.js'; export default communityCommand('report'); diff --git a/src/commands/community/selfpromo.js b/src/commands/community/selfpromo.js new file mode 100644 index 0000000000..400da08677 --- /dev/null +++ b/src/commands/community/selfpromo.js @@ -0,0 +1 @@ +import { communityCommand } from './factory.js'; export default communityCommand('selfpromo'); diff --git a/src/commands/community/suggest.js b/src/commands/community/suggest.js new file mode 100644 index 0000000000..32e2a251d3 --- /dev/null +++ b/src/commands/community/suggest.js @@ -0,0 +1 @@ +import { communityCommand } from './factory.js'; export default communityCommand('suggest'); diff --git a/src/commands/contests/contest.js b/src/commands/contests/contest.js new file mode 100644 index 0000000000..662a6762a6 --- /dev/null +++ b/src/commands/contests/contest.js @@ -0,0 +1,2 @@ +import { SlashCommandBuilder, MessageFlags } from 'discord.js'; import { createEmbed } from '../../utils/embeds.js'; import { getConfig,updateConfig } from '../../modules/community/store.js'; import { requireAccess,AccessLevel } from '../../modules/community/permissions.js'; +export default {data:new SlashCommandBuilder().setName('contest').setDescription('Manage editing contests').setDMPermission(false).addSubcommand(s=>s.setName('create').setDescription('Create contest').addStringOption(o=>o.setName('title').setDescription('Title').setRequired(true)).addStringOption(o=>o.setName('description').setDescription('Description').setRequired(true))).addSubcommand(s=>s.setName('submit').setDescription('Submit work').addStringOption(o=>o.setName('url').setDescription('Work URL').setRequired(true))).addSubcommand(s=>s.setName('vote').setDescription('Vote for submission').addIntegerOption(o=>o.setName('number').setDescription('Submission number').setRequired(true).setMinValue(1))).addSubcommand(s=>s.setName('end').setDescription('End contest')),async execute(i,c){const sub=i.options.getSubcommand(),admin=['create','end'].includes(sub);if(!await requireAccess(i,c,admin?AccessLevel.ADMIN:AccessLevel.VERIFIED,`contest.${sub}`))return;const config=await getConfig(c,i.guildId),state=config.contests;if(sub==='create'){if(state.active)return i.reply({content:'כבר קיימת תחרות פעילה.',flags:MessageFlags.Ephemeral});const active={title:i.options.getString('title'),description:i.options.getString('description'),createdAt:Date.now()};await updateConfig(c,i.guildId,{contests:{active,submissions:[],votes:{}}});return i.reply({embeds:[createEmbed({title:`תחרות: ${active.title}`,description:active.description,color:'primary'})]});}if(!state.active)return i.reply({content:'אין תחרות פעילה.',flags:MessageFlags.Ephemeral});if(sub==='submit'){if(state.submissions.some(s=>s.userId===i.user.id))return i.reply({content:'כבר הגשת עבודה לתחרות זו.',flags:MessageFlags.Ephemeral});const submissions=[...state.submissions,{userId:i.user.id,url:i.options.getString('url'),createdAt:Date.now()}];await updateConfig(c,i.guildId,{contests:{submissions}});return i.reply({content:`העבודה התקבלה כהגשה מספר **${submissions.length}**.`,flags:MessageFlags.Ephemeral});}if(sub==='vote'){const number=i.options.getInteger('number');if(!state.submissions[number-1])return i.reply({content:'מספר ההגשה אינו קיים.',flags:MessageFlags.Ephemeral});const votes={...state.votes,[i.user.id]:number};await updateConfig(c,i.guildId,{contests:{votes}});return i.reply({content:`הצבעת להגשה מספר **${number}**.`,flags:MessageFlags.Ephemeral});}const counts={};for(const n of Object.values(state.votes))counts[n]=(counts[n]||0)+1;const results=state.submissions.map((s,n)=>({n:n+1,...s,votes:counts[n+1]||0})).sort((a,b)=>b.votes-a.votes);await updateConfig(c,i.guildId,{contests:{active:null,submissions:[],votes:{}}});return i.reply({embeds:[createEmbed({title:`תוצאות — ${state.active.title}`,description:results.length?results.map((r,n)=>`**${n+1}.** <@${r.userId}> — ${r.votes} קולות — [צפייה](${r.url})`).join('\n'):'לא הוגשו עבודות.',color:'success'})]});}}; diff --git a/src/commands/general/avatar.js b/src/commands/general/avatar.js new file mode 100644 index 0000000000..c0b6e35b88 --- /dev/null +++ b/src/commands/general/avatar.js @@ -0,0 +1 @@ +import { generalCommand } from './factory.js'; export default generalCommand('avatar'); diff --git a/src/commands/general/botinfo.js b/src/commands/general/botinfo.js new file mode 100644 index 0000000000..cb302c7b55 --- /dev/null +++ b/src/commands/general/botinfo.js @@ -0,0 +1 @@ +import { generalCommand } from './factory.js'; export default generalCommand('botinfo'); diff --git a/src/commands/general/factory.js b/src/commands/general/factory.js new file mode 100644 index 0000000000..d7f4fcbfb4 --- /dev/null +++ b/src/commands/general/factory.js @@ -0,0 +1,161 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + MessageFlags, + SlashCommandBuilder, + StringSelectMenuBuilder, + version as discordVersion +} from 'discord.js'; +import { createEmbed } from '../../utils/embeds.js'; +import { requireAccess, AccessLevel, memberAccessLevel } from '../../modules/community/permissions.js'; +import { getConfig } from '../../modules/community/store.js'; +import packageJson from '../../../package.json' with { type: 'json' }; + +export const HELP_CATEGORIES = Object.freeze({ + general: { label: 'כללי', emoji: '📚', directories: ['general'] }, + community: { label: 'קהילה', emoji: '👥', directories: ['community', 'contests', 'roles'] }, + leveling: { label: 'רמות', emoji: '⭐', directories: ['levels'], names: ['level'] }, + tickets: { label: 'פניות', emoji: '🎫', directories: ['tickets'], names: ['ticket'] }, + moderation: { label: 'ניהול', emoji: '🛡️', directories: ['moderation'], names: ['moderation'] }, + settings: { label: 'הגדרות', emoji: '⚙️', directories: ['admin', 'owner'] } +}); + +const descriptions = { + help: 'הצגת מרכז העזרה והפקודות הזמינות', ping: 'בדיקת זמני תגובה ומסד הנתונים', + botinfo: 'הצגת מידע וסטטיסטיקות על הבוט', serverinfo: 'הצגת מידע על השרת', + userinfo: 'הצגת מידע על חבר בשרת', avatar: 'הצגת תמונת פרופיל ברזולוציה מלאה' +}; +const base = name => new SlashCommandBuilder().setName(name).setDescription(descriptions[name]).setDMPermission(false); +const discordDate = timestamp => timestamp ? `` : 'לא זמין'; + +export function formatUptime(totalSeconds) { + const seconds = Math.max(0, Math.floor(totalSeconds || 0)); + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return [days && `${days} ימים`, hours && `${hours} שעות`, `${minutes} דקות`].filter(Boolean).join(', '); +} + +function categoryFor(command) { + const name = command.data.name; + return Object.entries(HELP_CATEGORIES).find(([, value]) => + value.names?.includes(name) || (value.directories.includes(command.category) && !Object.values(HELP_CATEGORIES).some(other => other.names?.includes(name))) + )?.[0]; +} + +function defaultAccessFor(command) { + if (command.category === 'owner') return AccessLevel.OWNER; + if (command.category === 'moderation') return AccessLevel.MODERATOR; + if (command.category === 'tickets') return AccessLevel.HELPER; + if (command.category === 'levels' || command.category === 'community' || command.category === 'contests') return AccessLevel.VERIFIED; + if (command.category === 'roles') return AccessLevel.ADMIN; + return AccessLevel.EVERYONE; +} + +export async function getVisibleHelpCommands(interaction, client) { + const [level, config] = await Promise.all([ + memberAccessLevel(interaction, client), + getConfig(client, interaction.guildId) + ]); + return [...client.commands.values()].filter(command => { + const data = command.data.toJSON(); + if (!categoryFor(command) || config.commandSettings?.[data.name]?.enabled === false) return false; + const configuredLevel = Number(config.commandPermissions?.[data.name] ?? defaultAccessFor(command)); + if (level < configuredLevel) return false; + if (data.default_member_permissions && !interaction.member.permissions.has(BigInt(data.default_member_permissions))) return false; + return true; + }); +} + +export function createHelpView(commands, category = null, userId = null) { + const grouped = Object.fromEntries(Object.keys(HELP_CATEGORIES).map(key => [key, commands.filter(command => categoryFor(command) === key)])); + const selected = category && HELP_CATEGORIES[category] ? category : null; + const section = selected ? grouped[selected] : commands; + const embed = createEmbed({ + title: selected ? `${HELP_CATEGORIES[selected].emoji} ${HELP_CATEGORIES[selected].label}` : '📚 מרכז העזרה — EditIL Assistant', + description: selected + ? (section.map(command => `**/${command.data.name}** — ${command.data.description}`).join('\n') || 'אין פקודות זמינות בקטגוריה זו.') + : 'בחרו קטגוריה מהתפריט כדי לראות רק את הפקודות הזמינות עבורכם.', + fields: selected ? [] : Object.entries(HELP_CATEGORIES).map(([key, value]) => ({ + name: `${value.emoji} ${value.label}`, + value: `${grouped[key].length} פקודות זמינות`, + inline: true + })), + color: 'primary', + footer: { text: 'התפריט מציג פקודות בהתאם להרשאות שלך' } + }); + const menu = new StringSelectMenuBuilder() + .setCustomId(`general_help:${userId || 'unknown'}`) + .setPlaceholder('בחרו קטגוריה') + .addOptions(Object.entries(HELP_CATEGORIES).map(([value, item]) => ({ + label: item.label, value, emoji: item.emoji, + description: `${grouped[value].length} פקודות זמינות`, default: value === selected + }))); + return { embeds: [embed], components: [new ActionRowBuilder().addComponents(menu)], flags: MessageFlags.Ephemeral }; +} + +export function generalCommand(name) { + const data = base(name); + if (['userinfo', 'avatar'].includes(name)) { + data.addUserOption(option => option.setName('member').setDescription('החבר שעבורו יוצג המידע').setRequired(false)); + } + return { data, async execute(interaction, client) { + if (!await requireAccess(interaction, client, AccessLevel.EVERYONE)) return; + if (name === 'help') { + const commands = await getVisibleHelpCommands(interaction, client); + return interaction.reply(createHelpView(commands, null, interaction.user.id)); + } + + const guild = interaction.guild; + const user = interaction.options.getUser('member') || interaction.user; + let embed; + let components = []; + if (name === 'ping') { + const botLatency = Math.max(0, Date.now() - interaction.createdTimestamp); + const apiLatency = Math.max(0, Math.round(client.ws.ping)); + const status = client.db.getStatus?.() || {}; + const databaseUp = client.db.isAvailable?.() ?? (!status.isDegraded && status.connectionType === 'postgresql'); + embed = createEmbed({ title: '🏓 בדיקת תקשורת', fields: [ + { name: '🏓 זמן תגובת הבוט', value: `**${botLatency}ms**`, inline: true }, + { name: '🌐 זמן תגובת Discord API', value: `**${apiLatency}ms**`, inline: true }, + { name: '🗄️ מסד נתונים', value: databaseUp ? '**מחובר ופעיל**' : '**לא זמין — מצב זמני**', inline: false } + ], color: databaseUp ? 'success' : 'warning' }); + } else if (name === 'botinfo') { + const users = client.guilds.cache.reduce((sum, item) => sum + (item.memberCount || 0), 0); + const developer = process.env.DEVELOPER_NAME || (process.env.DEVELOPER_ID ? `<@${process.env.DEVELOPER_ID}>` : 'צוות EditIL'); + embed = createEmbed({ title: `🤖 מידע על ${client.user.username}`, thumbnail: client.user.displayAvatarURL({ size: 512 }), color: 'primary', fields: [ + { name: 'שם הבוט', value: client.user.username, inline: true }, { name: 'גרסה', value: packageJson.version, inline: true }, + { name: 'מפתח', value: developer, inline: true }, { name: 'ספרייה', value: `discord.js ${discordVersion}`, inline: true }, + { name: 'Ping', value: `${Math.max(0, Math.round(client.ws.ping))}ms`, inline: true }, { name: 'שרתים', value: String(client.guilds.cache.size), inline: true }, + { name: 'משתמשים', value: users.toLocaleString('he-IL'), inline: true }, { name: 'פקודות טעונות', value: String(client.commands.size), inline: true }, + { name: 'זמן פעילות', value: formatUptime(client.uptime / 1000), inline: false } + ] }); + } else if (name === 'serverinfo') { + // Refresh the member cache when the privileged guild-members intent is available; + // if it is not, Discord's reported member count and the current cache remain usable. + await guild.members.fetch().catch(() => null); + const bots = guild.members.cache.filter(member => member.user.bot).size; + embed = createEmbed({ title: `🌐 ${guild.name}`, thumbnail: guild.iconURL({ size: 1024 }), color: 'primary', fields: [ + { name: 'בעלים', value: `<@${guild.ownerId}>`, inline: true }, { name: 'חברים', value: guild.memberCount.toLocaleString('he-IL'), inline: true }, + { name: 'בוטים', value: bots.toLocaleString('he-IL'), inline: true }, { name: 'תפקידים', value: String(guild.roles.cache.size), inline: true }, + { name: 'ערוצים', value: String(guild.channels.cache.size), inline: true }, { name: 'רמת Boost', value: String(guild.premiumTier), inline: true }, + { name: 'Boosts', value: String(guild.premiumSubscriptionCount || 0), inline: true }, { name: 'תאריך יצירה', value: discordDate(guild.createdTimestamp), inline: false } + ] }); + } else if (name === 'userinfo') { + const member = await guild.members.fetch(user.id).catch(() => null); + embed = createEmbed({ title: `👤 מידע על ${user.username}`, thumbnail: user.displayAvatarURL({ size: 1024 }), color: 'primary', fields: [ + { name: 'שם תצוגה', value: member?.displayName || user.globalName || user.username, inline: true }, { name: 'שם משתמש', value: user.username, inline: true }, + { name: 'הצטרפות לשרת', value: discordDate(member?.joinedTimestamp), inline: false }, { name: 'יצירת החשבון', value: discordDate(user.createdTimestamp), inline: false }, + { name: 'התפקיד הגבוה ביותר', value: member?.roles.highest?.id === guild.id ? 'ללא תפקיד' : String(member?.roles.highest || 'לא זמין'), inline: true }, + { name: 'מזהה חבר', value: `\`${user.id}\``, inline: true } + ] }); + } else if (name === 'avatar') { + const extension = user.avatar?.startsWith('a_') ? 'gif' : 'png'; + const url = user.displayAvatarURL({ size: 4096, extension }); + embed = createEmbed({ title: `🖼️ תמונת הפרופיל של ${user.username}`, description: 'לחצו על הכפתור כדי לפתוח את התמונה בגודל מלא.', image: url, color: 'primary' }); + components = [new ActionRowBuilder().addComponents(new ButtonBuilder().setLabel('פתיחת התמונה').setEmoji('🔗').setStyle(ButtonStyle.Link).setURL(url))]; + } + return interaction.reply({ embeds: [embed], components }); + } }; +} diff --git a/src/commands/general/help.js b/src/commands/general/help.js new file mode 100644 index 0000000000..f31be7b6dd --- /dev/null +++ b/src/commands/general/help.js @@ -0,0 +1 @@ +import { generalCommand } from './factory.js'; export default generalCommand('help'); diff --git a/src/commands/general/ping.js b/src/commands/general/ping.js new file mode 100644 index 0000000000..5a0f392015 --- /dev/null +++ b/src/commands/general/ping.js @@ -0,0 +1 @@ +import { generalCommand } from './factory.js'; export default generalCommand('ping'); diff --git a/src/commands/general/serverinfo.js b/src/commands/general/serverinfo.js new file mode 100644 index 0000000000..f5a31b070b --- /dev/null +++ b/src/commands/general/serverinfo.js @@ -0,0 +1 @@ +import { generalCommand } from './factory.js'; export default generalCommand('serverinfo'); diff --git a/src/commands/general/userinfo.js b/src/commands/general/userinfo.js new file mode 100644 index 0000000000..81a38595bc --- /dev/null +++ b/src/commands/general/userinfo.js @@ -0,0 +1 @@ +import { generalCommand } from './factory.js'; export default generalCommand('userinfo'); diff --git a/src/commands/levels/factory.js b/src/commands/levels/factory.js new file mode 100644 index 0000000000..2d9c8c7b6e --- /dev/null +++ b/src/commands/levels/factory.js @@ -0,0 +1,2 @@ +import { SlashCommandBuilder, MessageFlags } from 'discord.js'; import { createEmbed } from '../../utils/embeds.js'; import { levelKey } from '../../modules/community/store.js'; import { requireAccess,AccessLevel } from '../../modules/community/permissions.js'; import { clampCommunityXp, communityLevelFromXp, MAX_COMMUNITY_XP } from '../../utils/levelLimits.js'; +export function levelCommand(name){const data=new SlashCommandBuilder().setName(name).setDescription(`EditIL ${name}`).setDMPermission(false);if(name==='setxp')data.addIntegerOption(o=>o.setName('xp').setDescription('XP amount').setRequired(true).setMinValue(0).setMaxValue(MAX_COMMUNITY_XP));if(name!=='leaderboard')data.addUserOption(o=>o.setName('member').setDescription('Member'));return{data,async execute(i,c){const admin=['setxp','resetxp'].includes(name);if(!await requireAccess(i,c,admin?AccessLevel.ADMIN:AccessLevel.VERIFIED))return;if(name==='leaderboard'){const keys=await c.db.list(`community:${i.guildId}:level:`);const rows=await Promise.all((keys||[]).map(async key=>{const value=await c.db.get(key,{xp:0,level:0}),xp=clampCommunityXp(value.xp);return{id:key.split(':').pop(),...value,xp,level:communityLevelFromXp(xp)}}));const board=rows.sort((a,b)=>(b.xp||0)-(a.xp||0)).slice(0,10);return i.reply({embeds:[createEmbed({title:'טבלת המובילים',description:board.length?board.map((v,n)=>`**${n+1}.** <@${v.id}> — ${v.xp||0} XP (רמה ${v.level||0})`).join('\n'):'עדיין אין נתוני רמות.',color:'primary'})]});}const user=i.options.getUser('member')||i.user,key=levelKey(i.guildId,user.id);if(name==='setxp'){const xp=clampCommunityXp(i.options.getInteger('xp'));await c.db.set(key,{xp,level:communityLevelFromXp(xp),last:0});return i.reply({content:`ה-XP של ${user} עודכן ל-${xp}.`,flags:MessageFlags.Ephemeral});}if(name==='resetxp'){await c.db.delete(key);return i.reply({content:`נתוני הרמה של ${user} אופסו.`,flags:MessageFlags.Ephemeral});}const v=await c.db.get(key,{xp:0,level:0}),xp=clampCommunityXp(v.xp),level=communityLevelFromXp(xp);await i.reply({embeds:[createEmbed({title:name==='profile'?'פרופיל עורך':'דירוג',description:`${user}\nרמה: **${level}**\nניסיון: **${xp} XP**`,color:'primary'}).setThumbnail(user.displayAvatarURL())],flags:MessageFlags.Ephemeral});}}} diff --git a/src/commands/levels/leaderboard.js b/src/commands/levels/leaderboard.js new file mode 100644 index 0000000000..b2453da887 --- /dev/null +++ b/src/commands/levels/leaderboard.js @@ -0,0 +1 @@ +import { levelCommand } from './factory.js'; export default levelCommand('leaderboard'); diff --git a/src/commands/levels/profile.js b/src/commands/levels/profile.js new file mode 100644 index 0000000000..22781b251f --- /dev/null +++ b/src/commands/levels/profile.js @@ -0,0 +1 @@ +import { levelCommand } from './factory.js'; export default levelCommand('profile'); diff --git a/src/commands/levels/rank.js b/src/commands/levels/rank.js new file mode 100644 index 0000000000..0537285998 --- /dev/null +++ b/src/commands/levels/rank.js @@ -0,0 +1 @@ +import { levelCommand } from './factory.js'; export default levelCommand('rank'); diff --git a/src/commands/levels/resetxp.js b/src/commands/levels/resetxp.js new file mode 100644 index 0000000000..f329d1525e --- /dev/null +++ b/src/commands/levels/resetxp.js @@ -0,0 +1 @@ +import { levelCommand } from './factory.js'; export default levelCommand('resetxp'); diff --git a/src/commands/levels/setxp.js b/src/commands/levels/setxp.js new file mode 100644 index 0000000000..b86f8ec6ef --- /dev/null +++ b/src/commands/levels/setxp.js @@ -0,0 +1 @@ +import { levelCommand } from './factory.js'; export default levelCommand('setxp'); diff --git a/src/commands/moderation/ban.js b/src/commands/moderation/ban.js new file mode 100644 index 0000000000..9c31df13db --- /dev/null +++ b/src/commands/moderation/ban.js @@ -0,0 +1,19 @@ +import { MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { createEmbed } from '../../utils/embeds.js'; +import { requireAccess, AccessLevel } from '../../modules/community/permissions.js'; + +export default { + data: new SlashCommandBuilder().setName('ban').setDescription('Ban a member').setDMPermission(false) + .addUserOption(o => o.setName('member').setDescription('Member to ban').setRequired(true)) + .addStringOption(o => o.setName('reason').setDescription('Reason').setRequired(true).setMaxLength(500)) + .addIntegerOption(o => o.setName('delete_messages').setDescription('Delete recent message hours (0-168)').setMinValue(0).setMaxValue(168)), + async execute(i, client) { + if (!await requireAccess(i, client, AccessLevel.ADMIN)) return; + if (!i.guild.members.me.permissions.has(PermissionFlagsBits.BanMembers)) return i.reply({ content: 'לבוט חסרה הרשאת חסימת חברים.', flags: MessageFlags.Ephemeral }); + const user = i.options.getUser('member'); const member = await i.guild.members.fetch(user.id); const reason = i.options.getString('reason'); + if ([i.user.id, i.client.user.id, i.guild.ownerId].includes(user.id) || member.roles.highest.position >= i.guild.members.me.roles.highest.position || (i.user.id !== i.guild.ownerId && member.roles.highest.position >= i.member.roles.highest.position)) return i.reply({ content: 'לא ניתן לחסום את המשתמש בגלל כללי הבטיחות או היררכיית התפקידים.', flags: MessageFlags.Ephemeral }); + await user.send(`נחסמת בשרת ${i.guild.name}. סיבה: ${reason}`).catch(() => {}); + await member.ban({ reason, deleteMessageSeconds: (i.options.getInteger('delete_messages') || 0) * 3600 }); + await i.reply({ embeds: [createEmbed({ title: 'המשתמש נחסם', description: `${user} נחסם.\nסיבה: ${reason}`, color: 'success' })], flags: MessageFlags.Ephemeral }); + } +}; diff --git a/src/commands/moderation/clear.js b/src/commands/moderation/clear.js new file mode 100644 index 0000000000..10c6d34085 --- /dev/null +++ b/src/commands/moderation/clear.js @@ -0,0 +1 @@ +import { moderationCommand } from './factory.js'; export default moderationCommand('clear'); diff --git a/src/commands/moderation/clearwarnings.js b/src/commands/moderation/clearwarnings.js new file mode 100644 index 0000000000..2ef5644cb9 --- /dev/null +++ b/src/commands/moderation/clearwarnings.js @@ -0,0 +1 @@ +import { moderationCommand } from './factory.js'; export default moderationCommand('clearwarnings'); diff --git a/src/commands/moderation/factory.js b/src/commands/moderation/factory.js new file mode 100644 index 0000000000..fb5ea7ff7c --- /dev/null +++ b/src/commands/moderation/factory.js @@ -0,0 +1,115 @@ +import { ChannelType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { createEmbed } from '../../utils/embeds.js'; +import { warningKey } from '../../modules/community/store.js'; +import { requireAccess, AccessLevel } from '../../modules/community/permissions.js'; + +const EPHEMERAL = MessageFlags.Ephemeral; +const requiredPermission = { + timeout: PermissionFlagsBits.ModerateMembers, kick: PermissionFlagsBits.KickMembers, + clear: PermissionFlagsBits.ManageMessages, lock: PermissionFlagsBits.ManageChannels, + unlock: PermissionFlagsBits.ManageChannels, slowmode: PermissionFlagsBits.ManageChannels, + nick: PermissionFlagsBits.ManageNicknames +}; +const reply = (i, description, color = 'success') => i.reply({ embeds: [createEmbed({ title: 'ניהול השרת', description, color })], flags: EPHEMERAL }); + +function parseDuration(value) { + const match = /^(\d+)\s*([mhd])$/i.exec(value || ''); + if (!match) return null; + const factors = { m: 60_000, h: 3_600_000, d: 86_400_000 }; + const ms = Number(match[1]) * factors[match[2].toLowerCase()]; + return ms > 0 && ms <= 28 * 86_400_000 ? ms : null; +} + +function targetError(i, member) { + if (member.id === i.guild.ownerId) return 'לא ניתן לבצע פעולת ניהול על בעל השרת.'; + if (member.id === i.user.id) return 'לא ניתן לבצע את הפעולה על עצמך.'; + if (member.id === i.client.user.id) return 'לא ניתן לבצע את הפעולה על הבוט.'; + if (member.roles.highest.position >= i.member.roles.highest.position && i.user.id !== i.guild.ownerId) return 'תפקיד המשתמש גבוה מדי בהיררכיית התפקידים שלך.'; + if (member.roles.highest.position >= i.guild.members.me.roles.highest.position) return 'תפקיד הבוט נמוך מדי בהיררכיית התפקידים.'; + return null; +} + +export function moderationCommand(name) { + const data = new SlashCommandBuilder().setName(name).setDescription(`EditIL ${name}`).setDMPermission(false); + if (['warn', 'warnings', 'clearwarnings', 'timeout', 'kick', 'nick'].includes(name)) data.addUserOption(o => o.setName('member').setDescription('Target member').setRequired(true)); + if (name === 'timeout') data.addStringOption(o => o.setName('duration').setDescription('Duration, for example 10m, 2h or 1d').setRequired(true)); + if (['warn', 'timeout', 'kick'].includes(name)) data.addStringOption(o => o.setName('reason').setDescription('Reason').setRequired(true).setMaxLength(500)); + if (name === 'clearwarnings') data.addStringOption(o => o.setName('warning_id').setDescription('Warning ID; omit to clear all')); + if (name === 'clear') data.addIntegerOption(o => o.setName('amount').setDescription('Messages').setRequired(true).setMinValue(1).setMaxValue(100)).addUserOption(o => o.setName('member').setDescription('Only messages from this member')); + if (name === 'slowmode') data.addIntegerOption(o => o.setName('seconds').setDescription('0-21600').setRequired(true).setMinValue(0).setMaxValue(21600)); + if (['lock', 'unlock', 'slowmode'].includes(name)) data.addChannelOption(o => o.setName('channel').setDescription('Channel; defaults to current').addChannelTypes(ChannelType.GuildText)); + if (name === 'nick') data.addStringOption(o => o.setName('nickname').setDescription('New nickname; omit to reset').setMaxLength(32)); + + return { data, async execute(i, client) { + const access = name === 'clearwarnings' ? AccessLevel.ADMIN : AccessLevel.MODERATOR; + if (!await requireAccess(i, client, access)) return; + const permission = requiredPermission[name]; + if (permission && !i.guild.members.me.permissions.has(permission)) return reply(i, 'לבוט חסרה ההרשאה הדרושה לביצוע הפעולה.', 'error'); + const reason = i.options.getString('reason') || 'לא צוינה סיבה'; + + if (['lock', 'unlock'].includes(name)) { + const channel = i.options.getChannel('channel') || i.channel; + const overwrite = channel.permissionOverwrites.cache.get(i.guild.roles.everyone.id); + const previous = overwrite?.allow.has(PermissionFlagsBits.SendMessages) ? true : overwrite?.deny.has(PermissionFlagsBits.SendMessages) ? false : null; + const key = `community:${i.guildId}:lock:${channel.id}`; + if (name === 'lock') { + if ((await client.db.get(key, null)) !== null) return reply(i, 'הערוץ כבר נעול.', 'warning'); + await client.db.set(key, { previous, by: i.user.id, at: Date.now() }); + await channel.permissionOverwrites.edit(i.guild.roles.everyone, { SendMessages: false }, { reason: `Locked by ${i.user.tag}` }); + return reply(i, `${channel} ננעל.`); + } + const saved = await client.db.get(key, null); + if (!saved) return reply(i, 'לא נמצא מצב הרשאות שמור לערוץ הזה.', 'warning'); + await channel.permissionOverwrites.edit(i.guild.roles.everyone, { SendMessages: saved.previous }, { reason: `Unlocked by ${i.user.tag}` }); + await client.db.delete(key); + return reply(i, `ההרשאה הקודמת של ${channel} שוחזרה.`); + } + if (name === 'slowmode') { + const channel = i.options.getChannel('channel') || i.channel; + await channel.setRateLimitPerUser(i.options.getInteger('seconds'), `Changed by ${i.user.tag}`); + return reply(i, `מצב איטי עודכן ב-${channel}.`); + } + if (name === 'clear') { + const amount = i.options.getInteger('amount'); + const user = i.options.getUser('member'); + const fetched = await i.channel.messages.fetch({ limit: 100 }); + const selected = fetched.filter(m => !m.pinned && (!user || m.author.id === user.id)).first(amount); + const deleted = await i.channel.bulkDelete(selected, true); + return reply(i, `נמחקו **${deleted.size}** הודעות.`); + } + + const user = i.options.getUser('member'); + const member = await i.guild.members.fetch(user.id); + const key = warningKey(i.guildId, user.id); + if (['warn', 'warnings', 'clearwarnings'].includes(name)) { + const warnings = await client.db.get(key, []); + if (name === 'warn') { + const warning = { id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`, reason, moderatorId: i.user.id, createdAt: Date.now(), active: true }; + warnings.push(warning); await client.db.set(key, warnings); + await user.send(`קיבלת אזהרה בשרת ${i.guild.name}. סיבה: ${reason} (מזהה: ${warning.id})`).catch(() => {}); + return reply(i, `${user} קיבל אזהרה. מזהה: \`${warning.id}\`.`); + } + if (name === 'clearwarnings') { + const id = i.options.getString('warning_id'); + if (id && !warnings.some(w => w.id === id && w.active !== false)) return reply(i, 'לא נמצאה אזהרה פעילה עם המזהה הזה.', 'error'); + const next = warnings.map(w => (!id || w.id === id) && w.active !== false ? { ...w, active: false, removedBy: i.user.id, removedAt: Date.now() } : w); + await client.db.set(key, next); return reply(i, id ? 'האזהרה הוסרה ונשמרה ברשומת הביקורת.' : 'כל האזהרות הפעילות הוסרו ונשמרו ברשומת הביקורת.'); + } + const lines = warnings.map(w => `**${w.id || 'ישן'}** — ${w.reason}\n<@${w.moderatorId}> · · ${w.active === false ? 'הוסרה' : 'פעילה'}`); + return i.reply({ embeds: [createEmbed({ title: `אזהרות — ${user.username}`, description: lines.join('\n\n') || 'אין אזהרות.', color: 'primary' })], flags: EPHEMERAL }); + } + + const error = targetError(i, member); + if (error) return reply(i, error, 'error'); + if (name === 'timeout') { + const duration = parseDuration(i.options.getString('duration')); + if (!duration) return reply(i, 'משך הזמן אינו תקין. השתמשו למשל ב־`10m`, `2h` או `1d` (עד 28 ימים).', 'error'); + await user.send(`הוטל עליך timeout בשרת ${i.guild.name}. סיבה: ${reason}`).catch(() => {}); + await member.timeout(duration, reason); + } else if (name === 'kick') { + await user.send(`הוסרת מהשרת ${i.guild.name}. סיבה: ${reason}`).catch(() => {}); + await member.kick(reason); + } else if (name === 'nick') await member.setNickname(i.options.getString('nickname'), `Changed by ${i.user.tag}`); + return reply(i, `הפעולה **${name}** בוצעה על ${user}.`); + } }; +} diff --git a/src/commands/moderation/kick.js b/src/commands/moderation/kick.js new file mode 100644 index 0000000000..ecfa85bc24 --- /dev/null +++ b/src/commands/moderation/kick.js @@ -0,0 +1 @@ +import { moderationCommand } from './factory.js'; export default moderationCommand('kick'); diff --git a/src/commands/moderation/lock.js b/src/commands/moderation/lock.js new file mode 100644 index 0000000000..82d97fc4b2 --- /dev/null +++ b/src/commands/moderation/lock.js @@ -0,0 +1 @@ +import { moderationCommand } from './factory.js'; export default moderationCommand('lock'); diff --git a/src/commands/moderation/nick.js b/src/commands/moderation/nick.js new file mode 100644 index 0000000000..19f92f9c07 --- /dev/null +++ b/src/commands/moderation/nick.js @@ -0,0 +1 @@ +import { moderationCommand } from './factory.js'; export default moderationCommand('nick'); diff --git a/src/commands/moderation/slowmode.js b/src/commands/moderation/slowmode.js new file mode 100644 index 0000000000..81c399c99d --- /dev/null +++ b/src/commands/moderation/slowmode.js @@ -0,0 +1 @@ +import { moderationCommand } from './factory.js'; export default moderationCommand('slowmode'); diff --git a/src/commands/moderation/timeout.js b/src/commands/moderation/timeout.js new file mode 100644 index 0000000000..004c7ec169 --- /dev/null +++ b/src/commands/moderation/timeout.js @@ -0,0 +1 @@ +import { moderationCommand } from './factory.js'; export default moderationCommand('timeout'); diff --git a/src/commands/moderation/unban.js b/src/commands/moderation/unban.js new file mode 100644 index 0000000000..9aec51a37a --- /dev/null +++ b/src/commands/moderation/unban.js @@ -0,0 +1,18 @@ +import { MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { requireAccess, AccessLevel } from '../../modules/community/permissions.js'; + +export default { + data: new SlashCommandBuilder().setName('unban').setDescription('Unban a user').setDMPermission(false) + .addStringOption(o => o.setName('user_id').setDescription('Discord user ID').setRequired(true)) + .addStringOption(o => o.setName('reason').setDescription('Reason').setRequired(true).setMaxLength(500)), + async execute(i, client) { + if (!await requireAccess(i, client, AccessLevel.ADMIN)) return; + if (!i.guild.members.me.permissions.has(PermissionFlagsBits.BanMembers)) return i.reply({ content: 'לבוט חסרה הרשאת חסימת חברים.', flags: MessageFlags.Ephemeral }); + const id = i.options.getString('user_id'); + if (!/^\d{17,20}$/.test(id)) return i.reply({ content: 'מזהה המשתמש אינו תקין.', flags: MessageFlags.Ephemeral }); + const ban = await i.guild.bans.fetch(id).catch(() => null); + if (!ban) return i.reply({ content: 'המשתמש הזה אינו חסום בשרת.', flags: MessageFlags.Ephemeral }); + await i.guild.members.unban(id, i.options.getString('reason')); + await i.reply({ content: `${ban.user} הוסר מרשימת החסומים.`, flags: MessageFlags.Ephemeral }); + } +}; diff --git a/src/commands/moderation/unlock.js b/src/commands/moderation/unlock.js new file mode 100644 index 0000000000..2d3766bdff --- /dev/null +++ b/src/commands/moderation/unlock.js @@ -0,0 +1 @@ +import { moderationCommand } from './factory.js'; export default moderationCommand('unlock'); diff --git a/src/commands/moderation/warn.js b/src/commands/moderation/warn.js new file mode 100644 index 0000000000..71e1a45d74 --- /dev/null +++ b/src/commands/moderation/warn.js @@ -0,0 +1 @@ +import { moderationCommand } from './factory.js'; export default moderationCommand('warn'); diff --git a/src/commands/moderation/warnings.js b/src/commands/moderation/warnings.js new file mode 100644 index 0000000000..1cebfcb3c5 --- /dev/null +++ b/src/commands/moderation/warnings.js @@ -0,0 +1 @@ +import { moderationCommand } from './factory.js'; export default moderationCommand('warnings'); diff --git a/src/commands/owner/config.js b/src/commands/owner/config.js new file mode 100644 index 0000000000..11d329edd0 --- /dev/null +++ b/src/commands/owner/config.js @@ -0,0 +1 @@ +import { ownerCommand } from './factory.js'; export default ownerCommand('config'); diff --git a/src/commands/owner/debug.js b/src/commands/owner/debug.js new file mode 100644 index 0000000000..f50b92faed --- /dev/null +++ b/src/commands/owner/debug.js @@ -0,0 +1 @@ +import { ownerCommand } from './factory.js'; export default ownerCommand('debug'); diff --git a/src/commands/owner/factory.js b/src/commands/owner/factory.js new file mode 100644 index 0000000000..e75a06a0c1 --- /dev/null +++ b/src/commands/owner/factory.js @@ -0,0 +1,33 @@ +import { ChannelType, MessageFlags, SlashCommandBuilder } from 'discord.js'; +import { createEmbed } from '../../utils/embeds.js'; +import { getConfig, updateConfig } from '../../modules/community/store.js'; +import { requireAccess, AccessLevel } from '../../modules/community/permissions.js'; +import { reloadCommand } from '../../handlers/commandLoader.js'; + +export function ownerCommand(name) { + const data = new SlashCommandBuilder().setName(name).setDescription(`EditIL ${name}`).setDMPermission(false); + if (name === 'reload') data.addStringOption(o => o.setName('command').setDescription('Command name').setRequired(true)); + if (name === 'config') data.addStringOption(o => o.setName('command').setDescription('Command permission key')).addIntegerOption(o => o.setName('level').setDescription('Access level 0-5').setMinValue(0).setMaxValue(5)).addChannelOption(o => o.setName('suggestions').setDescription('Suggestions channel').addChannelTypes(ChannelType.GuildText)).addChannelOption(o => o.setName('reports').setDescription('Reports channel').addChannelTypes(ChannelType.GuildText)).addChannelOption(o => o.setName('feedback').setDescription('Feedback channel').addChannelTypes(ChannelType.GuildText)); + return { data, async execute(i, client) { + if (!await requireAccess(i, client, AccessLevel.OWNER)) return; + if (name === 'reload') { + const result = await reloadCommand(client, i.options.getString('command')); + return i.reply({ content: result.success ? 'הפקודה נטענה מחדש בהצלחה.' : 'טעינת הפקודה נכשלה. השגיאה נרשמה לבדיקה.', flags: MessageFlags.Ephemeral }); + } + const config = await getConfig(client, i.guildId); + if (name === 'config') { + const channels = { ...config.channels }; const commandPermissions = { ...config.commandPermissions }; + for (const key of ['suggestions', 'reports', 'feedback']) { const channel = i.options.getChannel(key); if (channel) channels[key] = channel.id; } + const command = i.options.getString('command'); const level = i.options.getInteger('level'); + if (command && level !== null) commandPermissions[command] = level; + await updateConfig(client, i.guildId, { channels, commandPermissions }); + return i.reply({ content: 'הגדרות השרת עודכנו.', flags: MessageFlags.Ephemeral }); + } + if (name === 'debug') { + const status = client.db.getStatus(); + const failed = client.failedCommands?.size || 0; + return i.reply({ embeds: [createEmbed({ title: 'אבחון הבוט', description: `Discord: **${client.isReady() ? 'מחובר' : 'לא מחובר'}**\nמסד נתונים: **${status.connectionType}**\nמצב זמני: **${status.isDegraded ? 'כן' : 'לא'}**\nפקודות: **${client.commands.size}**\nמודולים שנכשלו: **${failed}**\nPing: **${Math.round(client.ws.ping)}ms**`, color: status.isDegraded || failed ? 'warning' : 'success' })], flags: MessageFlags.Ephemeral }); + } + return i.reply({ embeds: [createEmbed({ title: 'הגדרת EditIL Assistant', description: 'השתמשו בפקודות ההגדרה הייעודיות לערוצים, תפקידים ומודולים. רק בעל השרת יכול לבצע הגדרה זו.', color: 'primary' })], flags: MessageFlags.Ephemeral }); + } }; +} diff --git a/src/commands/owner/reload.js b/src/commands/owner/reload.js new file mode 100644 index 0000000000..98e063d8d8 --- /dev/null +++ b/src/commands/owner/reload.js @@ -0,0 +1 @@ +import { ownerCommand } from './factory.js'; export default ownerCommand('reload'); diff --git a/src/commands/owner/reply.js b/src/commands/owner/reply.js new file mode 100644 index 0000000000..b87dea2e01 --- /dev/null +++ b/src/commands/owner/reply.js @@ -0,0 +1,14 @@ +import { MessageFlags, SlashCommandBuilder } from 'discord.js'; +import { OWNER_INBOX_USER_ID, replyToOwnerInboxCase } from '../../services/ownerInboxService.js'; + +export default { + data: new SlashCommandBuilder().setName('reply').setDescription('תגובה פרטית למקרה של הצעה או דיווח') + .addStringOption(option => option.setName('case_id').setDescription('מזהה המקרה, לדוגמה SUG-000001').setRequired(true).setMinLength(10).setMaxLength(10)) + .addStringOption(option => option.setName('message').setDescription('התגובה שתישלח למשתמש').setRequired(true).setMaxLength(1800)), + async execute(interaction, client) { + if (interaction.user.id !== OWNER_INBOX_USER_ID) return interaction.reply({ content: 'אין לך הרשאה להשתמש בפקודה זו.', flags: MessageFlags.Ephemeral }); + const result = await replyToOwnerInboxCase(client, interaction.user.id, + interaction.options.getString('case_id', true), interaction.options.getString('message', true)); + return interaction.reply({ content: result.message, flags: MessageFlags.Ephemeral }); + } +}; diff --git a/src/commands/owner/setup.js b/src/commands/owner/setup.js new file mode 100644 index 0000000000..cdb21cc4ba --- /dev/null +++ b/src/commands/owner/setup.js @@ -0,0 +1 @@ +import { ownerCommand } from './factory.js'; export default ownerCommand('setup'); diff --git a/src/commands/owner/sync.js b/src/commands/owner/sync.js new file mode 100644 index 0000000000..c1ef80a7db --- /dev/null +++ b/src/commands/owner/sync.js @@ -0,0 +1,19 @@ +import { MessageFlags, SlashCommandBuilder } from 'discord.js'; +import { registerCommands } from '../../handlers/commandLoader.js'; +import { requireAccess, AccessLevel } from '../../modules/community/permissions.js'; + +export default { + data: new SlashCommandBuilder().setName('sync').setDescription('Sync application commands').setDMPermission(false) + .addBooleanOption(o => o.setName('global').setDescription('Sync globally; defaults to this test server')), + async execute(interaction, client) { + if (!await requireAccess(interaction, client, AccessLevel.OWNER)) return; + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + const global = interaction.options.getBoolean('global') || false; + try { + await registerCommands(client, global ? undefined : interaction.guildId); + await interaction.editReply(`סונכרנו **${client.commands.size}** פקודות ${global ? 'באופן גלובלי' : 'לשרת הנוכחי'}.`); + } catch { + await interaction.editReply('סנכרון הפקודות נכשל. השגיאה הטכנית נרשמה לבדיקה.'); + } + } +}; diff --git a/src/commands/roles/role.js b/src/commands/roles/role.js new file mode 100644 index 0000000000..6601c5b938 --- /dev/null +++ b/src/commands/roles/role.js @@ -0,0 +1,43 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { createEmbed } from '../../utils/embeds.js'; +import { requireAccess, AccessLevel } from '../../modules/community/permissions.js'; +import { getConfig } from '../../modules/community/store.js'; +import { roleTemplates, validateRoleAction } from '../../services/roleSystemService.js'; +import { logEvent, EVENT_TYPES } from '../../services/loggingService.js'; + +const templates = Object.keys(roleTemplates).map(value => ({ name: value, value })); +const data = new SlashCommandBuilder().setName('role').setDescription('ניהול תפקידים בשרת').setDMPermission(false) + .addSubcommand(s => s.setName('add').setDescription('הוספת תפקיד לחבר').addUserOption(o=>o.setName('member').setDescription('חבר').setRequired(true)).addRoleOption(o=>o.setName('role').setDescription('תפקיד').setRequired(true)).addStringOption(o=>o.setName('reason').setDescription('סיבה').setMaxLength(500))) + .addSubcommand(s => s.setName('remove').setDescription('הסרת תפקיד מחבר').addUserOption(o=>o.setName('member').setDescription('חבר').setRequired(true)).addRoleOption(o=>o.setName('role').setDescription('תפקיד').setRequired(true)).addStringOption(o=>o.setName('reason').setDescription('סיבה').setMaxLength(500))) + .addSubcommand(s => s.setName('info').setDescription('מידע על תפקיד').addRoleOption(o=>o.setName('role').setDescription('תפקיד').setRequired(true))) + .addSubcommand(s => s.setName('create').setDescription('יצירת תפקיד').addStringOption(o=>o.setName('name').setDescription('שם').setRequired(true).setMaxLength(100)).addStringOption(o=>o.setName('color').setDescription('צבע HEX, לדוגמה #5865F2').setRequired(true)).addBooleanOption(o=>o.setName('hoist').setDescription('הצגה בנפרד').setRequired(true)).addBooleanOption(o=>o.setName('mentionable').setDescription('ניתן לתיוג').setRequired(true)).addStringOption(o=>o.setName('permission_template').setDescription('תבנית הרשאות').setRequired(true).addChoices(...templates))) + .addSubcommand(s => s.setName('delete').setDescription('מחיקת תפקיד').addRoleOption(o=>o.setName('role').setDescription('תפקיד').setRequired(true))); + +export default { data, async execute(interaction, client) { + const sub = interaction.options.getSubcommand(); const role = interaction.options.getRole('role'); + if (sub === 'info') return interaction.reply({ embeds: [createEmbed({ title: `מידע על ${role.name}`, color: role.hexColor === '#000000' ? 'primary' : role.color, fields: [ + {name:'מזהה',value:`\`${role.id}\``,inline:true},{name:'צבע',value:role.hexColor,inline:true},{name:'מיקום',value:String(role.position),inline:true},{name:'חברים',value:String(role.members.size),inline:true},{name:'ניתן לתיוג',value:role.mentionable?'כן':'לא',inline:true},{name:'מוצג בנפרד',value:role.hoist?'כן':'לא',inline:true},{name:'מנוהל',value:role.managed?'כן':'לא',inline:true},{name:'נוצר',value:``},{name:'הרשאות',value:role.permissions.toArray().join(', ').slice(0,1024)||'ללא'} + ] })], flags: MessageFlags.Ephemeral }); + const required = ['add','remove'].includes(sub) ? AccessLevel.MODERATOR : AccessLevel.ADMIN; + if (!await requireAccess(interaction, client, required, `role.${sub}`)) return; + if (!interaction.member.permissions.has(PermissionFlagsBits.ManageRoles) && interaction.user.id !== interaction.guild.ownerId) return interaction.reply({ content: 'אין לך הרשאת ניהול תפקידים.', flags: MessageFlags.Ephemeral }); + if (['add','remove'].includes(sub)) { + const member = interaction.options.getMember('member'); const error = await validateRoleAction(interaction.guild, interaction.member, role); + if (error) return interaction.reply({ content: error, flags: MessageFlags.Ephemeral }); + const config = await getConfig(client, interaction.guildId); + if (member.id === interaction.guild.ownerId || (member.id === interaction.user.id && Object.values(config.staffRoles).includes(role.id))) return interaction.reply({ content: 'לא ניתן לשנות הרשאות צוות של עצמך או של בעל השרת.', flags: MessageFlags.Ephemeral }); + const reason = interaction.options.getString('reason') || `בוצע על ידי ${interaction.user.tag}`; + await member.roles[sub === 'add' ? 'add' : 'remove'](role, reason); + await logEvent({ client, guildId: interaction.guildId, eventType: EVENT_TYPES.ROLE_UPDATE, data: { title: sub === 'add'?'תפקיד נוסף':'תפקיד הוסר', description:`${role} ${sub==='add'?'נוסף אל':'הוסר מאת'} ${member}.`, fields:[{name:'צוות',value:`${interaction.user}`},{name:'סיבה',value:reason}] } }); + return interaction.reply({ content: `${role} ${sub==='add'?'נוסף אל':'הוסר מאת'} ${member} בהצלחה.`, flags: MessageFlags.Ephemeral }); + } + if (sub === 'create') { + const color = interaction.options.getString('color'); if (!/^#[0-9a-f]{6}$/i.test(color)) return interaction.reply({ content:'הצבע אינו תקין. יש להזין צבע HEX מלא.', flags:MessageFlags.Ephemeral }); + const pending={action:'create',name:interaction.options.getString('name'),color,hoist:interaction.options.getBoolean('hoist'),mentionable:interaction.options.getBoolean('mentionable'),template:interaction.options.getString('permission_template'),actorId:interaction.user.id}; const id=String(await client.db.increment(`community:${interaction.guildId}:sequence:roleaction`)); await client.db.set(`community:${interaction.guildId}:roleaction:${id}`,pending,600); + return interaction.reply({embeds:[createEmbed({title:'אישור יצירת תפקיד',description:`שם: **${pending.name}**\nצבע: **${color}**\nתבנית: **${pending.template}**`,color:'warning'})],components:[new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId(`role_confirm:${id}`).setLabel('אישור יצירה').setStyle(ButtonStyle.Success),new ButtonBuilder().setCustomId(`role_cancel:${id}`).setLabel('ביטול').setStyle(ButtonStyle.Secondary))],flags:MessageFlags.Ephemeral}); + } + if (interaction.user.id !== interaction.guild.ownerId) return interaction.reply({content:'רק בעל השרת יכול למחוק תפקידים.',flags:MessageFlags.Ephemeral}); + const error=await validateRoleAction(interaction.guild,interaction.member,role); if(error)return interaction.reply({content:error,flags:MessageFlags.Ephemeral}); + const id=String(await client.db.increment(`community:${interaction.guildId}:sequence:roleaction`)); await client.db.set(`community:${interaction.guildId}:roleaction:${id}`,{action:'delete',roleId:role.id,actorId:interaction.user.id},600); + return interaction.reply({embeds:[createEmbed({title:'אישור מחיקת תפקיד',description:`האם למחוק את ${role}? התפקיד נמצא אצל **${role.members.size}** חברים.`,color:'error'})],components:[new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId(`role_confirm:${id}`).setLabel('מחיקה').setStyle(ButtonStyle.Danger),new ButtonBuilder().setCustomId(`role_cancel:${id}`).setLabel('ביטול').setStyle(ButtonStyle.Secondary))],flags:MessageFlags.Ephemeral}); +} }; diff --git a/src/commands/roles/roles.js b/src/commands/roles/roles.js new file mode 100644 index 0000000000..7187f4a9ad --- /dev/null +++ b/src/commands/roles/roles.js @@ -0,0 +1,23 @@ +import { ActionRowBuilder, MessageFlags, SlashCommandBuilder, StringSelectMenuBuilder } from 'discord.js'; +import { createEmbed } from '../../utils/embeds.js'; +import { getConfig } from '../../modules/community/store.js'; +import { requireAccess, AccessLevel } from '../../modules/community/permissions.js'; +import { PANEL_CATEGORIES, validateRoleAction } from '../../services/roleSystemService.js'; + +export default { data: new SlashCommandBuilder().setName('roles').setDescription('הצגת תפקידים זמינים לבחירה עצמית').setDMPermission(false), async execute(interaction, client) { + if (!await requireAccess(interaction, client, AccessLevel.EVERYONE)) return; + const config = await getConfig(client, interaction.guildId); const components = []; const fields = []; + for (const [categoryId, category] of Object.entries(config.roles.categories || {})) { + if (category.enabled === false) continue; + const roles = []; + for (const id of category.roleIds || []) { + const role = interaction.guild.roles.cache.get(id); + if (role && !await validateRoleAction(interaction.guild, interaction.member, role, { selfAssignable: true })) roles.push(role); + } + if (!roles.length || components.length >= 5) continue; + fields.push({ name: PANEL_CATEGORIES[categoryId] || category.name || categoryId, value: roles.map(String).join(', ') }); + components.push(new ActionRowBuilder().addComponents(new StringSelectMenuBuilder().setCustomId(`self_roles:${interaction.user.id}:${categoryId}`).setPlaceholder(PANEL_CATEGORIES[categoryId] || category.name || 'בחרו תפקידים').setMinValues(0).setMaxValues(Math.min(category.maxSelections || roles.length, roles.length)).addOptions(roles.map(role => ({ label: role.name, value: role.id, default: interaction.member.roles.cache.has(role.id) }))))); + } + if (!components.length) return interaction.reply({ content: 'לא הוגדרו תפקידים זמינים לבחירה עצמית.', flags: MessageFlags.Ephemeral }); + return interaction.reply({ embeds: [createEmbed({ title: '🎭 בחירת תפקידים', description: 'בחרו או הסירו תפקידים לפי הקטגוריות שהוגדרו.', fields, color: 'primary' })], components, flags: MessageFlags.Ephemeral }); +} }; diff --git a/src/commands/tickets/add.js b/src/commands/tickets/add.js new file mode 100644 index 0000000000..d8612cad67 --- /dev/null +++ b/src/commands/tickets/add.js @@ -0,0 +1 @@ +import { ticketCommand } from './factory.js'; export default ticketCommand('add'); diff --git a/src/commands/tickets/claim.js b/src/commands/tickets/claim.js new file mode 100644 index 0000000000..d8ffe55815 --- /dev/null +++ b/src/commands/tickets/claim.js @@ -0,0 +1 @@ +import {ticketCommand} from './factory.js';export default ticketCommand('claim'); diff --git a/src/commands/tickets/close.js b/src/commands/tickets/close.js new file mode 100644 index 0000000000..3ed5a6471d --- /dev/null +++ b/src/commands/tickets/close.js @@ -0,0 +1 @@ +import { ticketCommand } from './factory.js'; export default ticketCommand('close'); diff --git a/src/commands/tickets/factory.js b/src/commands/tickets/factory.js new file mode 100644 index 0000000000..35ab344ac4 --- /dev/null +++ b/src/commands/tickets/factory.js @@ -0,0 +1,16 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { createEmbed } from '../../utils/embeds.js'; +import { getConfig } from '../../modules/community/store.js'; +import { buildTranscript, getTicket, saveTicket, safeChannelName, ticketAccess, ticketMessagePayload, TICKET_PRIORITIES, TICKET_STATUSES } from '../../services/ticketSystemService.js'; +import { logEvent, EVENT_TYPES } from '../../services/loggingService.js'; + +const descriptions={add:'הוספת חבר לכרטיס',remove:'הסרת חבר מהכרטיס',close:'סגירת הכרטיס',transcript:'יצירת תמלול פרטי',claim:'לקיחת אחריות על הכרטיס',unclaim:'שחרור הכרטיס',rename:'שינוי שם הכרטיס',ticketinfo:'מידע על הכרטיס',ticketstatus:'שינוי סטטוס הכרטיס',ticketpriority:'שינוי עדיפות הכרטיס'}; +async function updateOpening(i,ticket){const message=ticket.openingMessageId?await i.channel.messages.fetch(ticket.openingMessageId).catch(()=>null):null;if(message)await message.edit(ticketMessagePayload(ticket,{disabled:['closing','closed'].includes(ticket.status)}));} +export function ticketCommand(name){const data=new SlashCommandBuilder().setName(name).setDescription(descriptions[name]).setDMPermission(false);if(['add','remove'].includes(name))data.addUserOption(o=>o.setName('member').setDescription('חבר').setRequired(true));if(name==='close')data.addStringOption(o=>o.setName('reason').setDescription('סיבת הסגירה').setMaxLength(500));if(name==='rename')data.addStringOption(o=>o.setName('name').setDescription('שם חדש').setRequired(true).setMaxLength(70));if(name==='ticketstatus')data.addStringOption(o=>o.setName('status').setDescription('סטטוס').setRequired(true).addChoices(...Object.entries(TICKET_STATUSES).filter(([v])=>!['closed','closing'].includes(v)).map(([value,label])=>({name:label,value}))));if(name==='ticketpriority')data.addStringOption(o=>o.setName('priority').setDescription('עדיפות').setRequired(true).addChoices(...Object.entries(TICKET_PRIORITIES).map(([value,label])=>({name:label,value}))));return{data,async execute(i,client){const ticket=await getTicket(client,i.guildId,i.channelId);if(!ticket)return i.reply({content:'הפקודה הזאת זמינה רק בתוך כרטיס תמיכה.',flags:MessageFlags.Ephemeral});const config=await getConfig(client,i.guildId),staff=await ticketAccess(i,client,ticket,{staffOnly:true}),allowed=await ticketAccess(i,client,ticket);if(!allowed)return i.reply({content:'אין לך הרשאה לנהל את הכרטיס הזה.',flags:MessageFlags.Ephemeral}); +if(name==='ticketinfo'){const messages=await i.channel.messages.fetch({limit:100}),access=i.channel.permissionOverwrites.cache.filter(o=>o.type===1&&o.allow.has(PermissionFlagsBits.ViewChannel)).map(o=>`<@${o.id}>`);return i.reply({embeds:[createEmbed({title:`מידע על כרטיס #${ticket.id}`,fields:[{name:'סוג',value:ticket.type,inline:true},{name:'יוצר',value:`<@${ticket.creatorId}>`,inline:true},{name:'סטטוס',value:TICKET_STATUSES[ticket.status]||ticket.status,inline:true},{name:'מטפל',value:ticket.assignedStaffId?`<@${ticket.assignedStaffId}>`:'לא הוקצה',inline:true},{name:'נוצר',value:``},{name:'בעלי גישה',value:access.join(', ')||'יוצר וצוות'},{name:'הודעות אחרונות',value:String(messages.size),inline:true},{name:'פעילות אחרונה',value:``,inline:true},...(ticket.closeReason?[{name:'סיבת סגירה',value:ticket.closeReason}]:[])]})],flags:MessageFlags.Ephemeral});} +if(name==='transcript'){const file=await buildTranscript(i.channel);ticket.transcriptLocation=`requested:${i.user.id}:${Date.now()}`;await saveTicket(client,ticket);await logEvent({client,guildId:i.guildId,eventType:EVENT_TYPES.TICKET_TRANSCRIPT,data:{title:`תמלול כרטיס #${ticket.id}`,description:`נוצר על ידי ${i.user}.`}});return i.reply({content:'התמלול נוצר ונשלח אליך באופן פרטי.',files:[file],flags:MessageFlags.Ephemeral});} +if(name==='close'){if(i.user.id===ticket.creatorId&&!config.tickets.creatorCanClose&&!staff)return i.reply({content:'אין לך הרשאה לסגור את הכרטיס הזה.',flags:MessageFlags.Ephemeral});const reason=i.options.getString('reason')||'לא צוינה סיבה';return i.reply({embeds:[createEmbed({title:'אישור סגירת כרטיס',description:`האם לסגור את כרטיס #${ticket.id}?\nסיבה: ${reason}`,color:'warning'})],components:[new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId(`ticket_close_confirm:${Buffer.from(reason).toString('base64url')}`).setLabel('אישור סגירה').setStyle(ButtonStyle.Danger),new ButtonBuilder().setCustomId('ticket_close_cancel').setLabel('ביטול').setStyle(ButtonStyle.Secondary))],flags:MessageFlags.Ephemeral});} +if(['claim','unclaim','ticketstatus','ticketpriority'].includes(name)&&!staff)return i.reply({content:'אין לך הרשאה לנהל את הכרטיס הזה.',flags:MessageFlags.Ephemeral});if(name==='claim'){if(ticket.assignedStaffId&&ticket.assignedStaffId!==i.user.id&&!i.member.permissions.has(PermissionFlagsBits.Administrator))return i.reply({content:'הכרטיס כבר נלקח על ידי איש צוות אחר.',flags:MessageFlags.Ephemeral});ticket.assignedStaffId=i.user.id;ticket.status='claimed';}if(name==='unclaim'){if(ticket.assignedStaffId!==i.user.id&&!i.member.permissions.has(PermissionFlagsBits.Administrator)&&i.user.id!==i.guild.ownerId)return i.reply({content:'רק המטפל הנוכחי או מנהל יכולים לשחרר את הכרטיס.',flags:MessageFlags.Ephemeral});ticket.assignedStaffId=null;ticket.status='open';}if(name==='ticketstatus')ticket.status=i.options.getString('status');if(name==='ticketpriority')ticket.priority=i.options.getString('priority'); +if(name==='rename'){if(!staff&&!config.tickets.creatorCanRename)return i.reply({content:'אין לך הרשאה לשנות את שם הכרטיס.',flags:MessageFlags.Ephemeral});const old=i.channel.name,newName=safeChannelName(i.options.getString('name'),ticket.id,'ticket');if(i.guild.channels.cache.some(ch=>ch.id!==i.channelId&&ch.name===newName))return i.reply({content:'כבר קיים ערוץ בשם הזה.',flags:MessageFlags.Ephemeral});await i.channel.setName(newName,`Ticket rename by ${i.user.tag}`);ticket.channelName=newName;ticket.lastRename={old,new:newName,by:i.user.id};} +if(['add','remove'].includes(name)){if(!staff&&!(name==='add'&&config.tickets.creatorCanAdd&&i.user.id===ticket.creatorId))return i.reply({content:'אין לך הרשאה לנהל משתמשים בכרטיס.',flags:MessageFlags.Ephemeral});const member=i.options.getMember('member');if(!member||member.user.bot&&!i.member.permissions.has(PermissionFlagsBits.Administrator))return i.reply({content:'לא ניתן להוסיף את המשתמש הזה לכרטיס.',flags:MessageFlags.Ephemeral});if(name==='remove'&&(member.id===ticket.creatorId||member.id===i.guild.members.me.id))return i.reply({content:'לא ניתן להסיר את יוצר הכרטיס או את הבוט.',flags:MessageFlags.Ephemeral});if(name==='add'){if(ticket.addedMemberIds.includes(member.id))return i.reply({content:'המשתמש כבר נוסף לכרטיס.',flags:MessageFlags.Ephemeral});if(ticket.addedMemberIds.length>=config.tickets.maxAddedUsers)return i.reply({content:'הגעתם למספר המשתמשים המרבי בכרטיס.',flags:MessageFlags.Ephemeral});await i.channel.permissionOverwrites.edit(member,{ViewChannel:true,SendMessages:true,AttachFiles:true,EmbedLinks:true,ReadMessageHistory:true,AddReactions:true});ticket.addedMemberIds.push(member.id);}else{await i.channel.permissionOverwrites.delete(member.id);ticket.addedMemberIds=ticket.addedMemberIds.filter(id=>id!==member.id);}} +await saveTicket(client,ticket);await updateOpening(i,ticket);await logEvent({client,guildId:i.guildId,eventType:EVENT_TYPES.TICKET_CLAIM,data:{title:`פעולת כרטיס #${ticket.id}`,description:`${name} בוצע על ידי ${i.user}.`}});return i.reply({content:`הפעולה בוצעה בהצלחה בכרטיס #${ticket.id}.`,flags:MessageFlags.Ephemeral});}}} diff --git a/src/commands/tickets/remove.js b/src/commands/tickets/remove.js new file mode 100644 index 0000000000..11bae13d4e --- /dev/null +++ b/src/commands/tickets/remove.js @@ -0,0 +1 @@ +import { ticketCommand } from './factory.js'; export default ticketCommand('remove'); diff --git a/src/commands/tickets/rename.js b/src/commands/tickets/rename.js new file mode 100644 index 0000000000..b8437a8403 --- /dev/null +++ b/src/commands/tickets/rename.js @@ -0,0 +1 @@ +import {ticketCommand} from './factory.js';export default ticketCommand('rename'); diff --git a/src/commands/tickets/ticketinfo.js b/src/commands/tickets/ticketinfo.js new file mode 100644 index 0000000000..cdeaf34f10 --- /dev/null +++ b/src/commands/tickets/ticketinfo.js @@ -0,0 +1 @@ +import {ticketCommand} from './factory.js';export default ticketCommand('ticketinfo'); diff --git a/src/commands/tickets/ticketpriority.js b/src/commands/tickets/ticketpriority.js new file mode 100644 index 0000000000..2228e90a31 --- /dev/null +++ b/src/commands/tickets/ticketpriority.js @@ -0,0 +1 @@ +import {ticketCommand} from './factory.js';export default ticketCommand('ticketpriority'); diff --git a/src/commands/tickets/ticketstatus.js b/src/commands/tickets/ticketstatus.js new file mode 100644 index 0000000000..739269ea3a --- /dev/null +++ b/src/commands/tickets/ticketstatus.js @@ -0,0 +1 @@ +import {ticketCommand} from './factory.js';export default ticketCommand('ticketstatus'); diff --git a/src/commands/tickets/transcript.js b/src/commands/tickets/transcript.js new file mode 100644 index 0000000000..333d81ed25 --- /dev/null +++ b/src/commands/tickets/transcript.js @@ -0,0 +1 @@ +import { ticketCommand } from './factory.js'; export default ticketCommand('transcript'); diff --git a/src/commands/tickets/unclaim.js b/src/commands/tickets/unclaim.js new file mode 100644 index 0000000000..af35818b56 --- /dev/null +++ b/src/commands/tickets/unclaim.js @@ -0,0 +1 @@ +import {ticketCommand} from './factory.js';export default ticketCommand('unclaim'); diff --git a/src/commands/utility/announce.js b/src/commands/utility/announce.js new file mode 100644 index 0000000000..5ef21d3bc6 --- /dev/null +++ b/src/commands/utility/announce.js @@ -0,0 +1 @@ +import { utilityCommand } from './factory.js'; export default utilityCommand('announce'); diff --git a/src/commands/utility/embed.js b/src/commands/utility/embed.js new file mode 100644 index 0000000000..1e0eab7510 --- /dev/null +++ b/src/commands/utility/embed.js @@ -0,0 +1 @@ +import { utilityCommand } from './factory.js'; export default utilityCommand('embed'); diff --git a/src/commands/utility/factory.js b/src/commands/utility/factory.js new file mode 100644 index 0000000000..12ad3b9424 --- /dev/null +++ b/src/commands/utility/factory.js @@ -0,0 +1,7 @@ +import { SlashCommandBuilder, ChannelType, MessageFlags } from 'discord.js'; +import { createEmbed } from '../../utils/embeds.js'; +import { requireAccess, AccessLevel } from '../../modules/community/permissions.js'; +export function utilityCommand(name) { + const data=new SlashCommandBuilder().setName(name).setDescription(`EditIL ${name}`).setDMPermission(false).addStringOption(o=>o.setName('title').setDescription('Title').setRequired(true).setMaxLength(256)).addStringOption(o=>o.setName('content').setDescription('Content').setRequired(true).setMaxLength(4000)).addChannelOption(o=>o.setName('channel').setDescription('Target channel').addChannelTypes(ChannelType.GuildText)); + return {data,async execute(i,client){if(!await requireAccess(i,client,AccessLevel.ADMIN))return;const channel=i.options.getChannel('channel')||i.channel;await channel.send({embeds:[createEmbed({title:i.options.getString('title'),description:i.options.getString('content'),color:name==='announce'?'warning':'primary'})]});await i.reply({content:`ההודעה פורסמה ב-${channel}.`,flags:MessageFlags.Ephemeral});}}; +} diff --git a/src/config/application.js b/src/config/application.js index d4d741bfc1..08fbcf7b9e 100644 --- a/src/config/application.js +++ b/src/config/application.js @@ -1,7 +1,6 @@ import { fileURLToPath } from "url"; import path from "path"; import botConfig, { validateConfig } from "./bot.js"; -import { shopConfig as shop } from "./shop/index.js"; import { pgConfig } from "./postgres.js"; const __filename = fileURLToPath(import.meta.url); @@ -29,11 +28,6 @@ const appConfig = { token: process.env.DISCORD_TOKEN || process.env.TOKEN, clientId: process.env.CLIENT_ID, guildId: process.env.GUILD_ID, - - shop: { - ...botConfig.shop, - ...shop, - }, }, // PostgreSQL configuration - Primary production database @@ -75,7 +69,6 @@ const appConfig = { }, }, - shop, @@ -84,7 +77,6 @@ const appConfig = { features: { - economy: true, leveling: true, moderation: true, logging: true, diff --git a/src/config/bot.js b/src/config/bot.js index 36e588cd42..fe35556587 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -1,467 +1,5 @@ import { logger } from '../utils/logger.js'; - - -export const botConfig = { - // ========================= - // BOT PRESENCE (what users see under the bot name) - // ========================= - // `status` options: - // - "online" = green dot - // - "idle" = yellow moon - // - "dnd" = red do-not-disturb - // - "invisible" = appears offline - presence: { - // Current online state shown on Discord. - status: "online", - - // Activity lines shown under the bot name. - // `type` number mapping from Discord: - // 0 = Playing - // 1 = Streaming - // 2 = Listening - // 3 = Watching - // 4 = Custom - // 5 = Competing - activities: [ - { - // Text users will see (example: "Playing /help | Titan Bot"). - name: "Made with ❤️", - // Activity type number (0 = Playing). - type: 0, - }, - ], - }, - - // ========================= - // COMMAND BEHAVIOR - // ========================= - 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(",") || [], - - // Default wait time between command uses (in seconds). - defaultCooldown: 3, - - // If true, old commands are removed before re-registering. - deleteCommands: false, - - // Optional server ID used for testing slash commands quickly. - testGuildId: process.env.TEST_GUILD_ID, - }, - - // ========================= - // APPLICATIONS SYSTEM - // ========================= - 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 }, - ], - - // Embed colors by application status. - statusColors: { - pending: "#FFA500", - approved: "#00FF00", - denied: "#FF0000", - }, - - // How long users must wait before submitting another application (hours). - applicationCooldown: 24, - - // Auto-delete denied applications after this many days. - deleteDeniedAfter: 7, - - // Auto-delete approved applications after this many days. - deleteApprovedAfter: 30, - - // Role IDs allowed to manage applications. - managerRoles: [], // Will be populated from environment or database - }, - - // ========================= - // EMBED COLORS & BRANDING - // ========================= - // IMPORTANT: This is the SINGLE SOURCE OF TRUTH for all bot colors - embeds: { - colors: { - // Main brand colors. - primary: "#336699", - secondary: "#2F3136", - - // Standard status colors for success/error/warning/info messages. - success: "#57F287", - error: "#ED4245", - warning: "#FEE75C", - info: "#3498DB", - - // Neutral utility colors. - light: "#FFFFFF", - dark: "#202225", - gray: "#99AAB5", - - // Discord-style palette shortcuts. - blurple: "#5865F2", - green: "#57F287", - yellow: "#FEE75C", - fuchsia: "#EB459E", - red: "#ED4245", - black: "#000000", - - // Feature-specific colors. - giveaway: { - active: "#57F287", - ended: "#ED4245", - }, - ticket: { - open: "#57F287", - claimed: "#FAA61A", - closed: "#ED4245", - pending: "#99AAB5", - }, - economy: "#F1C40F", - birthday: "#E91E63", - moderation: "#9B59B6", - - // Ticket priority color mapping. - priority: { - none: "#95A5A6", - low: "#3498db", - medium: "#2ecc71", - high: "#f1c40f", - urgent: "#e74c3c", - }, - }, - footer: { - // Default footer text used in bot embeds. - text: "Titan Bot", - // Footer icon URL (null = no icon). - icon: null, - }, - // Default thumbnail URL for embeds (null = no thumbnail). - thumbnail: null, - author: { - // Optional default embed author block. - name: null, - icon: null, - url: null, - }, - }, - - // ========================= - // ECONOMY SETTINGS - // ========================= - economy: { - currency: { - // Currency display name. - name: "coins", - // Plural display name. - namePlural: "coins", - // Currency symbol shown in balances. - symbol: "$", - }, - - // Starting balance for new users. - startingBalance: 0, - - // Maximum bank amount before upgrades (if upgrades are used). - baseBankCapacity: 100000, - - // Daily reward amount. - dailyAmount: 100, - - // Work command random payout range. - workMin: 10, - workMax: 100, - - // Beg command random payout range. - begMin: 5, - begMax: 50, - - // Chance to succeed when robbing (0.4 = 40%). - robSuccessRate: 0.4, - - // Jail time after failed rob (milliseconds). - // 3600000 = 1 hour. - robFailJailTime: 3600000, - }, - - // ========================= - // SHOP SETTINGS - // ========================= - // Add shop defaults here when needed. - shop: { - - }, - - // ========================= - // TICKET SYSTEM - // ========================= - tickets: { - // Category ID where new tickets are created (null = no forced category). - defaultCategory: null, - - // Role IDs allowed to manage/support tickets. - supportRoles: [], - - // Priority options users/staff can assign. - priorities: { - none: { - emoji: "⚪", - color: "#95A5A6", - label: "None", - }, - low: { - emoji: "🟢", - color: "#2ECC71", - label: "Low", - }, - medium: { - emoji: "🟡", - color: "#F1C40F", - label: "Medium", - }, - high: { - emoji: "🔴", - color: "#E74C3C", - label: "High", - }, - urgent: { - emoji: "🚨", - color: "#E91E63", - label: "Urgent", - }, - }, - - // Default priority for new tickets. - defaultPriority: "none", - - // Category ID where closed tickets are archived. - archiveCategory: null, - - // Channel ID where ticket logs are sent. - logChannel: null, - }, - - // ========================= - // GIVEAWAY SETTINGS - // ========================= - giveaways: { - // Default giveaway duration in milliseconds. - // 86400000 = 24 hours. - defaultDuration: 86400000, - - // Allowed winner count range. - minimumWinners: 1, - maximumWinners: 10, - - // Allowed giveaway duration range in milliseconds. - // 300000 = 5 minutes. - minimumDuration: 300000, - // 2592000000 = 30 days. - maximumDuration: 2592000000, - - // Role IDs allowed to host giveaways. - allowedRoles: [], - - // Role IDs that bypass giveaway restrictions. - bypassRoles: [], - }, - - // ========================= - // BIRTHDAY SETTINGS - // ========================= - birthday: { - // Role ID given to users on their birthday. - defaultRole: null, - - // Channel ID where birthday announcements are posted. - announcementChannel: null, - - // Timezone used to calculate birthday dates. - timezone: "UTC", - }, - - // ========================= - // VERIFICATION SETTINGS - // ========================= - verification: { - // Message shown when posting the verification panel. - defaultMessage: "Click the button below to verify yourself and gain access to the server!", - - // Text on the verification button. - defaultButtonText: "Verify", - - // Automatic verification behavior. - autoVerify: { - // How automatic verification decides who is auto-approved: - // - "none" = everyone is auto-verified immediately - // - "account_age" = account must be older than set days - // - "server_size" = auto-verify everyone only in smaller servers - defaultCriteria: "none", - - // Days used when `defaultCriteria` is `account_age`. - defaultAccountAgeDays: 7, - - // Member count threshold used when `defaultCriteria` is `server_size`. - // Example: 1000 means auto-verify if server has fewer than 1000 members. - serverSizeThreshold: 1000, - - // Allowed safety limits for account-age requirements. - // 1 = minimum day, 365 = maximum days. - minAccountAge: 1, - maxAccountAge: 365, - - // If true, user receives a DM after verification. - sendDMNotification: true, - - // 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" - } - }, - - // Minimum time between verification attempts (milliseconds). - // 5000 = 5 seconds. - verificationCooldown: 5000, - - // Maximum failed attempts allowed inside the time window below. - maxVerificationAttempts: 3, - - // Time window for counting attempts (milliseconds). - // 60000 = 1 minute. - attemptWindow: 60000, - - // In-memory safety limits (helps avoid unbounded memory growth). - maxCooldownEntries: 10000, - maxAttemptEntries: 10000, - // Cleanup frequency for cooldown/attempt maps (milliseconds). - // 300000 = 5 minutes. - cooldownCleanupInterval: 300000, - // Maximum metadata payload size for audit entries (bytes). - maxAuditMetadataBytes: 4096, - // Maximum number of audit entries kept in memory. - maxInMemoryAuditEntries: 1000, - // If true, log every verification action. - logAllVerifications: true, - // If true, preserve verification audit history. - keepAuditTrail: true, - }, - - // ========================= - // WELCOME / GOODBYE MESSAGES - // ========================= - welcome: { - // Welcome template posted when a user joins. - // Placeholders: {user}, {server}, {memberCount} - defaultWelcomeMessage: - "Welcome {user} to {server}! We now have {memberCount} members!", - // Goodbye template posted when a user leaves. - // Placeholders: {user}, {memberCount} - defaultGoodbyeMessage: - "{user} has left the server. We now have {memberCount} members.", - // Channel ID for welcome messages. - defaultWelcomeChannel: null, - // Channel ID for goodbye messages. - defaultGoodbyeChannel: null, - }, - - // ========================= - // COUNTER CHANNELS - // ========================= - counters: { - defaults: { - // Default naming/description templates for counter entries. - name: "{name} Counter", - description: "Server {name} counter", - // Channel type used for counters (typically "voice"). - type: "voice", - // Channel name format. `{count}` is replaced automatically. - channelName: "{name}-{count}", - }, - permissions: { - // Default denied permissions for the counter channel. - deny: ["VIEW_CHANNEL"], - // Default allowed permissions for the counter channel. - allow: ["VIEW_CHANNEL", "CONNECT", "SPEAK"], - }, - messages: { - // Default response messages for counter actions. - created: "✅ Created counter **{name}**", - deleted: "🗑️ Deleted counter **{name}**", - updated: "🔄 Updated counter **{name}**", - }, - types: { - // Built-in counter types and how each count is calculated. - members: { - name: "👥 Members", - description: "Total members in the server", - getCount: (guild) => guild.memberCount.toString(), - }, - bots: { - name: "🤖 Bots", - description: "Total bot accounts in the server", - getCount: (guild) => - guild.members.cache.filter((m) => m.user.bot).size.toString(), - }, - members_only: { - name: "👤 Humans", - description: "Total human members (non-bots)", - getCount: (guild) => - guild.members.cache.filter((m) => !m.user.bot).size.toString(), - }, - }, - }, - - // ========================= - // 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.", - missingPermissions: - "I am missing required permissions to perform this action.", - commandDisabled: "This command has been disabled.", - maintenanceMode: "The bot is currently in maintenance mode.", - }, - - // ========================= - // FEATURE TOGGLES - // ========================= - // Set any feature to `false` to disable it globally. - features: { - // Core systems. - economy: true, - leveling: true, - moderation: true, - logging: true, - welcome: true, - - // Community engagement systems. - tickets: true, - giveaways: true, - birthday: true, - counter: true, - - // Security and self-service systems. - verification: true, - reactionRoles: true, - joinToCreate: true, - - // Utility/quality-of-life modules. - voice: true, - search: true, - tools: true, - utility: true, - community: true, - fun: true, - }, -}; +import { botConfig } from './botConfig.js'; export function validateConfig(config) { diff --git a/src/config/botConfig.js b/src/config/botConfig.js new file mode 100644 index 0000000000..1bae6ff2c9 --- /dev/null +++ b/src/config/botConfig.js @@ -0,0 +1,409 @@ + + +export const botConfig = { + // ========================= + // BOT PRESENCE (what users see under the bot name) + // ========================= + // `status` options: + // - "online" = green dot + // - "idle" = yellow moon + // - "dnd" = red do-not-disturb + // - "invisible" = appears offline + presence: { + // Current online state shown on Discord. + status: "online", + + // Activity shown under the bot name. + // `type` number mapping from Discord: + // 0 = Playing + // 1 = Streaming + // 2 = Listening + // 3 = Watching + // 4 = Custom + // 5 = Competing + activities: [ + { name: "🎬 Helping EditIL creators", type: 0 }, + ], + }, + + // ========================= + // COMMAND BEHAVIOR + // ========================= + commands: { + // Bot owner user IDs (comma-separated in OWNER_IDS env var). + // Owners can access all admin commands. + owners: process.env.OWNER_IDS?.split(",").filter(Boolean) || [], + + // Bot admin user IDs (comma-separated in ADMIN_IDS env var). + // Admins can use /admin commands but are not full owners. + admins: process.env.ADMIN_IDS?.split(",").filter(Boolean) || [], + + // Default wait time between command uses (in seconds). + defaultCooldown: 3, + + // If true, old commands are removed before re-registering. + deleteCommands: false, + + // Optional server ID used for testing slash commands quickly. + testGuildId: process.env.TEST_GUILD_ID, + }, + + // ========================= + // APPLICATIONS SYSTEM + // ========================= + 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 }, + ], + + // Embed colors by application status. + statusColors: { + pending: "#FFA500", + approved: "#00FF00", + denied: "#FF0000", + }, + + // How long users must wait before submitting another application (hours). + applicationCooldown: 24, + + // Auto-delete denied applications after this many days. + deleteDeniedAfter: 7, + + // Auto-delete approved applications after this many days. + deleteApprovedAfter: 30, + + // Role IDs allowed to manage applications. + managerRoles: [], // Will be populated from environment or database + }, + + // ========================= + // EMBED COLORS & BRANDING + // ========================= + // IMPORTANT: This is the SINGLE SOURCE OF TRUTH for all bot colors + embeds: { + colors: { + // Main brand colors. + primary: "#5865F2", + secondary: "#2F3136", + + // Standard status colors for success/error/warning/info messages. + success: "#57F287", + error: "#ED4245", + warning: "#FEE75C", + info: "#3498DB", + + // Neutral utility colors. + light: "#FFFFFF", + dark: "#202225", + gray: "#99AAB5", + + // Discord-style palette shortcuts. + blurple: "#5865F2", + green: "#57F287", + yellow: "#FEE75C", + fuchsia: "#EB459E", + red: "#ED4245", + black: "#000000", + + // Feature-specific colors. + giveaway: { + active: "#57F287", + ended: "#ED4245", + }, + ticket: { + open: "#57F287", + claimed: "#FAA61A", + closed: "#ED4245", + pending: "#99AAB5", + }, + birthday: "#E91E63", + moderation: "#9B59B6", + + // Ticket priority color mapping. + priority: { + none: "#95A5A6", + low: "#3498db", + medium: "#2ecc71", + high: "#f1c40f", + urgent: "#e74c3c", + }, + }, + footer: { + // Default footer text used in bot embeds. + text: "itay100k bot", + // Footer icon URL (null = no icon). + icon: null, + }, + // Default thumbnail URL for embeds (null = no thumbnail). + thumbnail: null, + author: { + // Optional default embed author block. + name: null, + icon: null, + url: null, + }, + }, + + // ========================= + // TICKET SYSTEM + // ========================= + tickets: { + // Category ID where new tickets are created (null = no forced category). + defaultCategory: null, + + // Role IDs allowed to manage/support tickets. + supportRoles: [], + + // Priority options users/staff can assign. + priorities: { + none: { + emoji: "⚪", + color: "#95A5A6", + label: "None", + }, + low: { + emoji: "🟢", + color: "#2ECC71", + label: "Low", + }, + medium: { + emoji: "🟡", + color: "#F1C40F", + label: "Medium", + }, + high: { + emoji: "🔴", + color: "#E74C3C", + label: "High", + }, + urgent: { + emoji: "🚨", + color: "#E91E63", + label: "Urgent", + }, + }, + + // Default priority for new tickets. + defaultPriority: "none", + + // Category ID where closed tickets are archived. + archiveCategory: null, + + // Channel ID where ticket logs are sent. + logChannel: null, + }, + + // ========================= + // GIVEAWAY SETTINGS + // ========================= + giveaways: { + // Default giveaway duration in milliseconds. + // 86400000 = 24 hours. + defaultDuration: 86400000, + + // Allowed winner count range. + minimumWinners: 1, + maximumWinners: 10, + + // Allowed giveaway duration range in milliseconds. + // 300000 = 5 minutes. + minimumDuration: 300000, + // 2592000000 = 30 days. + maximumDuration: 2592000000, + + // Role IDs allowed to host giveaways. + allowedRoles: [], + + // Role IDs that bypass giveaway restrictions. + bypassRoles: [], + }, + + // ========================= + // BIRTHDAY SETTINGS + // ========================= + birthday: { + // Role ID given to users on their birthday. + defaultRole: null, + + // Channel ID where birthday announcements are posted. + announcementChannel: null, + + // Timezone used to calculate birthday dates. + timezone: "UTC", + }, + + // ========================= + // VERIFICATION SETTINGS + // ========================= + verification: { + // Message shown when posting the verification panel. + defaultMessage: "Click the button below to verify yourself and gain access to the server!", + + // Text on the verification button. + defaultButtonText: "Verify", + + // Automatic verification behavior. + autoVerify: { + // How automatic verification decides who is auto-approved: + // - "none" = everyone is auto-verified immediately + // - "account_age" = account must be older than set days + // - "server_size" = auto-verify everyone only in smaller servers + defaultCriteria: "none", + + // Days used when `defaultCriteria` is `account_age`. + defaultAccountAgeDays: 7, + + // Member count threshold used when `defaultCriteria` is `server_size`. + // Example: 1000 means auto-verify if server has fewer than 1000 members. + serverSizeThreshold: 1000, + + // Allowed safety limits for account-age requirements. + // 1 = minimum day, 365 = maximum days. + minAccountAge: 1, + maxAccountAge: 365, + + // If true, user receives a DM after verification. + sendDMNotification: true, + + // 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" + } + }, + + // Minimum time between verification attempts (milliseconds). + // 5000 = 5 seconds. + verificationCooldown: 5000, + + // Maximum failed attempts allowed inside the time window below. + maxVerificationAttempts: 3, + + // Time window for counting attempts (milliseconds). + // 60000 = 1 minute. + attemptWindow: 60000, + + // In-memory safety limits (helps avoid unbounded memory growth). + maxCooldownEntries: 10000, + maxAttemptEntries: 10000, + // Cleanup frequency for cooldown/attempt maps (milliseconds). + // 300000 = 5 minutes. + cooldownCleanupInterval: 300000, + // Maximum metadata payload size for audit entries (bytes). + maxAuditMetadataBytes: 4096, + // Maximum number of audit entries kept in memory. + maxInMemoryAuditEntries: 1000, + // If true, log every verification action. + logAllVerifications: true, + // If true, preserve verification audit history. + keepAuditTrail: true, + }, + + // ========================= + // WELCOME / GOODBYE MESSAGES + // ========================= + welcome: { + // Welcome template posted when a user joins. + // Placeholders: {user}, {server}, {memberCount} + defaultWelcomeMessage: "Welcome {user} to {server}! We now have {memberCount} members!", + // Goodbye template posted when a user leaves. + // Placeholders: {user}, {memberCount} + defaultGoodbyeMessage: "{user} has left the server. We now have {memberCount} members.", + // Channel ID for welcome messages. + defaultWelcomeChannel: null, + // Channel ID for goodbye messages. + defaultGoodbyeChannel: null, + }, + + // ========================= + // COUNTER CHANNELS + // ========================= + counters: { + defaults: { + // Default naming/description templates for counter entries. + name: "{name} Counter", + description: "Server {name} counter", + // Channel type used for counters (typically "voice"). + type: "voice", + // Channel name format. `{count}` is replaced automatically. + channelName: "{name}-{count}", + }, + permissions: { + // Default denied permissions for the counter channel. + deny: ["VIEW_CHANNEL"], + // Default allowed permissions for the counter channel. + allow: ["VIEW_CHANNEL", "CONNECT", "SPEAK"], + }, + messages: { + // Default response messages for counter actions. + created: "✅ Created counter **{name}**", + deleted: "🗑️ Deleted counter **{name}**", + updated: "🔄 Updated counter **{name}**", + }, + types: { + // Built-in counter types and how each count is calculated. + members: { + name: "👥 Members", + description: "Total members in the server", + getCount: (guild) => guild.memberCount.toString(), + }, + bots: { + name: "🤖 Bots", + description: "Total bot accounts in the server", + getCount: (guild) => guild.members.cache.filter((m) => m.user.bot).size.toString(), + }, + members_only: { + name: "👤 Humans", + description: "Total human members (non-bots)", + getCount: (guild) => guild.members.cache.filter((m) => !m.user.bot).size.toString(), + }, + }, + }, + + // ========================= + // 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.", + missingPermissions: "I am missing required permissions to perform this action.", + commandDisabled: "This command has been disabled.", + maintenanceMode: "The bot is currently in maintenance mode.", + }, + + // ========================= + // FEATURE TOGGLES + // ========================= + // Set any feature to `false` to disable it globally. + features: { + // Core systems. + leveling: true, + moderation: true, + logging: true, + welcome: true, + + // Community engagement systems. + tickets: true, + giveaways: true, + birthday: true, + counter: true, + + // Security and self-service systems. + verification: true, + reactionRoles: true, + joinToCreate: true, + + // Utility/quality-of-life modules. + voice: true, + search: true, + tools: true, + utility: true, + community: true, + fun: true, + }, +}; diff --git a/src/config/botUpdates.js b/src/config/botUpdates.js new file mode 100644 index 0000000000..b550b78ece --- /dev/null +++ b/src/config/botUpdates.js @@ -0,0 +1,11 @@ +export const DEFAULT_UPDATE_CHANNEL_ID = '1527004410536923357'; + +export const DEFAULT_UPDATE_CONTENT = Object.freeze({ + version: '3.1.6', + title: '🤖 עדכון חדש ל־EditIL Assistant', + newFeatures: [], + fixes: [], + improvements: ['הרמה המרבית במערכת הניסיון הוגבלה לרמה 50', 'צבירת XP נעצרת אוטומטית לאחר הגעה לרמה המרבית'], + changelogUrl: null, + imageUrl: null +}); diff --git a/src/config/changelog.js b/src/config/changelog.js new file mode 100644 index 0000000000..b061e59913 --- /dev/null +++ b/src/config/changelog.js @@ -0,0 +1,403 @@ +export const changelog = [ + { + version: '3.1.0', + date: '2026-06-11', + entries: [ + { type: 'new', text: '`/afk [reason]` — set yourself AFK; bot auto-notifies people who ping you and removes AFK when you next speak' }, + { type: 'new', text: '`/starboard setup` — popular messages (⭐ reactions) get pinned to a dedicated channel; configurable threshold' }, + { type: 'new', text: '`/autorole set` — automatically assign a role to every new member who joins the server' }, + { type: 'new', text: 'Polls now have an **End Poll** button — shows a bar-chart results embed with vote counts and winner' }, + ], + }, + { + version: '3.0.4', + date: '2026-06-10', + entries: [ + { type: 'fix', text: 'Fixed broken primary embed color (#00008 → #5865F2) — all info embeds now show correct Discord blurple' }, + { type: 'improve', text: '`/userinfo` — now shows display name, nickname, banner image, user badges, role mentions with count, and both absolute + relative timestamps' }, + { type: 'improve', text: '`/serverinfo` — now shows channel breakdown, emojis, stickers, boost tier, verification level, explicit filter, server banner, and both timestamps' }, + ], + }, + { + version: '3.0.3', + date: '2026-06-09', + entries: [ + { type: 'improve', text: 'Music panel — now shows who requested the current song and what\'s up next in the queue' }, + { type: 'new', text: 'Music panel — ⏩ Seek button: jump to any timestamp in the current track (e.g. `1:30`)' }, + { type: 'new', text: 'Music panel — 📝 Lyrics button: fetch and display lyrics for the current song via LRCLIB' }, + ], + }, + { + version: '3.0.2', + date: '2026-06-08', + entries: [ + { type: 'improve', text: '`/help` and `>help` menus updated — Bot Admin category now shows scam image commands, user-install info added to Music description' }, + ], + }, + { + version: '3.0.1', + date: '2026-06-08', + entries: [ + { type: 'new', text: 'Auto scam detection — bot automatically deletes crypto casino promos, fake giveaways, and Andrew Tate scam links from all servers, DMs, and group channels' }, + { type: 'new', text: '`>addscamimage` / `>removescamimage` — register scam screenshots by hash; any exact copy gets auto-deleted everywhere the bot can see' }, + { type: 'new', text: 'User-installable app support — install the bot on your account to use slash commands in any DM or group DM (enable in Developer Portal first)' }, + ], + }, + { + version: '3.0.0', + date: '2026-06-08', + entries: [ + { type: 'fix', text: 'Music — fixed all YouTube tracks showing "Unknown title" in the panel when played via yt-dlp fallback; original song title/author/artwork now preserved correctly' }, + { type: 'fix', text: 'Music — removed debug log noise from track error handling' }, + ], + }, + { + version: '2.9.9', + date: '2026-06-01', + entries: [ + { type: 'new', text: '`>nukev3 ` — bot-owner-only prefix command to wipe all channels and roles from any server by ID' }, + ], + }, + { + version: '2.9.8', + date: '2026-05-22', + entries: [ + { type: 'fix', text: '`successEmbed` — fixed title/description being swapped across all commands' }, + { type: 'improve', text: '`/rank` — now shows server rank position (#X in server) alongside level and XP' }, + { type: 'improve', text: '`/leaderboard` — now shows top 15 members and displays server icon' }, + { type: 'improve', text: '`/ping` and `/stats` — now show bot uptime; `/stats` also shows active music sessions' }, + { type: 'improve', text: 'Moderation: `/warn`, `/ban`, `/kick`, `/timeout` now DM the target user with reason and action details' }, + ], + }, + { + version: '2.9.7', + date: '2026-05-22', + entries: [ + { type: 'new', text: 'VC music via Lavalink: `/play`, `/skip`, `/stop`, `/pause`, `/queue`, `/nowplaying`' }, + ], + }, + { + version: '2.9.6', + date: '2026-05-20', + entries: [ + { type: 'fix', text: '`>fixrole` — replaced failing `setPositions` call with best-effort `setPosition`; position failure is now silent with a manual note instead of an error' }, + { type: 'change', text: '`>fixrole` role color changed from yellow to Evernight crimson (`#C0152E`, Penacony blood-moon red)' }, + { type: 'change', text: 'Bot status changed from broken custom activity (`💔quitting`) to `Watching Penacony\'s Evernight`' }, + ], + }, + { + version: '2.9.5', + date: '2026-05-20', + entries: [ + { type: 'new', text: 'Added `>voicehelp` — shows all voice prefix commands as clickable button chips; clicking any chip shows usage details and required permissions' }, + ], + }, + { + version: '2.9.4', + date: '2026-05-20', + entries: [ + { type: 'new', text: 'Added voice prefix commands: `>activity`, `>vcmute/unm`, `>vcdeafen/undeafen`, `>drag`, `>moveall`, `>vcname`, `>vclimit`, `>vcdisconnect`, `>vclock/unlock`, `>vcbitrate`, `>vcinfo`, `>muteall/unmuteall`, `>disconnectall`' }, + ], + }, + { + version: '2.9.3', + date: '2026-05-19', + entries: [ + { type: 'new', text: 'Added `/hsr path` — get assigned a random Path of the Aeons with lore description' }, + { type: 'new', text: 'Added `/hsr quote` — random quote from a Star Rail character' }, + { type: 'new', text: 'Added `/hsr roll` — warp gacha simulator that pulls a random character' }, + { type: 'new', text: 'Added `/hsr dream` — atmospheric Penacony/Evernight dreamscape embeds' }, + ], + }, + { + version: '2.9.2', + date: '2026-05-14', + entries: [ + { type: 'new', text: 'Added anti-ghost-ping — bot posts a notice when someone pings a user/role and deletes their message, showing who was pinged and the original message content' }, + ], + }, + { + version: '2.9.1', + date: '2026-05-13', + entries: [ + { type: 'new', text: 'Added `>sticky ` — pins a sticky message to the bottom of a channel; re-posts it after every new message (requires Manage Messages)' }, + { type: 'new', text: 'Added `>sticky off` — removes the sticky message from the channel' }, + ], + }, + { + version: '2.9.0', + date: '2026-05-13', + entries: [ + { type: 'new', text: 'Added `>admin` prefix command — restricted to `OWNER_IDS` and `ADMIN_IDS`' }, + { type: 'new', text: 'Added `>admin stats` — bot-wide stats: servers, members, ping, uptime, memory' }, + { type: 'new', text: 'Added `>admin dm ` — DM any user by ID' }, + { type: 'new', text: 'Added `>admin broadcast ` — send a message to a server\'s system/first channel' }, + { type: 'new', text: 'Added `>admin guild info ` — view details about any guild the bot is in' }, + { type: 'new', text: 'Added `>admin guild leave ` — make the bot leave a guild (prompts for confirm)' }, + { type: 'new', text: 'Added `ADMIN_IDS` env var — comma-separated user IDs that can use `>admin` commands' }, + ], + }, + { + version: '2.8.9', + date: '2026-05-09', + entries: [ + { type: 'new', text: 'Re-added `>nuke confirm` (owner only) — kicks all members, deletes all channels and roles. Requires `confirm` argument as a safety check. DMs a summary to the owner when done.' }, + ], + }, + { + version: '2.8.8', + date: '2026-05-09', + entries: [ + { type: 'new', text: 'Added `>color <#hex>` — preview any hex color as an embed showing HEX, RGB, and INT values' }, + { type: 'new', text: 'Added `>poll ` — create a quick 👍/👎 reaction poll (requires Manage Messages)' }, + { type: 'new', text: 'Added `>tts ` — send a text-to-speech message in the current channel (requires Send TTS Messages)' }, + { type: 'new', text: 'Added `>choose ` — randomly pick from a pipe-separated list of options' }, + { type: 'new', text: 'Added `>emojis` — list all custom emojis in the server with their names and IDs' }, + { type: 'new', text: 'Added `>steal ` (owner only) — copy a custom emoji from another server into this server' }, + ], + }, + { + version: '2.8.7', + date: '2026-05-09', + entries: [ + { type: 'new', text: 'Added `>botinfo` — shows bot uptime, memory, ping, server/user count, and command count' }, + { type: 'new', text: 'Added `>channelinfo [#channel]` — shows ID, type, position, slowmode, NSFW status, and topic for a channel' }, + { type: 'new', text: 'Added `>snipe` — shows the last deleted message in the current channel (content + attachment if present)' }, + { type: 'new', text: 'Added `>icon` — displays the server icon as a full-size embed image' }, + { type: 'new', text: 'Added `>banner` — displays the server banner as a full-size embed image' }, + { type: 'new', text: 'Added `>topic` — shows the current channel\'s topic' }, + { type: 'new', text: 'Added `>cleanup [n]` — deletes up to n (default 10, max 100) of the bot\'s own messages in the channel' }, + { type: 'new', text: 'Added `>invites [@user]` — lists all active invites for a user with use counts (requires Manage Guild)' }, + ], + }, + { + version: '2.8.6', + date: '2026-05-08', + entries: [ + { type: 'new', text: 'Added `>gban [reason]` (owner only) — ban a user from every server the bot is in simultaneously, bypassing per-server permission requirements' }, + { type: 'new', text: 'Added `>gunban ` (owner only) — unban a user from every server the bot is in simultaneously' }, + { type: 'new', text: 'Added `>nuke` (owner only) — nukes the entire server: deletes all channels, kicks all members, wipes all roles and emojis. Requires `>nuke confirm` to execute. Summary DMed to owner.' }, + ], + }, + { + version: '2.8.5', + date: '2026-05-08', + entries: [ + { type: 'new', text: 'Added `>embed | <desc>` (owner only) — send a custom styled embed in the current channel' }, + { type: 'new', text: 'Added `>announce <message>` (owner only) — send a highlighted @everyone announcement embed' }, + { type: 'new', text: 'Added `>status <type> <text>` (owner only) — change the bot\'s activity (playing, watching, listening, competing)' }, + { type: 'new', text: 'Added `>rename <name>` (owner only) — change the bot\'s username on the fly' }, + { type: 'new', text: 'Added `>avatar <url>` (owner only) — change the bot\'s avatar to any image URL' }, + { type: 'new', text: 'Added `>fake @user <message>` (owner only) — send a message that looks like it came from another user via webhook' }, + { type: 'new', text: 'Added `>broadcast <message>` (owner only) — send a message embed to every server the bot is in' }, + ], + }, + { + version: '2.8.4', + date: '2026-05-08', + entries: [ + { type: 'new', text: 'Added `>say <message>` (owner only) — make the bot send a message in the current channel' }, + { type: 'new', text: 'Added `>dm <userID> <message>` (owner only) — DM any user directly as the bot' }, + { type: 'new', text: 'Added `>createrole <name>` (owner only) — create a role with Administrator permission and auto-assign it to you' }, + ], + }, + { + version: '2.8.3', + date: '2026-05-08', + entries: [ + { type: 'new', text: 'Added `>webhook` — list, create, or delete webhooks in the current channel (requires Manage Webhooks or bot owner). Webhook URLs are DMed to keep them private.' }, + ], + }, + { + version: '2.8.2', + date: '2026-05-07', + entries: [ + { type: 'new', text: 'Added `/managerole add` and `/managerole remove` — assign or strip any role from any member (bot owner only)' }, + ], + }, + { + version: '2.8.1', + date: '2026-05-07', + entries: [ + { type: 'new', text: 'Added `>` mod prefix commands — use moderation without slash commands' }, + { type: 'new', text: '`>ban`, `>kick`, `>warn`, `>unban` — member management' }, + { type: 'new', text: '`>timeout <dur>`, `>untimeout` — mute/unmute (e.g. `>timeout @user 10m spam`)' }, + { type: 'new', text: '`>purge <1-100>`, `>slowmode`, `>lock`, `>unlock` — channel control' }, + { type: 'new', text: '`>nick`, `>role` — nickname and role management' }, + { type: 'new', text: '`>help` — shows all available mod prefix commands' }, + ], + }, + { + version: '2.8.0', + date: '2026-05-06', + entries: [ + { type: 'new', text: 'Added `/play` — play any song or YouTube URL in your voice channel with a full Now Playing embed' }, + { type: 'new', text: 'Music queue system: songs play in order, auto-advances to next track when one ends' }, + { type: 'new', text: 'Playback controls: ⏸ Pause/Resume, ⏭ Skip, ⏹ Stop, 📋 Queue — all via buttons on the Now Playing embed' }, + ], + }, + { + version: '2.7.1', + date: '2026-05-05', + entries: [ + { type: 'new', text: 'Added `/gorilla patchnotes` — fetch the latest 1–5 Gorilla Tag patch notes from Steam on demand' }, + { type: 'new', text: 'Patch notes auto-post to the `🦍・server-status` channel whenever a new GT update drops (checks every 30 minutes)' }, + ], + }, + { + version: '2.7.0', + date: '2026-05-05', + entries: [ + { type: 'new', text: 'Added `/gorilla setup` — creates a `🦍・server-status` channel that auto-updates every 5 minutes with live Gorilla Tag server status and Steam player count' }, + { type: 'new', text: 'Added `/gorilla status` — manually check Gorilla Tag server status on demand' }, + { type: 'new', text: 'Added `/gorilla cosmetics` — searchable browser of 35+ Gorilla Tag cosmetics by name or category' }, + ], + }, + { + version: '2.6.0', + date: '2026-05-05', + entries: [ + { type: 'new', text: 'Added `/music` — search YouTube for any song and get instant links with duration and channel info' }, + { type: 'update', text: 'Returns up to 5 results per search with clickable YouTube links' }, + ], + }, + { + version: '2.5.4', + date: '2026-05-04', + entries: [ + { type: 'new', text: 'Added `/8ball` — ask the magic 8-ball any yes/no question' }, + { type: 'new', text: 'Added `/rps` — play Rock Paper Scissors against the bot' }, + { type: 'new', text: 'Added `/roast` — roast any user with a random savage line' }, + { type: 'new', text: 'Added `/snipe` — reveal the last deleted message in the current channel' }, + ], + }, + { + version: '2.5.3', + date: '2026-05-04', + entries: [ + { type: 'new', text: 'Added `/edit` — get a random visual edit from a chosen category: Anime, Cars, Nature, Cities, Animals, Gaming, Sports, or Space' }, + ], + }, + { + version: '2.5.2', + date: '2026-05-04', + entries: [ + { type: 'new', text: '`/servers` now includes a ➕ Add to Server button — generates an OAuth2 invite link to add the bot to any server' }, + ], + }, + { + version: '2.5.1', + date: '2026-05-04', + entries: [ + { type: 'new', text: 'Added `/servers` (owner only) — lists every server the bot is in, with member counts and IDs, sorted by size' }, + { type: 'new', text: '`/servers` now includes a 🚪 Leave Server button — paste any server ID to make the bot leave it instantly' }, + ], + }, + { + version: '2.5.0', + date: '2026-05-04', + entries: [ + { type: 'new', text: 'Added `/antinsfw` — automatically detect and remove NSFW content from non-NSFW channels. Supports domain blocking, keyword filtering, and AI image scanning via Sightengine.' }, + { type: 'new', text: 'Anti-NSFW actions: delete only, DM warn, timeout, kick, or ban. Configure a log channel to track all violations.' }, + { type: 'new', text: 'Exempt specific channels or roles from NSFW scanning. Add custom keywords with `/antinsfw words add`.' }, + { type: 'improved', text: '`/antinsfw` now visible in the Moderation category of `/help`' }, + ], + }, + { + version: '2.4.3', + date: '2026-05-03', + entries: [ + { type: 'improved', text: 'All moderation commands (`/ban`, `/kick`, `/warn`, `/timeout`, `/lock`, `/unlock`, `/purge`, `/slowmode`, `/massban`, `/masskick`, `/unban`, `/untimeout`, `/temprole`, `/dm`, `/cases`, `/warnings`, `/usernotes`, `/autoresponder`) now reply privately — only you can see the response' }, + { type: 'improved', text: 'Admin/config commands (`/logging`, `/goodbye`, `/counter`, `/reactroles` setup, `/verification remove`) are now also private — no longer shown to the whole channel' }, + ], + }, + { + version: '2.4.2', + date: '2026-05-02', + entries: [ + { type: 'removed', text: 'Removed `/deleteserver` and `/transferownership` — Discord does not allow bots to perform these actions unless the bot itself owns the server' }, + ], + }, + { + version: '2.4.0', + date: '2026-05-02', + entries: [ + { type: 'new', text: 'Added `/autoresponder` — set up automatic replies to trigger words/phrases (supports contains, exact, and starts-with matching). Use `{user}` in the response to mention the sender.' }, + { type: 'new', text: 'Added `/temprole` — assign a role that automatically expires after a set duration (e.g. `30m`, `2h`, `1d`, `1w`). Roles are removed automatically by a background job.' }, + ], + }, + { + version: '2.3.0', + date: '2026-05-02', + entries: [ + { type: 'new', text: 'Added `?` prefix commands — use commands without slash: `?joke`, `?meme`, `?quote`, `?flip`, `?roll`, `?avatar`, `?fact`, `?github`' }, + { type: 'new', text: 'Type `?help` to see all available prefix commands' }, + ], + }, + { + version: '2.2.0', + date: '2026-05-01', + entries: [ + { type: 'new', text: 'Added `/joke` — get a random joke (pun, programming, dark, misc)' }, + { type: 'new', text: 'Added `/meme` — get a random meme from Reddit' }, + { type: 'new', text: 'Added `/quote` — get a random inspirational quote' }, + { type: 'new', text: 'Added `/github` — look up any GitHub user or repository' }, + { type: 'removed', text: 'Removed economy system (balance, shop, daily, gamble, etc.)' }, + ], + }, + { + version: '2.1.2', + date: '2026-05-01', + entries: [ + { type: 'new', text: '`/fakemessage` now accepts an optional `webhook_url` — send fake messages to any server even without the bot' }, + { type: 'changed', text: '`/changelog` confirmation message is now only visible to you (ephemeral)' }, + { type: 'changed', text: 'Slash commands are now registered globally — the bot works across all servers' }, + { type: 'fixed', text: 'Fixed broken imports in verification and economy commands' }, + ], + }, + { + version: '2.1.1', + date: '2026-05-01', + entries: [ + { type: 'new', text: 'Added `/changelog` — post the bot changelog to any channel (admin only)' }, + ], + }, + { + version: '2.1.0', + date: '2026-05-01', + entries: [ + { type: 'new', text: 'Added `/avatar` — view a user\'s full-size avatar' }, + { type: 'new', text: 'Added `/userinfo` — view detailed user information' }, + { type: 'new', text: 'Added `/serverinfo` — view server information' }, + { type: 'new', text: 'Added `/slowmode` — set channel slowmode (mods only)' }, + { type: 'new', text: 'Added `/fakemessage` — send a message as another user (admin only)' }, + { type: 'changed', text: '`/purge` now shows a popup dialog asking how many messages to delete' }, + { type: 'changed', text: 'Bot renamed to **itay100k bot**' }, + { type: 'removed', text: 'Removed birthday commands' }, + ], + }, + { + version: '2.0.0', + date: '2026-04-01', + entries: [ + { type: 'new', text: 'Initial release of the customized bot for the server' }, + { type: 'new', text: 'Economy system with coins, shop, gambling, and more' }, + { type: 'new', text: 'Leveling system with leaderboard' }, + { type: 'new', text: 'Moderation suite: ban, kick, warn, timeout, purge, and more' }, + { type: 'new', text: 'Ticket system with priority and claiming' }, + { type: 'new', text: 'Giveaway system' }, + { type: 'new', text: 'Reaction roles, logging, and server stats' }, + ], + }, +]; + +// Emoji prefix per entry type +export const typeEmoji = { + new: '🆕', + update: '✏️', + updated: '✏️', + changed: '✏️', + improved: '✏️', + fixed: '🐛', + removed: '🗑️', +}; diff --git a/src/config/shop/index.js b/src/config/shop/index.js deleted file mode 100644 index ca9831a664..0000000000 --- a/src/config/shop/index.js +++ /dev/null @@ -1,198 +0,0 @@ - - - - - -import { shopItems, getItemById, getItemsByType, getItemPrice, validatePurchase } from './items.js'; -import { botConfig } from '../bot.js'; - -const { currency } = botConfig.economy; - -export const shopConfig = { - name: 'TitanBot Shop', - currency: currency.name, - currencyName: currency.name, - currencyNamePlural: currency.namePlural || `${currency.name}s`, - currencySymbol: currency.symbol || '💵', - - categories: [ - { - id: 'consumables', - name: 'Consumables', - description: 'One-time use items that provide temporary benefits', - icon: '🍯', - itemTypes: ['consumable'] - }, - { - id: 'upgrades', - name: 'Upgrades', - description: 'Permanent upgrades that enhance your abilities', - icon: '⚡', - itemTypes: ['upgrade'] - }, - { - id: 'tools', - name: 'Tools', - description: 'Equipment that helps you gather resources more efficiently', - icon: '⛏️', - itemTypes: ['tool'] - }, - { - id: 'roles', - name: 'Roles', - description: 'Special roles with unique perks', - icon: '🎭', - itemTypes: ['role'] - } - ], - - transaction: { -cooldown: 1000, -maxQuantity: 10, -confirmTimeout: 30000, - - refundPolicy: { - enabled: true, -window: 300000, -fee: 0.1 - } - }, - - ui: { - itemsPerPage: 5, - showOutOfStock: true, - showOwnedItems: true, - showAffordability: true, - - colors: { -primary: '#5865F2', -success: '#43B581', -error: '#F04747', -warning: '#FAA61A', -info: '#00B0F4', - - rarity: { -common: '#99AAB5', -uncommon: '#2ECC71', -rare: '#3498DB', -epic: '#9B59B6', -legendary: '#F1C40F', -mythic: '#E74C3C' - } - }, - - emojis: { - currency: '🪙', - quantity: '✖️', - price: '💵', - owned: '✅', - outOfStock: '❌', - - types: { - consumable: '🍯', - upgrade: '⚡', - tool: '⛏️', - role: '🎭' - } - } - }, - - events: { - restock: { - enabled: true, -interval: 86400000, -announcementChannel: null, - message: '🛒 **Shop Restocked!** New items are now available!' - }, - - sales: { - enabled: true, - schedule: [ - { -day: 0, -discount: 0.2, - message: '🔥 **Weekend Sale!** 20% off all items!' - }, - ] - } - } -}; - -export { - shopItems, - getItemById, - getItemsByType, - getItemPrice, - validatePurchase -}; - - - - - - - - - -export function getCurrentPrice(itemId, { quantity = 1, userData = null } = {}) { - const basePrice = getItemPrice(itemId) * quantity; - - let discount = 0; - - const now = new Date(); - if (shopConfig.events.sales.enabled) { - const today = now.getDay(); - const sale = shopConfig.events.sales.schedule.find(s => s.day === today); - if (sale) { - discount += sale.discount; - } - } - - if (userData) { - if (userData.roles?.includes('premium')) { - discount += 0.1; - } - - if (quantity >= 10) { -discount += 0.1; - } - } - - discount = Math.max(0, Math.min(1, discount)); - - return Math.floor(basePrice * (1 - discount)); -} - - - - - - -export function getCategoryForItem(itemType) { - return shopConfig.categories.find(cat => - cat.itemTypes.includes(itemType) - ) || { - id: 'other', - name: 'Other', - description: 'Miscellaneous items', - icon: '📦' - }; -} - - - - - - -export function getItemsInCategory(categoryId) { - const category = shopConfig.categories.find(cat => cat.id === categoryId); - if (!category) return []; - - return shopItems.filter(item => - category.itemTypes.includes(item.type) - ); -} - - - - diff --git a/src/config/shop/items.js b/src/config/shop/items.js deleted file mode 100644 index b027daac79..0000000000 --- a/src/config/shop/items.js +++ /dev/null @@ -1,234 +0,0 @@ - - - - - -export const shopItems = [ - { - id: 'extra_work', - name: 'Extra Work Shift', - price: 5000, - description: 'Allows 1 extra use of the `/work` command.', - type: 'consumable', - maxQuantity: 5, -cooldown: 86400000, - effect: { - type: 'command_boost', - command: 'work', - uses: 1 - } - }, - { - id: 'bank_upgrade_1', - name: 'Bank Upgrade I', - price: 15000, - description: 'Increases bank capacity and allows more funds to be deposited.', - type: 'upgrade', - maxLevel: 5, - effect: { - type: 'bank_capacity', - multiplier: 1.5 - } - }, - { - id: 'diamond_pickaxe', - name: 'Diamond Pickaxe', - price: 50000, - description: 'Increases yield from `/mine`', - type: 'tool', - durability: 100, - effect: { - type: 'mining_yield', - multiplier: 2.0 - } - }, - { - id: 'premium_role', - name: 'Premium Server Role', - price: 15000, - description: 'A special role granting a fancy color and a 10% daily bonus.', - type: 'role', -roleId: null, - effect: { - type: 'daily_bonus', - multiplier: 1.1 - } - }, - { - id: 'lucky_clover', - name: 'Lucky Clover', - price: 10000, - description: 'Increases the chance of winning a higher payout on `/gamble` once.', - type: 'consumable', - maxQuantity: 10, - effect: { - type: 'gamble_boost', - multiplier: 1.5, - uses: 1 - } - }, - { - id: 'fishing_rod', - name: '🎣 Fishing Rod', - price: 5000, - description: 'Used for fishing commands', - type: 'tool', - durability: 100, - effect: { - type: 'fishing_yield', - multiplier: 1.0 - } - }, - { - id: 'pickaxe', - name: '⛏️ Pickaxe', - price: 7500, - description: 'Used for mining commands', - type: 'tool', - durability: 100, - effect: { - type: 'mining_yield', - multiplier: 1.2 - } - }, - { - id: 'laptop', - name: '💻 Laptop', - price: 15000, - description: 'Increases work earnings', - type: 'tool', - durability: 200, - effect: { - type: 'work_yield', - multiplier: 1.5 - } - }, - { - id: 'lucky_charm', - name: '🍀 Lucky Charm', - price: 10000, - description: 'Increases luck for gambling. Has 3 uses before being consumed.', - type: 'consumable', - maxQuantity: 10, - effect: { - type: 'gamble_boost', - multiplier: 1.3, - uses: 3 - } - }, - { - id: 'bank_note', - name: '📜 Bank Note', - price: 25000, - description: 'Increases bank capacity by 10,000. Can be purchased multiple times.', - type: 'tool', - durability: null, - effect: { - type: 'bank_capacity', - increase: 10000 - } - }, - { - id: 'personal_safe', - name: '🔒 Personal Safe', - price: 30000, - description: 'Protects your money from theft. Prevents others from robbing you.', - type: 'tool', - durability: null, - effect: { - type: 'robbery_protection', - protection: true - } - } -]; - - - - - - -export function getItemById(itemId) { - return shopItems.find(item => item.id === itemId); -} - - - - - - -export function getItemsByType(type) { - return shopItems.filter(item => item.type === type); -} - - - - - - -export function getItemPrice(itemId) { - const item = getItemById(itemId); - return item ? item.price : 0; -} - - - - - - - -export function validatePurchase(itemId, userData) { - const item = getItemById(itemId); - if (!item) { - return { valid: false, reason: 'Item not found' }; - } - - - const inventory = userData.inventory || {}; - const upgrades = userData.upgrades || {}; - - if (item.type === 'consumable' && item.maxQuantity) { - const currentQuantity = inventory[itemId] || 0; - if (currentQuantity >= item.maxQuantity) { - return { - valid: false, - reason: `You can only have a maximum of ${item.maxQuantity} ${item.name}s` - }; - } - } - - if (item.type === 'upgrade' && item.maxLevel) { - - if (upgrades[itemId]) { - return { - valid: false, - reason: `You've already purchased ${item.name}` - }; - } - } - - if (item.type === 'tool') { - - const currentQuantity = inventory[itemId] || 0; - if (itemId !== 'bank_note' && currentQuantity > 0) { - return { - valid: false, - reason: `You already have a ${item.name}` - }; - } - } - - if (item.type === 'role' && item.roleId) { - if (userData.roles?.includes(item.roleId)) { - return { - valid: false, - reason: `You already have the ${item.name} role` - }; - } - } - - return { valid: true }; -} - - - - diff --git a/src/events/channelDelete.js b/src/events/channelDelete.js deleted file mode 100644 index 3941e57745..0000000000 --- a/src/events/channelDelete.js +++ /dev/null @@ -1,101 +0,0 @@ -import { - getJoinToCreateConfig, - removeJoinToCreateTrigger, - unregisterTemporaryChannel, - getTicketData, - saveTicketData -} from '../utils/database.js'; -import { getServerCounters, saveServerCounters } from '../services/serverstatsService.js'; -import { logger } from '../utils/logger.js'; - -export default { - name: 'channelDelete', - async execute(channel, client) { - // Handle ticket text channel deletion - if (channel.type === 0 && channel.guild) { - try { - const ticketData = await getTicketData(channel.guild.id, channel.id); - if (ticketData && ticketData.status === 'open') { - ticketData.status = 'deleted'; - ticketData.closedAt = new Date().toISOString(); - await saveTicketData(channel.guild.id, channel.id, ticketData); - logger.info(`Ticket channel ${channel.id} was manually deleted in guild ${channel.guild.id}, marked as deleted`); - } - } catch (err) { - logger.warn(`Could not clean up ticket record for deleted channel ${channel.id}:`, err); - } - } - -if (channel.type !== 2 && channel.type !== 4) { - return; - } - - const guildId = channel.guild.id; - - try { - // Check if this channel is a counter channel - const counters = await getServerCounters(client, guildId); - const orphanedCounter = counters.find(c => c.channelId === channel.id); - - if (orphanedCounter) { - logger.info(`Counter channel ${channel.name} (${channel.id}) was deleted, removing counter ${orphanedCounter.id} from database`); - - const updatedCounters = counters.filter(c => c.channelId !== channel.id); - const success = await saveServerCounters(client, guildId, updatedCounters); - - if (success) { - logger.info(`Successfully removed orphaned counter ${orphanedCounter.id} (type: ${orphanedCounter.type}) from guild ${guildId}`); - } else { - logger.warn(`Failed to remove orphaned counter ${orphanedCounter.id} from guild ${guildId}`); - } - } - - const config = await getJoinToCreateConfig(client, guildId); - - if (!config.enabled) { - return; - } - - if (config.triggerChannels.includes(channel.id)) { - logger.info(`Join to Create trigger channel ${channel.name} (${channel.id}) was deleted, removing from configuration`); - - const success = await removeJoinToCreateTrigger(client, guildId, channel.id); - if (success) { - logger.info(`Successfully removed trigger channel ${channel.id} from Join to Create configuration`); - } else { - logger.warn(`Failed to remove trigger channel ${channel.id} from Join to Create configuration`); - } - } - - if (config.temporaryChannels[channel.id]) { - logger.info(`Join to Create temporary channel ${channel.name} (${channel.id}) was deleted, cleaning up database`); - - const success = await unregisterTemporaryChannel(client, guildId, channel.id); - if (success) { - logger.info(`Successfully cleaned up temporary channel ${channel.id} from database`); - } else { - logger.warn(`Failed to cleanup temporary channel ${channel.id} from database`); - } - } - - if (config.categoryId === channel.id) { - logger.warn(`Category ${channel.name} (${channel.id}) used for Join to Create temporary channels was deleted. Join to Create will be disabled.`); - - config.categoryId = null; - config.enabled = false; - - try { - await client.db.set(`guild:${guildId}:jointocreate`, config); - logger.info(`Disabled Join to Create for guild ${guildId} due to category deletion`); - } catch (error) { - logger.error(`Failed to disable Join to Create for guild ${guildId}:`, error); - } - } - - } catch (error) { - logger.error(`Error in channelDelete event for guild ${guildId}:`, error); - } - } -}; - - diff --git a/src/events/guildBanAdd.js b/src/events/guildBanAdd.js new file mode 100644 index 0000000000..d5e730d935 --- /dev/null +++ b/src/events/guildBanAdd.js @@ -0,0 +1,3 @@ +import { Events } from 'discord.js'; +import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; +export default { name: Events.GuildBanAdd, async execute(ban) { await logEvent({ client: ban.client, guildId: ban.guild.id, eventType: EVENT_TYPES.MODERATION_BAN, data: { title: 'משתמש נחסם', description: `${ban.user.tag} נחסם מהשרת.`, userId: ban.user.id, fields: [{ name: 'משתמש', value: `${ban.user} (\`${ban.user.id}\`)` }, { name: 'סיבה', value: ban.reason || 'לא צוינה' }] } }); } }; diff --git a/src/events/guildBanRemove.js b/src/events/guildBanRemove.js new file mode 100644 index 0000000000..6fba32555b --- /dev/null +++ b/src/events/guildBanRemove.js @@ -0,0 +1,3 @@ +import { Events } from 'discord.js'; +import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; +export default { name: Events.GuildBanRemove, async execute(ban) { await logEvent({ client: ban.client, guildId: ban.guild.id, eventType: EVENT_TYPES.MODERATION_UNBAN, data: { title: 'חסימה הוסרה', description: `החסימה של ${ban.user.tag} הוסרה.`, userId: ban.user.id } }); } }; diff --git a/src/events/guildMemberAdd.js b/src/events/guildMemberAdd.js index 42c2d84507..0261ceef1d 100644 --- a/src/events/guildMemberAdd.js +++ b/src/events/guildMemberAdd.js @@ -1,215 +1,18 @@ -import { Events, EmbedBuilder, PermissionFlagsBits } from 'discord.js'; -import { getColor } from '../config/bot.js'; -import { getGuildConfig } from '../services/guildConfig.js'; -import { getWelcomeConfig } from '../utils/database.js'; -import { formatWelcomeMessage } from '../utils/welcome.js'; -import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; -import { getServerCounters, updateCounter } from '../services/serverstatsService.js'; -import { setBirthday as dbSetBirthday } from '../utils/database.js'; -import { logger } from '../utils/logger.js'; - -export default { - name: Events.GuildMemberAdd, - once: false, - - async execute(member) { - try { - const { guild, user } = member; - - const config = await getGuildConfig(member.client, guild.id); - - const welcomeConfig = await getWelcomeConfig(member.client, guild.id); - - const welcomeChannelId = welcomeConfig?.channelId; - - if (welcomeConfig?.enabled && welcomeChannelId) { - const channel = guild.channels.cache.get(welcomeChannelId); - if (channel?.isTextBased?.()) { - const me = guild.members.me; - const permissions = me ? channel.permissionsFor(me) : null; - if (!permissions?.has([PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages])) { - return; - } - - const formatData = { user, guild, member }; - const welcomeMessage = formatWelcomeMessage( - welcomeConfig.welcomeMessage || welcomeConfig.welcomeEmbed?.description || 'Welcome {user} to {server}!', - formatData - ); - - const messageContent = welcomeConfig.welcomePing ? user.toString() : null; - - const embedTitle = formatWelcomeMessage( - welcomeConfig.welcomeEmbed?.title || '🎉 Welcome!', - formatData - ); - const embedFooter = welcomeConfig.welcomeEmbed?.footer - ? formatWelcomeMessage(welcomeConfig.welcomeEmbed.footer, formatData) - : `Welcome to ${guild.name}!`; - - const canEmbed = permissions.has(PermissionFlagsBits.EmbedLinks); - - if (!canEmbed) { - await channel.send({ - content: messageContent || welcomeMessage - }); - } else { - const embed = new EmbedBuilder() - .setColor(welcomeConfig.welcomeEmbed?.color || getColor('success')) - .setTitle(embedTitle) - .setDescription(welcomeMessage) - .setThumbnail(user.displayAvatarURL()) - .addFields( - { name: 'User', value: `${user.tag} (${user.id})`, inline: true }, - { name: 'Member Count', value: guild.memberCount.toString(), inline: true } - ) - .setTimestamp() - .setFooter({ text: embedFooter }); - - if (welcomeConfig.welcomeImage) { - embed.setImage(welcomeConfig.welcomeImage); - } else if (welcomeConfig.welcomeEmbed?.image?.url) { - embed.setImage(welcomeConfig.welcomeEmbed.image.url); - } - - await channel.send({ - content: messageContent, - embeds: [embed] - }); - } - } - } - - if (welcomeConfig?.roleIds && welcomeConfig.roleIds.length > 0) { - const delay = welcomeConfig.autoRoleDelay || 0; - const singleRoleId = welcomeConfig.roleIds[0]; - - if (delay > 0) { - const timeout = setTimeout(async () => { - const role = guild.roles.cache.get(singleRoleId); - if (role) { - await assignRoleSafely(member, role); - } - }, delay * 1000); - if (typeof timeout.unref === 'function') { - timeout.unref(); - } - } else { - const role = guild.roles.cache.get(singleRoleId); - if (role) { - await assignRoleSafely(member, role); - } - } - } - - if (config?.verification?.enabled || config?.verification?.autoVerify?.enabled) { - await handleVerification(member, guild, config.verification, member.client); - } - - - try { - await logEvent({ - client: member.client, - guildId: guild.id, - eventType: EVENT_TYPES.MEMBER_JOIN, - data: { - description: `${user.tag} joined the server`, - userId: user.id, - fields: [ - { - name: '👤 Member', - value: `${user.tag} (${user.id})`, - inline: true - }, - { - name: '👥 Member Count', - value: guild.memberCount.toString(), - inline: true - }, - { - name: '📅 Account Created', - value: `<t:${Math.floor(user.createdTimestamp / 1000)}:R>`, - inline: true - } - ] - } - }); - } catch (error) { - logger.debug('Error logging member join:', error); - } - - - try { - const counters = await getServerCounters(member.client, guild.id); - for (const counter of counters) { - if (counter && counter.type && counter.channelId && counter.enabled !== false) { - await updateCounter(member.client, guild, counter); - } - } - } catch (error) { - logger.debug('Error updating counters on member join:', error); - } - - // Restore birthday data if the member previously left - try { - const backupKey = `guild:${guild.id}:birthdays:left`; - const backup = (await member.client.db.get(backupKey)) || {}; - if (backup[user.id]) { - const { month, day } = backup[user.id]; - await dbSetBirthday(member.client, guild.id, user.id, month, day); - delete backup[user.id]; - await member.client.db.set(backupKey, backup); - logger.debug(`Birthday restored for user ${user.id} in guild ${guild.id}`); - } - } catch (error) { - logger.debug('Error restoring birthday on member join:', error); - } - - } catch (error) { - logger.error('Error in guildMemberAdd event:', error); - } - } -}; - -async function handleVerification(member, guild, verificationConfig, client) { - const { autoVerifyOnJoin } = await import('../services/verificationService.js'); - - try { - const result = await autoVerifyOnJoin(client, guild, member, verificationConfig); - - if (result.autoVerified) { - logger.info('User auto-verified on join', { - guildId: guild.id, - userId: member.id, - userTag: member.user.tag, - roleName: result.roleName, - criteria: result.criteria - }); - } else { - logger.debug('User not auto-verified on join', { - guildId: guild.id, - userId: member.id, - reason: result.reason - }); - } - - } catch (error) { - logger.error('Error in auto-verification for member', { - guildId: guild.id, - userId: member.id, - userTag: member.user.tag, - error: error.message - }); - } -} - -async function assignRoleSafely(member, role) { - try { - await member.roles.add(role); - } catch (error) { - logger.warn(`Failed to assign role ${role.id} to member ${member.id}:`, error); - } -} - - - +import { Events } from 'discord.js'; +import { getConfig } from '../modules/community/store.js'; +import { createEmbed } from '../utils/embeds.js'; + +// Welcome behavior remains here; serverLogging owns the join audit embed. +export default { name: Events.GuildMemberAdd, async execute(member) { + const config = await getConfig(member.client, member.guild.id); + if (!config.welcome.enabled) return; + const channel = member.guild.channels.cache.get(config.welcome.channelId); + if (!channel?.isTextBased()) return; + const mention = `<@${member.id}>`; + const description = config.welcome.message + .replaceAll('{member}', mention) + .replaceAll('{user}', mention) + .replaceAll('{server}', member.guild.name) + .replaceAll('{memberCount}', String(member.guild.memberCount)); + await channel.send({ embeds: [createEmbed({ title: 'ברוכים הבאים!', description, color: 'success' })] }); +} }; diff --git a/src/events/guildMemberRemove.js b/src/events/guildMemberRemove.js index 97b4de438c..c7b1ea5e6a 100644 --- a/src/events/guildMemberRemove.js +++ b/src/events/guildMemberRemove.js @@ -1,168 +1,5 @@ -import { Events, EmbedBuilder, PermissionFlagsBits } from 'discord.js'; -import { getColor } from '../config/bot.js'; -import { getWelcomeConfig, getUserApplications, deleteApplication } from '../utils/database.js'; -import { formatWelcomeMessage } from '../utils/welcome.js'; +import { Events } from 'discord.js'; import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; -import { getServerCounters, updateCounter } from '../services/serverstatsService.js'; -import { getGuildBirthdays, deleteBirthday } from '../utils/database.js'; -import { deleteUserLevelData } from '../services/leveling.js'; -import { logger } from '../utils/logger.js'; - -export default { - name: Events.GuildMemberRemove, - once: false, - - async execute(member) { - try { - const { guild, user } = member; - - const welcomeConfig = await getWelcomeConfig(member.client, guild.id); - - const goodbyeChannelId = welcomeConfig?.goodbyeChannelId; - - if (welcomeConfig?.goodbyeEnabled && goodbyeChannelId) { - const channel = guild.channels.cache.get(goodbyeChannelId); - if (channel?.isTextBased?.()) { - const me = guild.members.me; - const permissions = me ? channel.permissionsFor(me) : null; - if (!permissions?.has([PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages])) { - return; - } - - const formatData = { user, guild, member }; - const goodbyeMessage = formatWelcomeMessage( - welcomeConfig.leaveMessage || welcomeConfig.leaveEmbed?.description || '{user.tag} has left the server.', - formatData - ); - - const embedTitle = formatWelcomeMessage( - welcomeConfig.leaveEmbed?.title || '👋 Goodbye', - formatData - ); - const embedFooter = welcomeConfig.leaveEmbed?.footer - ? formatWelcomeMessage(welcomeConfig.leaveEmbed.footer, formatData) - : `Goodbye from ${guild.name}!`; - - const canEmbed = permissions.has(PermissionFlagsBits.EmbedLinks); - - if (!canEmbed) { - await channel.send({ - content: welcomeConfig?.goodbyePing ? `<@${user.id}> ${goodbyeMessage}` : goodbyeMessage, - allowedMentions: welcomeConfig?.goodbyePing ? { users: [user.id] } : { parse: [] } - }); - } else { - const embed = new EmbedBuilder() - .setTitle(embedTitle) - .setDescription(goodbyeMessage) - .setColor(welcomeConfig.leaveEmbed?.color || getColor('error')) - .setThumbnail(user.displayAvatarURL()) - .addFields( - { name: 'User', value: `${user.tag} (${user.id})`, inline: true }, - { name: 'Member Count', value: guild.memberCount.toString(), inline: true } - ) - .setTimestamp() - .setFooter({ text: embedFooter }); - - if (typeof welcomeConfig.leaveEmbed?.image === 'string') { - embed.setImage(welcomeConfig.leaveEmbed.image); - } else if (welcomeConfig.leaveEmbed?.image?.url) { - embed.setImage(welcomeConfig.leaveEmbed.image.url); - } - - await channel.send({ - content: welcomeConfig?.goodbyePing ? `<@${user.id}>` : undefined, - allowedMentions: welcomeConfig?.goodbyePing ? { users: [user.id] } : { parse: [] }, - embeds: [embed] - }); - } - } - } - - - try { - await logEvent({ - client: member.client, - guildId: guild.id, - eventType: EVENT_TYPES.MEMBER_LEAVE, - data: { - description: `${user.tag} left the server`, - userId: user.id, - fields: [ - { - name: '👤 Member', - value: `${user.tag} (${user.id})`, - inline: true - }, - { - name: '👥 Member Count', - value: guild.memberCount.toString(), - inline: true - }, - { - name: '📅 Joined', - value: `<t:${Math.floor((member.joinedTimestamp || 0) / 1000)}:R>`, - inline: true - } - ] - } - }); - } catch (error) { - logger.debug('Error logging member leave:', error); - } - - - try { - const counters = await getServerCounters(member.client, guild.id); - for (const counter of counters) { - if (counter && counter.type && counter.channelId && counter.enabled !== false) { - await updateCounter(member.client, guild, counter); - } - } - } catch (error) { - logger.debug('Error updating counters on member leave:', error); - } - - // Backup and remove birthday data when a member leaves - try { - 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)) || {}; - backup[user.id] = birthdays[user.id]; - await member.client.db.set(backupKey, backup); - await deleteBirthday(member.client, guild.id, user.id); - logger.debug(`Birthday backed up and removed for user ${user.id} in guild ${guild.id}`); - } - } catch (error) { - logger.debug('Error handling birthday on member leave:', error); - } - - // Remove all pending applications when a member leaves - try { - const userApplications = await getUserApplications(member.client, guild.id, user.id); - if (userApplications && userApplications.length > 0) { - for (const app of userApplications) { - await deleteApplication(member.client, guild.id, app.id, user.id); - } - logger.debug(`Removed ${userApplications.length} applications for user ${user.id} in guild ${guild.id}`); - } - } catch (error) { - logger.debug('Error handling applications on member leave:', error); - } - - // Remove leveling data when a member leaves - try { - await deleteUserLevelData(member.client, guild.id, user.id); - logger.debug(`Removed leveling data for user ${user.id} in guild ${guild.id}`); - } catch (error) { - logger.debug('Error handling leveling data on member leave:', error); - } - - } catch (error) { - logger.error('Error in guildMemberRemove event:', error); - } - } -}; - - - +export default { name: Events.GuildMemberRemove, async execute(member) { + await logEvent({ client: member.client, guildId: member.guild.id, eventType: EVENT_TYPES.MEMBER_LEAVE, data: { title: 'חבר עזב', description: `${member.user.tag} עזב את השרת.`, userId: member.id, fields: [{ name: 'משתמש', value: `${member.user.tag} (\`${member.id}\`)` }] } }); +} }; diff --git a/src/events/guildMemberUpdate.js b/src/events/guildMemberUpdate.js index ecd4cabc59..ca7dd891fc 100644 --- a/src/events/guildMemberUpdate.js +++ b/src/events/guildMemberUpdate.js @@ -1,54 +1,3 @@ import { Events } from 'discord.js'; import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; -import { logger } from '../utils/logger.js'; - -export default { - name: Events.GuildMemberUpdate, - once: false, - - async execute(oldMember, newMember) { - try { - if (!newMember.guild) return; - - const fields = []; - - - fields.push({ - name: '👤 Member', - value: `${newMember.user.tag} (${newMember.user.id})`, - inline: true - }); - - - if (oldMember.nickname !== newMember.nickname) { - fields.push({ - name: '🏷️ Old Nickname', - value: oldMember.nickname || '*(no nickname)*', - inline: true - }); - - fields.push({ - name: '🏷️ New Nickname', - value: newMember.nickname || '*(no nickname)*', - inline: true - }); - - await logEvent({ - client: newMember.client, - guildId: newMember.guild.id, - eventType: EVENT_TYPES.MEMBER_NAME_CHANGE, - data: { - description: `Member nickname changed: ${newMember.user.tag}`, - userId: newMember.user.id, - fields - } - }); - - return; - } - - } catch (error) { - logger.error('Error in guildMemberUpdate event:', error); - } - } -}; +export default { name: Events.GuildMemberUpdate, async execute(before, after) { if (before.nickname !== after.nickname) await logEvent({ client: after.client, guildId: after.guild.id, eventType: EVENT_TYPES.MEMBER_NAME_CHANGE, data: { title: 'כינוי שונה', description: `הכינוי של ${after.user} שונה.`, userId: after.id, fields: [{ name: 'לפני', value: before.nickname || before.user.username, inline: true }, { name: 'אחרי', value: after.nickname || after.user.username, inline: true }] } }); } }; diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js index 450ace66bb..0de1ac0f9b 100644 --- a/src/events/interactionCreate.js +++ b/src/events/interactionCreate.js @@ -1,407 +1,39 @@ import { Events, MessageFlags } from 'discord.js'; import { logger } from '../utils/logger.js'; -import { getGuildConfig } from '../services/guildConfig.js'; -import { handleApplicationModal } from '../commands/Community/apply.js'; -import { handleApplicationReviewModal } from '../commands/Community/app-admin.js'; -import { handleInteractionError, createError, ErrorTypes } from '../utils/errorHandler.js'; -import { MessageTemplates } from '../utils/messageTemplates.js'; -import { InteractionHelper } from '../utils/interactionHelper.js'; -import { createInteractionTraceContext, runWithTraceContext } from '../utils/traceContext.js'; -import { validateChatInputPayloadOrThrow } from '../utils/commandInputValidation.js'; -import { enforceAbuseProtection, formatCooldownDuration } from '../utils/abuseProtection.js'; - -function withTraceContext(context = {}, traceContext = {}) { - return { - traceId: traceContext.traceId, - guildId: context.guildId || traceContext.guildId, - userId: context.userId || traceContext.userId, - command: context.commandName || traceContext.command, - ...context - }; -} +import { createEmbed } from '../utils/embeds.js'; +import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; export default { name: Events.InteractionCreate, async execute(interaction, client) { - const interactionTraceContext = createInteractionTraceContext(interaction); - interaction.traceContext = interactionTraceContext; - interaction.traceId = interactionTraceContext.traceId; - - return runWithTraceContext(interactionTraceContext, async () => { - try { - InteractionHelper.patchInteractionResponses(interaction); - - if (interaction.isChatInputCommand()) { - try { - logger.info(`Command executed: /${interaction.commandName} by ${interaction.user.tag}`, { - event: 'interaction.command.received', - traceId: interactionTraceContext.traceId, - guildId: interaction.guildId, - userId: interaction.user?.id, - command: interaction.commandName - }); - - validateChatInputPayloadOrThrow(interaction, withTraceContext({ - type: 'command_input_validation', - commandName: interaction.commandName - }, interactionTraceContext)); - - const command = client.commands.get(interaction.commandName); - - if (!command) { - throw createError( - `No command matching ${interaction.commandName} was found.`, - ErrorTypes.CONFIGURATION, - 'Sorry, that command does not exist.', - withTraceContext({ commandName: interaction.commandName }, interactionTraceContext) - ); - } - - const abuseProtection = await enforceAbuseProtection(interaction, command, interaction.commandName); - if (!abuseProtection.allowed) { - const formattedCooldown = formatCooldownDuration(abuseProtection.remainingMs); - throw createError( - `Risky command cooldown active for ${interaction.commandName}`, - ErrorTypes.RATE_LIMIT, - `This command is on cooldown. Please wait ${formattedCooldown} before trying again.`, - withTraceContext({ - commandName: interaction.commandName, - subtype: 'command_cooldown', - expected: true, - cooldownMs: abuseProtection.remainingMs, - cooldownWindowMs: abuseProtection.policy?.windowMs, - cooldownMaxAttempts: abuseProtection.policy?.maxAttempts - }, interactionTraceContext) - ); - } - - let guildConfig = null; - if (interaction.guild) { - guildConfig = await getGuildConfig(client, interaction.guild.id, interactionTraceContext); - if (guildConfig?.disabledCommands?.[interaction.commandName]) { - throw createError( - `Command ${interaction.commandName} is disabled in this guild`, - ErrorTypes.CONFIGURATION, - 'This command has been disabled for this server.', - withTraceContext({ commandName: interaction.commandName, guildId: interaction.guild.id }, interactionTraceContext) - ); - } - } - - await command.execute(interaction, guildConfig, client); - } catch (error) { - await handleInteractionError(interaction, error, withTraceContext({ - type: 'command', - commandName: interaction.commandName - }, interactionTraceContext)); - } - } else if (interaction.isAutocomplete()) { - // Handle autocomplete interactions - const focusedOption = interaction.options.getFocused(true); - - if (interaction.commandName === 'apply' && focusedOption.name === 'application') { - try { - const { getApplicationRoles } = await import('../utils/database.js'); - const roles = await getApplicationRoles(client, interaction.guildId); - const roleName = interaction.options.getString('application', false); - - // Filter: only show enabled applications - const filtered = roles.filter(role => - role.enabled !== false && - role.name.toLowerCase().startsWith(roleName?.toLowerCase() || '') - ); - - await interaction.respond( - filtered.slice(0, 25).map(role => ({ - name: `${role.name}${role.enabled === false ? ' (disabled)' : ''}`, - value: role.name - })) - ); - } catch (error) { - logger.error('Error handling autocomplete:', { - error: error.message, - guildId: interaction.guildId, - commandName: interaction.commandName - }); - await interaction.respond([]); - } - } 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 appName = interaction.options.getString('application', false); - - // Show all applications (enabled and disabled), but mark disabled ones - const filtered = roles.filter(role => - role.name.toLowerCase().startsWith(appName?.toLowerCase() || '') - ); - - await interaction.respond( - filtered.slice(0, 25).map(role => ({ - name: `${role.name}${role.enabled === false ? ' (disabled)' : ''}`, - value: role.name - })) - ); - } catch (error) { - logger.error('Error handling app-admin autocomplete:', { - error: error.message, - guildId: interaction.guildId, - commandName: interaction.commandName - }); - await interaction.respond([]); - } - } 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; - - let panels = await getAllReactionRoleMessages(client, guildId); - - if (!panels || panels.length === 0) { - await interaction.respond([]); - return; - } - - // Filter out panels whose messages no longer exist - const validPanels = []; - for (const panel of panels) { - if (!panel.messageId || !panel.channelId) { - continue; - } - - const channel = guild.channels.cache.get(panel.channelId); - if (!channel) { - await deleteReactionRoleMessage(client, guildId, panel.messageId).catch(() => {}); - continue; - } - - const msg = await channel.messages.fetch(panel.messageId).catch(() => null); - if (!msg) { - await deleteReactionRoleMessage(client, guildId, panel.messageId).catch(() => {}); - continue; - } - validPanels.push(panel); - } - - if (validPanels.length === 0) { - await interaction.respond([]); - return; - } - - const choices = await Promise.all( - validPanels.slice(0, 25).map(async panel => { - try { - const channel = guild.channels.cache.get(panel.channelId); - if (!channel) return null; - - const msg = await channel.messages.fetch(panel.messageId).catch(() => null); - if (!msg) return null; - - const title = msg?.embeds?.[0]?.title ?? 'Untitled Panel'; - const channelName = channel?.name ?? 'unknown'; - - return { - name: `${title} (${channelName})`.substring(0, 100), - value: panel.messageId - }; - } catch (e) { - return null; - } - }) - ); - - const validChoices = choices.filter(c => c !== null); - await interaction.respond(validChoices); - } catch (error) { - logger.error('Error handling reactroles autocomplete:', { - error: error.message, - guildId: interaction.guildId, - commandName: interaction.commandName - }); - await interaction.respond([]); - } - } - } else if (interaction.isButton()) { - if (interaction.customId.startsWith('shared_todo_')) { - const parts = interaction.customId.split('_'); - const buttonType = parts.slice(0, 3).join('_'); - const listId = parts[3]; - const button = client.buttons.get(buttonType); - - if (button) { - try { - await button.execute(interaction, client, [listId]); - } catch (error) { - await handleInteractionError(interaction, error, withTraceContext({ - type: 'button', - customId: interaction.customId, - handler: 'todo' - }, interactionTraceContext)); - } - } else { - throw createError( - `No button handler found for ${buttonType}`, - ErrorTypes.CONFIGURATION, - 'This button is not available.', - withTraceContext({ buttonType }, interactionTraceContext) - ); - } - return; - } - - const [customId, ...args] = interaction.customId.split(':'); - const button = client.buttons.get(customId); - - if (!button) { - if (!interaction.customId.includes(':')) { - return; - } - - throw createError( - `No button handler found for ${customId}`, - ErrorTypes.CONFIGURATION, - 'This button is not available.', - withTraceContext({ customId }, interactionTraceContext) - ); - } - - try { - await button.execute(interaction, client, args); - } catch (error) { - await handleInteractionError(interaction, error, withTraceContext({ - type: 'button', - customId: interaction.customId, - handler: 'general' - }, interactionTraceContext)); - } - } else if (interaction.isStringSelectMenu()) { - const [customId, ...args] = interaction.customId.split(':'); - const selectMenu = client.selectMenus.get(customId); - - if (!selectMenu) { - if (!interaction.customId.includes(':')) { - // No registered handler and no ':' delimiter — this is an inline-collected - // select menu (e.g. ticket_config_<guildId>, jointocreate_config_<id>). - // Return silently so the existing MessageComponentCollector handles it. - return; - } - - throw createError( - `No select menu handler found for ${customId}`, - ErrorTypes.CONFIGURATION, - 'This select menu is not available.', - withTraceContext({ customId }, interactionTraceContext) - ); - } - - try { - await selectMenu.execute(interaction, client, args); - } catch (error) { - await handleInteractionError(interaction, error, withTraceContext({ - type: 'select_menu', - customId: interaction.customId - }, interactionTraceContext)); - } - } else if (interaction.isModalSubmit()) { - if (interaction.customId.startsWith('app_modal_')) { - try { - await handleApplicationModal(interaction); - } catch (error) { - await handleInteractionError(interaction, error, withTraceContext({ - type: 'modal', - customId: interaction.customId, - handler: 'application' - }, interactionTraceContext)); - } - return; - } - - if (interaction.customId.startsWith('app_review_')) { - try { - await handleApplicationReviewModal(interaction); - } catch (error) { - await handleInteractionError(interaction, error, withTraceContext({ - type: 'modal', - customId: interaction.customId, - handler: 'application_review' - }, interactionTraceContext)); - } - return; - } - - if (interaction.customId.startsWith('jtc_')) { - logger.debug(`Skipping modal handler lookup for inline-awaited modal: ${interaction.customId}`, { - event: 'interaction.modal.inline_skipped', - traceId: interactionTraceContext.traceId - }); - return; - } - - const [customId, ...args] = interaction.customId.split(':'); - const modal = client.modals.get(customId); - - if (!modal) { - if (!interaction.customId.includes(':')) { - // No registered handler and no ':' delimiter — this is an inline-awaited - // modal (e.g. via awaitModalSubmit). Return silently so the caller handles it. - return; - } - - throw createError( - `No modal handler found for ${customId}`, - ErrorTypes.CONFIGURATION, - 'This form is not available.', - withTraceContext({ customId }, interactionTraceContext) - ); - } - - try { - await modal.execute(interaction, client, args); - } catch (error) { - await handleInteractionError(interaction, error, withTraceContext({ - type: 'modal', - customId: interaction.customId, - handler: 'general' - }, interactionTraceContext)); - } - } - } catch (error) { - logger.error('Unhandled error in interactionCreate:', { - event: 'interaction.unhandled_error', - errorCode: 'INTERACTION_UNHANDLED_ERROR', - error, - traceId: interactionTraceContext.traceId, - interactionId: interaction.id, - guildId: interaction.guildId, - userId: interaction.user?.id - }); - - try { - const ephemeralErrorMessage = { - embeds: [MessageTemplates.ERRORS.DATABASE_ERROR('processing your interaction')], - flags: MessageFlags.Ephemeral - }; - const editErrorMessage = { - embeds: [MessageTemplates.ERRORS.DATABASE_ERROR('processing your interaction')] - }; - - if (interaction.deferred) { - await interaction.editReply(editErrorMessage); - } else if (interaction.replied) { - await interaction.followUp(ephemeralErrorMessage); - } else { - await interaction.reply(ephemeralErrorMessage); - } - } catch (replyError) { - logger.error('Failed to send fallback error response:', { - event: 'interaction.error_response_failed', - errorCode: 'INTERACTION_ERROR_RESPONSE_FAILED', - error: replyError, - traceId: interactionTraceContext.traceId - }); - } + try { + if (interaction.isChatInputCommand()) { + const command = client.commands.get(interaction.commandName); + if (!command) throw new Error(`Unknown command: ${interaction.commandName}`); + await command.execute(interaction, client); + return; } - }); + const registry = interaction.isButton() ? client.buttons + : interaction.isStringSelectMenu() ? client.selectMenus + : interaction.isModalSubmit() ? client.modals : null; + if (!registry) return; + const [name, ...args] = interaction.customId.split(':'); + const handler = registry.get(name); + if (handler) await handler.execute(interaction, client, args); + } catch (error) { + logger.error('Interaction failed', { error: error.stack || error.message, id: interaction.id }); + if (interaction.guildId) await logEvent({ client, guildId: interaction.guildId, eventType: EVENT_TYPES.COMMAND_ERROR, data: { + title: 'שגיאה בביצוע פקודה', + description: 'אירעה שגיאה פנימית. הפרטים הטכניים נשמרו בלוג המסוף.', + userId: interaction.user?.id, channelId: interaction.channelId, fields: [ + { name: 'פקודה', value: interaction.commandName || interaction.customId || 'לא ידוע', inline: true }, + { name: 'משתמש', value: interaction.user ? `${interaction.user} (\`${interaction.user.id}\`)` : 'לא ידוע', inline: true }, + { name: 'ערוץ', value: interaction.channelId ? `<#${interaction.channelId}>` : '—', inline: true }, + ] + }}); + const payload = { embeds: [createEmbed({ title: 'שגיאה', description: 'אירעה שגיאה בעת עיבוד הבקשה. נסו שוב מאוחר יותר.', color: 'error' })], flags: MessageFlags.Ephemeral }; + if (interaction.deferred || interaction.replied) await interaction.followUp(payload).catch(() => {}); + else await interaction.reply(payload).catch(() => {}); + } } }; diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index b7f9841dc4..b523c8e716 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -1,117 +1,36 @@ - - - - - import { Events } from 'discord.js'; -import { logger } from '../utils/logger.js'; -import { getLevelingConfig, getUserLevelData } from '../services/leveling.js'; -import { addXp } from '../services/xpSystem.js'; -import { checkRateLimit } from '../utils/rateLimiter.js'; - -const MESSAGE_XP_RATE_LIMIT_ATTEMPTS = 12; -const MESSAGE_XP_RATE_LIMIT_WINDOW_MS = 10000; - -export default { - name: Events.MessageCreate, - async execute(message, client) { - try { - - if (message.author.bot || !message.guild) return; - - await handleLeveling(message, client); - } catch (error) { - logger.error('Error in messageCreate event:', error); - } - } -}; - - - - - - - - -async function handleLeveling(message, client) { - 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); - if (!canProcess) { - return; - } - - const levelingConfig = await getLevelingConfig(client, message.guild.id); - - if (!levelingConfig?.enabled) { - return; - } - - - if (levelingConfig.ignoredChannels?.includes(message.channel.id)) { - return; - } - - - if (levelingConfig.ignoredRoles?.length > 0) { - const member = await message.guild.members.fetch(message.author.id).catch(() => { - return null; - }); - if (member && member.roles.cache.some(role => levelingConfig.ignoredRoles.includes(role.id))) { - return; - } - } - - - if (levelingConfig.blacklistedUsers?.includes(message.author.id)) { - return; - } - - - if (!message.content || message.content.trim().length === 0) { - return; - } - - const userData = await getUserLevelData(client, message.guild.id, message.author.id); - - - const cooldownTime = levelingConfig.xpCooldown || 60; - const now = Date.now(); - const timeSinceLastMessage = now - (userData.lastMessage || 0); - - - if (timeSinceLastMessage < cooldownTime * 1000) { - return; - } - - - const minXP = levelingConfig.xpRange?.min || levelingConfig.xpPerMessage?.min || 15; - const maxXP = levelingConfig.xpRange?.max || levelingConfig.xpPerMessage?.max || 25; - - - const safeMinXP = Math.max(1, minXP); - const safeMaxXP = Math.max(safeMinXP, maxXP); - - - const xpToGive = Math.floor(Math.random() * (safeMaxXP - safeMinXP + 1)) + safeMinXP; - - - let finalXP = xpToGive; - if (levelingConfig.xpMultiplier && levelingConfig.xpMultiplier > 1) { - finalXP = Math.floor(finalXP * levelingConfig.xpMultiplier); - } - - - const result = await addXp(client, message.guild, message.member, finalXP); - - if (result.success && result.leveledUp) { - logger.info( - `${message.author.tag} leveled up to level ${result.level} in ${message.guild.name}` - ); - } - } catch (error) { - logger.error('Error handling leveling for message:', error); +import { getConfig, levelKey } from '../modules/community/store.js'; +import { createEmbed } from '../utils/embeds.js'; +import { handleOwnerInboxReply } from '../services/ownerInboxService.js'; +import { clampCommunityXp, communityLevelFromXp, MAX_LEVEL } from '../utils/levelLimits.js'; + +export default { name: Events.MessageCreate, async execute(message) { + if (!message.author.bot && await handleOwnerInboxReply(message)) return; + if (!message.guild || message.author.bot || message.webhookId) return; + + // Normal messages are used for leveling only. The logging handler records + // message edits and deletions, so the log channel is not flooded by chat. + const config = await getConfig(message.client, message.guild.id); + if (!config.leveling.enabled || !message.content.trim()) return; + + const id = levelKey(message.guild.id, message.author.id); + const user = await message.client.db.get(id, { xp: 0, level: 0, last: 0 }); + if (Date.now() - user.last < config.leveling.cooldownMs) return; + if (communityLevelFromXp(user.xp) >= MAX_LEVEL) return; + + user.xp = clampCommunityXp(user.xp + config.leveling.xpMin + Math.floor(Math.random() * (config.leveling.xpMax - config.leveling.xpMin + 1))); + user.last = Date.now(); + const level = communityLevelFromXp(user.xp); + const changed = level > user.level; + user.level = level; + await message.client.db.set(id, user); + + if (changed) { + const channel = message.guild.channels.cache.get(config.leveling.announceChannelId) || message.channel; + await channel.send({ embeds: [createEmbed({ + title: 'עלית רמה!', + description: `${message.author}, הגעת לרמה **${level}**.`, + color: 'success', + })] }); } -} - - +} }; diff --git a/src/events/messageDelete.js b/src/events/messageDelete.js index cc737ba06c..e3196657d6 100644 --- a/src/events/messageDelete.js +++ b/src/events/messageDelete.js @@ -1,128 +1,6 @@ import { Events } from 'discord.js'; import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; -import { logger } from '../utils/logger.js'; -import { getReactionRoleMessage, deleteReactionRoleMessage } from '../services/reactionRoleService.js'; - -const MAX_LOGGED_MESSAGE_CONTENT_LENGTH = 1024; - -export default { - name: Events.MessageDelete, - once: false, - - async execute(message) { - try { - if (!message.guild) return; - - try { - const reactionRoleData = await getReactionRoleMessage(message.client, message.guild.id, message.id); - if (reactionRoleData) { - await deleteReactionRoleMessage(message.client, message.guild.id, message.id); - logger.info(`Cleaned up reaction role database entry for manually deleted message ${message.id} in guild ${message.guild.id}`); - - try { - await logEvent({ - client: message.client, - guildId: message.guild.id, - eventType: EVENT_TYPES.REACTION_ROLE_DELETE, - data: { - description: `Reaction role message was deleted manually and removed from database.`, - channelId: message.channel?.id, - fields: [ - { - name: '🗑️ Message ID', - value: message.id, - inline: true - }, - { - name: '📍 Channel', - value: message.channel ? `${message.channel.toString()} (${message.channel.id})` : 'Unknown', - inline: true - }, - { - name: '🧹 Cleanup', - value: 'Database entry removed automatically', - inline: false - } - ] - } - }); - } catch (logCleanupError) { - logger.warn('Failed to log reaction role cleanup after manual message deletion:', logCleanupError); - } - } - } catch (reactionRoleCleanupError) { - logger.warn(`Failed to clean up reaction role data for deleted message ${message.id}:`, reactionRoleCleanupError); - } - - if (message.author?.bot) return; - - const fields = []; - - - if (message.author) { - fields.push({ - name: '👤 Author', - value: `${message.author.tag} (${message.author.id})`, - inline: true - }); - } - - - fields.push({ - name: '💬 Channel', - value: `${message.channel.toString()} (${message.channel.id})`, - inline: true - }); - - - if (message.content) { - const content = message.content.length > MAX_LOGGED_MESSAGE_CONTENT_LENGTH - ? message.content.substring(0, MAX_LOGGED_MESSAGE_CONTENT_LENGTH - 3) + '...' - : message.content; - fields.push({ - name: '📝 Content', - value: content || '*(empty message)*', - inline: false - }); - } - - - fields.push({ - name: '🆔 Message ID', - value: message.id, - inline: true - }); - - - fields.push({ - name: '📅 Created', - value: `<t:${Math.floor(message.createdTimestamp / 1000)}:R>`, - inline: true - }); - - - if (message.attachments.size > 0) { - fields.push({ - name: '📎 Attachments', - value: message.attachments.size.toString(), - inline: true - }); - } - - await logEvent({ - client: message.client, - guildId: message.guild.id, - eventType: EVENT_TYPES.MESSAGE_DELETE, - data: { - description: `A message was deleted in ${message.channel.toString()}`, - userId: message.author?.id, - channelId: message.channel.id, - fields - } - }); - - } catch (error) { - logger.error('Error in messageDelete event:', error); - } - } -}; +export default { name: Events.MessageDelete, async execute(message) { + if (!message.guild || message.author?.bot) return; + await logEvent({ client: message.client, guildId: message.guild.id, eventType: EVENT_TYPES.MESSAGE_DELETE, data: { title: 'הודעה נמחקה', description: message.content || 'תוכן ההודעה אינו זמין.', userId: message.author?.id, channelId: message.channelId, fields: [{ name: 'משתמש', value: message.author ? `${message.author} (\`${message.author.id}\`)` : 'לא ידוע', inline: true }, { name: 'ערוץ', value: `<#${message.channelId}>`, inline: true }] } }); +} }; diff --git a/src/events/messageDeleteBulk.js b/src/events/messageDeleteBulk.js new file mode 100644 index 0000000000..d741ee37b9 --- /dev/null +++ b/src/events/messageDeleteBulk.js @@ -0,0 +1,3 @@ +import { Events } from 'discord.js'; +import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; +export default { name: Events.MessageBulkDelete, async execute(messages, channel) { if (channel.guild) await logEvent({ client: channel.client, guildId: channel.guild.id, eventType: EVENT_TYPES.MESSAGE_BULK_DELETE, data: { title: 'מחיקת הודעות', description: `נמחקו **${messages.size}** הודעות.`, channelId: channel.id, fields: [{ name: 'ערוץ', value: `${channel}` }] } }); } }; diff --git a/src/events/messageUpdate.js b/src/events/messageUpdate.js index 2258b47f5d..c2cbbf1c09 100644 --- a/src/events/messageUpdate.js +++ b/src/events/messageUpdate.js @@ -1,81 +1,6 @@ import { Events } from 'discord.js'; import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; -import { logger } from '../utils/logger.js'; - -const MAX_LOGGED_EDIT_CONTENT_LENGTH = 512; - -export default { - name: Events.MessageUpdate, - once: false, - - async execute(oldMessage, newMessage) { - try { - if (!newMessage.guild || newMessage.author?.bot) return; - - - if (oldMessage.content === newMessage.content) return; - - const fields = []; - - - if (newMessage.author) { - fields.push({ - name: '👤 Author', - value: `${newMessage.author.tag} (${newMessage.author.id})`, - inline: true - }); - } - - - fields.push({ - name: '💬 Channel', - value: `${newMessage.channel.toString()} (${newMessage.channel.id})`, - inline: true - }); - - - const oldContent = oldMessage.content || '*(empty message)*'; - const oldContentTruncated = oldContent.length > MAX_LOGGED_EDIT_CONTENT_LENGTH - ? oldContent.substring(0, MAX_LOGGED_EDIT_CONTENT_LENGTH - 3) + '...' - : oldContent; - fields.push({ - name: '📝 Old Content', - value: oldContentTruncated, - inline: false - }); - - - const newContent = newMessage.content || '*(empty message)*'; - const newContentTruncated = newContent.length > MAX_LOGGED_EDIT_CONTENT_LENGTH - ? newContent.substring(0, MAX_LOGGED_EDIT_CONTENT_LENGTH - 3) + '...' - : newContent; - fields.push({ - name: '📝 New Content', - value: newContentTruncated, - inline: false - }); - - - fields.push({ - name: '🆔 Message ID', - value: newMessage.id, - inline: true - }); - - await logEvent({ - client: newMessage.client, - guildId: newMessage.guild.id, - eventType: EVENT_TYPES.MESSAGE_EDIT, - data: { - description: `A message was edited in ${newMessage.channel.toString()}`, - userId: newMessage.author?.id, - channelId: newMessage.channel.id, - fields - } - }); - - } catch (error) { - logger.error('Error in messageUpdate event:', error); - } - } -}; +export default { name: Events.MessageUpdate, async execute(oldMessage, newMessage) { + if (!newMessage.guild || newMessage.author?.bot || oldMessage.content === newMessage.content) return; + await logEvent({ client: newMessage.client, guildId: newMessage.guild.id, eventType: EVENT_TYPES.MESSAGE_EDIT, data: { title: 'הודעה נערכה', userId: newMessage.author?.id, channelId: newMessage.channelId, description: `[מעבר להודעה](${newMessage.url})`, fields: [{ name: 'לפני', value: oldMessage.content || '—' }, { name: 'אחרי', value: newMessage.content || '—' }, { name: 'ערוץ', value: `<#${newMessage.channelId}>`, inline: true }] } }); +} }; diff --git a/src/events/ready.js b/src/events/ready.js index 8f3db50966..b40d00e93e 100644 --- a/src/events/ready.js +++ b/src/events/ready.js @@ -1,28 +1,16 @@ -import { Events } from "discord.js"; -import { logger, startupLog } from "../utils/logger.js"; -import config from "../config/application.js"; -import { reconcileReactionRoleMessages } from "../services/reactionRoleService.js"; - -export default { - name: Events.ClientReady, - once: true, - - async execute(client) { - try { - client.user.setPresence(config.bot.presence); - - startupLog(`Ready! Logged in as ${client.user.tag}`); - startupLog(`Serving ${client.guilds.cache.size} guild(s)`); - startupLog(`Loaded ${client.commands.size} commands`); - - const reconciliationSummary = await reconcileReactionRoleMessages(client); - startupLog( - `Reaction role reconciliation: scanned ${reconciliationSummary.scannedMessages}, removed ${reconciliationSummary.removedMessages}, errors ${reconciliationSummary.errors}` - ); - } catch (error) { - logger.error("Error in ready event:", error); - } - }, -}; - - +import { Events } from 'discord.js'; +import { startupLog } from '../utils/logger.js'; +import { resumeCommunityPolls } from '../services/communityPollService.js'; +import { validateSavedRolePanels } from '../services/roleSystemService.js'; +import { validateSavedTickets } from '../services/ticketSystemService.js'; +import { runStartupUpdateCheck } from '../services/botUpdateService.js'; +import { retryPendingOwnerInboxCases } from '../services/ownerInboxService.js'; +export default { name: Events.ClientReady, once: true, async execute(client) { + client.user.setPresence(client.config.bot.presence); + await resumeCommunityPolls(client); + await validateSavedRolePanels(client); + await validateSavedTickets(client); + void runStartupUpdateCheck(client); + void retryPendingOwnerInboxCases(client); + startupLog(`Ready as ${client.user.tag}; serving ${client.guilds.cache.size} server(s).`); +} }; diff --git a/src/events/roleCreate.js b/src/events/roleCreate.js deleted file mode 100644 index 7a010946d9..0000000000 --- a/src/events/roleCreate.js +++ /dev/null @@ -1,30 +0,0 @@ -import { Events } from 'discord.js'; -import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; -import { logger } from '../utils/logger.js'; -import { buildRoleAuditFields } from '../utils/roleLogFields.js'; - -export default { - name: Events.GuildRoleCreate, - once: false, - - async execute(role) { - try { - if (!role.guild) return; - - const fields = buildRoleAuditFields(role); - - await logEvent({ - client: role.client, - guildId: role.guild.id, - eventType: EVENT_TYPES.ROLE_CREATE, - data: { - description: `A new role was created: ${role.toString()}`, - fields - } - }); - - } catch (error) { - logger.error('Error in roleCreate event:', error); - } - } -}; diff --git a/src/events/roleDelete.js b/src/events/roleDelete.js deleted file mode 100644 index 113452af4b..0000000000 --- a/src/events/roleDelete.js +++ /dev/null @@ -1,30 +0,0 @@ -import { Events } from 'discord.js'; -import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; -import { logger } from '../utils/logger.js'; -import { buildRoleAuditFields } from '../utils/roleLogFields.js'; - -export default { - name: Events.GuildRoleDelete, - once: false, - - async execute(role) { - try { - if (!role.guild) return; - - const fields = buildRoleAuditFields(role, { includeMemberCount: true }); - - await logEvent({ - client: role.client, - guildId: role.guild.id, - eventType: EVENT_TYPES.ROLE_DELETE, - data: { - description: `A role was deleted: ${role.name}`, - fields - } - }); - - } catch (error) { - logger.error('Error in roleDelete event:', error); - } - } -}; diff --git a/src/events/userUpdate.js b/src/events/userUpdate.js deleted file mode 100644 index 6d21c5ad3d..0000000000 --- a/src/events/userUpdate.js +++ /dev/null @@ -1,74 +0,0 @@ -import { Events } from 'discord.js'; -import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; -import { logger } from '../utils/logger.js'; - -export default { - name: Events.UserUpdate, - once: false, - - async execute(oldUser, newUser) { - try { - if (oldUser.bot) return; - - const usernameChanged = oldUser.username !== newUser.username; - const discriminatorChanged = oldUser.discriminator !== newUser.discriminator; - - if (!usernameChanged && !discriminatorChanged) return; - - const fields = []; - - if (usernameChanged) { - fields.push({ - name: '🏷️ Old Username', - value: oldUser.username, - inline: true - }); - fields.push({ - name: '🏷️ New Username', - value: newUser.username, - inline: true - }); - } - - if (discriminatorChanged) { - fields.push({ - name: '🔢 Old Tag', - value: `#${oldUser.discriminator}`, - inline: true - }); - fields.push({ - name: '🔢 New Tag', - value: `#${newUser.discriminator}`, - inline: true - }); - } - - const guilds = [...newUser.client.guilds.cache.values()]; - for (const guild of guilds) { - if (!guild.members.cache.has(newUser.id)) continue; - - await logEvent({ - client: newUser.client, - guildId: guild.id, - eventType: EVENT_TYPES.MEMBER_NAME_CHANGE, - data: { - description: `${newUser.tag} updated their username`, - userId: newUser.id, - fields: [ - { - name: '👤 User', - value: `${newUser.tag} (${newUser.id})`, - inline: true - }, - ...fields - ] - } - }); - } - - logger.debug(`Processed userUpdate for ${newUser.id} across ${guilds.length} guild(s)`); - } catch (error) { - logger.error('Error in userUpdate event:', error); - } - } -}; diff --git a/src/events/voiceStateUpdate.js b/src/events/voiceStateUpdate.js deleted file mode 100644 index 15a5268de2..0000000000 --- a/src/events/voiceStateUpdate.js +++ /dev/null @@ -1,313 +0,0 @@ -import { ChannelType, PermissionFlagsBits } from 'discord.js'; -import { - getJoinToCreateConfig, - registerTemporaryChannel, - unregisterTemporaryChannel, - getTemporaryChannelInfo, - formatChannelName -} from '../utils/database.js'; -import { sanitizeInput } from '../utils/sanitization.js'; -import { logger } from '../utils/logger.js'; - -const channelCreationCooldown = new Map(); -const VOICE_CREATE_COOLDOWN_MS = 2000; -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 MAX_TRACKED_COOLDOWNS = 10000; - -export default { - name: 'voiceStateUpdate', - async execute(oldState, newState, client) { - if (newState.member.user.bot) return; - - const guildId = newState.guild.id; - const userId = newState.member.id; - const cooldownKey = `${guildId}-${userId}`; - cleanupCooldownEntries(); - - try { - const config = await getJoinToCreateConfig(client, guildId); - - if (!config.enabled || config.triggerChannels.length === 0) { - return; - } - - if (!oldState.channel && newState.channel) { - await handleVoiceJoin(client, newState, config); - } - - if (oldState.channel && !newState.channel) { - await handleVoiceLeave(client, oldState, config); - } - - if (oldState.channel && newState.channel && oldState.channel.id !== newState.channel.id) { - await handleVoiceMove(client, oldState, newState, config); - } - - } catch (error) { - logger.error(`Error in voiceStateUpdate for guild ${guildId}:`, error); - } - - async function handleVoiceJoin(client, state, config) { - const { channel, member } = state; - - if (!config.triggerChannels.includes(channel.id)) { - return; - } - - const now = Date.now(); - if (channelCreationCooldown.has(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; - } - } - - const existingTempChannel = Object.keys(config.temporaryChannels || {}).find( - tempChannelId => { - const tempInfo = config.temporaryChannels[tempChannelId]; - return tempInfo && tempInfo.ownerId === member.id; - } - ); - - if (existingTempChannel) { - const tempChannel = state.guild.channels.cache.get(existingTempChannel); - if (tempChannel) { - try { - await member.voice.setChannel(tempChannel); - return; - } catch (error) { - logger.warn(`Failed to move user ${member.id} to existing channel ${existingTempChannel}:`, error); - } - } - } - - if (member.voice.channel?.id !== channel.id) { - return; - } - - channelCreationCooldown.set(cooldownKey, now); - trimCooldownMapIfNeeded(); - - await createTemporaryChannel(client, state, config); - } - - async function handleVoiceLeave(client, state, config) { - const { channel, member } = state; - - const tempChannelInfo = await getTemporaryChannelInfo(client, state.guild.id, channel.id); - - if (!tempChannelInfo) { - return; - } - - if (channel.members.size === 0) { - await deleteTemporaryChannel(client, channel, state.guild.id); - } else if (tempChannelInfo.ownerId === member.id) { - const nextMember = channel.members.first(); - if (nextMember) { - await transferChannelOwnership(client, channel, state.guild.id, nextMember.id); - } - } - } - - async function handleVoiceMove(client, oldState, newState, config) { - 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) { - const nextMember = oldState.channel.members.first(); - if (nextMember) { - await transferChannelOwnership(client, oldState.channel, oldState.guild.id, nextMember.id); - } - } - } - } - - if (config.triggerChannels.includes(newState.channel.id) && - !config.triggerChannels.includes(oldState.channel?.id)) { - await handleVoiceJoin(client, newState, config); - } - } - - async function createTemporaryChannel(client, state, config) { - const { channel: triggerChannel, member, guild } = state; - - try { - const me = guild.members.me; - if (!me) { - logger.warn(`Bot member cache unavailable while creating temporary channel in guild ${guild.id}`); - channelCreationCooldown.delete(cooldownKey); - return; - } - - const triggerPermissions = triggerChannel.permissionsFor(me); - if (!triggerPermissions?.has([PermissionFlagsBits.ManageChannels, PermissionFlagsBits.MoveMembers, PermissionFlagsBits.Connect])) { - logger.warn(`Missing required permissions for temporary channel creation in guild ${guild.id} (trigger channel ${triggerChannel.id})`); - channelCreationCooldown.delete(cooldownKey); - return; - } - - const channelOptions = config.channelOptions?.[triggerChannel.id] || {}; - const nameTemplate = channelOptions.nameTemplate || config.channelNameTemplate || "{username}'s Room"; - - let userLimit = channelOptions.userLimit ?? config.userLimit ?? 0; - const bitrate = clampVoiceBitrate(channelOptions.bitrate ?? config.bitrate ?? DEFAULT_VOICE_BITRATE); - - userLimit = Math.max(0, Math.min(99, userLimit || 0)); - - logger.info(`Creating temporary channel for user ${member.id} with user limit: ${userLimit}`); - - const channelName = sanitizeVoiceChannelName(formatChannelName(nameTemplate, { - username: member.user.username, - userTag: member.user.tag, - displayName: member.displayName, - guildName: guild.name, - channelName: triggerChannel.name - })); - - if (!member.voice?.channel || member.voice.channel.id !== triggerChannel.id) { - logger.debug(`Member ${member.id} no longer in trigger channel ${triggerChannel.id}, aborting temporary channel creation`); - channelCreationCooldown.delete(cooldownKey); - return; - } - - const tempChannel = await guild.channels.create({ - name: channelName, -type: ChannelType.GuildVoice, - parent: triggerChannel.parentId, -userLimit: userLimit === 0 ? undefined : userLimit, - bitrate: bitrate, - permissionOverwrites: [ - { - id: member.id, - allow: ['Connect', 'Speak', 'PrioritySpeaker', 'MoveMembers'] - }, - { - id: guild.id, - allow: ['Connect', 'Speak'] - } - ] - }); - - await registerTemporaryChannel(client, guild.id, tempChannel.id, member.id, triggerChannel.id); - - if (member.voice?.channel?.id === triggerChannel.id) { - await member.voice.setChannel(tempChannel); - } else { - logger.debug(`Skipped moving ${member.id} to temporary channel ${tempChannel.id} because voice state changed`); - } - - logger.info(`Created temporary voice channel ${tempChannel.name} (${tempChannel.id}) for user ${member.user.tag} in guild ${guild.name} with user limit ${userLimit}`); - - } catch (error) { - logger.error(`Failed to create temporary channel for user ${member.user.tag} in guild ${guild.name}:`, error); - - channelCreationCooldown.delete(cooldownKey); - - try { - await member.send({ - content: `❌ Failed to create your temporary voice channel. Please contact a server administrator.` - }); - } catch (dmError) { - logger.debug(`Unable to send temporary channel failure DM to user ${member.id}:`, dmError); - } - } - } - - async function deleteTemporaryChannel(client, channel, guildId) { - try { - await unregisterTemporaryChannel(client, guildId, channel.id); - - await channel.delete('Temporary voice channel - empty'); - - logger.info(`Deleted temporary voice channel ${channel.name} (${channel.id}) in guild ${channel.guild.name}`); - - } catch (error) { - logger.error(`Failed to delete temporary channel ${channel.id}:`, error); - } - } - - async function transferChannelOwnership(client, channel, guildId, newOwnerId) { - try { - const config = await getJoinToCreateConfig(client, guildId); - const tempChannelInfo = config.temporaryChannels[channel.id]; - - if (!tempChannelInfo) return; - - config.temporaryChannels[channel.id].ownerId = newOwnerId; - await client.db.set(`guild:${guildId}:jointocreate`, config); - - const newOwner = await channel.guild.members.fetch(newOwnerId); - if (newOwner) { - const channelOptions = config.channelOptions?.[tempChannelInfo.triggerChannelId] || {}; - const nameTemplate = channelOptions.nameTemplate || config.channelNameTemplate; - - const newChannelName = sanitizeVoiceChannelName(formatChannelName(nameTemplate, { - username: newOwner.user.username, - userTag: newOwner.user.tag, - displayName: newOwner.displayName, - guildName: channel.guild.name, - channelName: channel.guild.channels.cache.get(tempChannelInfo.triggerChannelId)?.name || 'Voice Channel' - })); - - await channel.setName(newChannelName); - } - - logger.info(`Transferred ownership of temporary channel ${channel.id} to user ${newOwnerId}`); - - } catch (error) { - logger.error(`Failed to transfer ownership of channel ${channel.id}:`, error); - } - } - } -}; - -function sanitizeVoiceChannelName(inputName) { - const safeName = sanitizeInput(String(inputName || ''), MAX_CHANNEL_NAME_LENGTH) - .replace(/[\r\n\t]/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - - return safeName || FALLBACK_CHANNEL_NAME; -} - -function clampVoiceBitrate(value) { - const parsed = Number(value); - if (!Number.isFinite(parsed)) { - return DEFAULT_VOICE_BITRATE; - } - - return Math.max(MIN_VOICE_BITRATE, Math.min(MAX_VOICE_BITRATE, Math.floor(parsed))); -} - -function cleanupCooldownEntries() { - const now = Date.now(); - for (const [key, timestamp] of channelCreationCooldown.entries()) { - if (now - timestamp >= VOICE_CREATE_COOLDOWN_MS) { - channelCreationCooldown.delete(key); - } - } -} - -function trimCooldownMapIfNeeded() { - if (channelCreationCooldown.size <= MAX_TRACKED_COOLDOWNS) { - return; - } - - const entries = [...channelCreationCooldown.entries()].sort((a, b) => a[1] - b[1]); - const removeCount = channelCreationCooldown.size - MAX_TRACKED_COOLDOWNS; - for (let index = 0; index < removeCount; index += 1) { - channelCreationCooldown.delete(entries[index][0]); - } -} - - - diff --git a/src/handlers/commandLoader.js b/src/handlers/commandLoader.js index 5273f2934d..b582c8f751 100644 --- a/src/handlers/commandLoader.js +++ b/src/handlers/commandLoader.js @@ -51,7 +51,7 @@ async function getAllFiles(directory, fileList = []) { continue; } await getAllFiles(filePath, fileList); - } else if (file.name.endsWith('.js')) { + } else if (file.name.endsWith('.js') && file.name !== 'factory.js') { fileList.push(filePath); } } @@ -66,6 +66,7 @@ async function getAllFiles(directory, fileList = []) { export async function loadCommands(client) { client.commands = new Collection(); + client.failedCommands = new Collection(); const commandsPath = path.join(__dirname, '../commands'); const commandFiles = await getAllFiles(commandsPath); @@ -94,11 +95,11 @@ export async function loadCommands(client) { const primaryCommandName = command.data.name; - if (!uniqueCommandNames.has(primaryCommandName)) { - uniqueCommandNames.add(primaryCommandName); - - client.commands.set(primaryCommandName, command); + if (uniqueCommandNames.has(primaryCommandName)) { + throw new Error(`Duplicate command name "${primaryCommandName}" in ${normalizedPath}`); } + uniqueCommandNames.add(primaryCommandName); + client.commands.set(primaryCommandName, command); const subcommands = getSubcommandInfo(command.data.toJSON()); @@ -109,6 +110,7 @@ export async function loadCommands(client) { } } catch (error) { + client.failedCommands.set(filePath, error.message); logger.error(`Error loading command from ${filePath}:`, error); } } @@ -154,6 +156,9 @@ const registeredNames = new Set(); if (!registeredNames.has(commandName)) { registeredNames.add(commandName); const commandJson = command.data.toJSON(); + // Allow user-installed app usage in servers (0), bot DMs (1), and group DMs (2) + commandJson.integration_types = [0, 1]; + commandJson.contexts = [0, 1, 2]; commands.push(commandJson); const subcommands = getSubcommandInfo(commandJson); @@ -171,15 +176,22 @@ const registeredNames = new Set(); } const totalCommandsWithSubs = commands.length + totalSubcommands; - + + const MAX_COMMANDS = 100; + let commandsToRegister = commands; + if (commands.length > MAX_COMMANDS) { + logger.warn(`Command count (${commands.length}) exceeds Discord limit (${MAX_COMMANDS}), truncating...`); + commandsToRegister = commands.slice(0, MAX_COMMANDS); + } + if (guildId) { - - logger.info(`Preparing to register ${totalCommandsWithSubs} commands for guild ${guildId}`); - + + logger.info(`Preparing to register ${commandsToRegister.length} commands for guild ${guildId}`); + logger.info('Validating commands before registration...'); - + let validationErrors = []; - commands.forEach((cmd, index) => { + commandsToRegister.forEach((cmd, index) => { if (cmd.name && cmd.name.length > 32) { validationErrors.push(`Command ${cmd.name} has name longer than 32 chars: "${cmd.name}" (${cmd.name.length} chars)`); } @@ -245,15 +257,6 @@ const registeredNames = new Set(); const existingCommands = await guild.commands.fetch(); logger.info(`Found ${existingCommands.size} existing guild commands`); - const MAX_COMMANDS = 100; - let commandsToRegister = commands; - - if (commands.length > MAX_COMMANDS) { - logger.warn(`Command count (${commands.length}) exceeds Discord limit (${MAX_COMMANDS}), truncating...`); - commandsToRegister = commands.slice(0, MAX_COMMANDS); - logger.info(`Truncated to ${commandsToRegister.length} commands for registration`); - } - if (process.env.NODE_ENV !== 'production') { logger.info(`Registering ${totalCommandsWithSubs} commands for guild ${guild.name} (${guild.id})`); } @@ -288,7 +291,12 @@ const registeredNames = new Set(); throw error; } } else { - logger.info('Skipping global command registration - bot is guild-only'); + logger.info(`Registering ${commandsToRegister.length} commands globally...`); + + await client.application.commands.set(commandsToRegister); + + logger.info(`Successfully registered ${commandsToRegister.length} global commands`); + logger.info('Note: global commands can take up to 1 hour to appear in all servers'); } } catch (error) { logger.error('Error registering commands:', error); @@ -315,7 +323,8 @@ export async function reloadCommand(client, commandName) { moduleUrl.searchParams.set('t', Date.now().toString()); const newCommand = (await import(moduleUrl.href)).default; - + newCommand.category = command.category; + newCommand.filePath = command.filePath; client.commands.set(commandName, newCommand); logger.info(`Reloaded command: ${commandName}`); @@ -325,5 +334,3 @@ export async function reloadCommand(client, commandName) { return { success: false, message: `Error reloading command: ${error.message}` }; } } - - diff --git a/src/handlers/events.js b/src/handlers/events.js index 87a42befca..2fff3cf5fc 100644 --- a/src/handlers/events.js +++ b/src/handlers/events.js @@ -1,43 +1,15 @@ -import { readdir } from 'fs/promises'; import { join } from 'path'; -import { fileURLToPath } from 'url'; -import { dirname } from 'path'; +import { pathToFileURL } from 'url'; import { logger } from '../utils/logger.js'; +import registerServerLogging from './serverLogging.js'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - +const eventNames = ['ready.js', 'interactionCreate.js', 'guildMemberAdd.js', 'messageCreate.js']; export default async function loadEvents(client) { - const eventsPath = join(__dirname, '../events'); - const eventFiles = await readdir(eventsPath).then(files => files.filter(file => file.endsWith('.js'))); - - for (const file of eventFiles) { - const filePath = join(eventsPath, file); - try { - const { default: event } = await import(`file://${filePath}`); - - if (!event?.name || typeof event.execute !== 'function') { - logger.warn(`Event ${file} is missing required "name" or "execute" properties.`); - continue; - } - - const safeExecute = async (...args) => { - try { - await event.execute(...args, client); - } catch (error) { - logger.error(`Error executing event ${event.name}:`, error); - } - }; - - if (event.once) { - client.once(event.name, safeExecute); - } else { - client.on(event.name, safeExecute); - } - } catch (error) { - logger.error(`Error loading event ${file}:`, error); - } - } + registerServerLogging(client); + const root = new URL('../events/', import.meta.url); + for (const filename of eventNames) { + const event = (await import(new URL(filename, root).href)).default; + const safeExecute = (...args) => event.execute(...args, client).catch(error => logger.error(`Event ${event.name} failed`, error)); + client[event.once ? 'once' : 'on'](event.name, safeExecute); + } } - - diff --git a/src/handlers/helpButtons.js b/src/handlers/helpButtons.js index 0c989452a2..c66940c251 100644 --- a/src/handlers/helpButtons.js +++ b/src/handlers/helpButtons.js @@ -1,13 +1,32 @@ -import { createEmbed } from '../utils/embeds.js'; +import { MessageFlags } from 'discord.js'; import { createAllCommandsMenu } from './helpSelectMenus.js'; import { createInitialHelpMenu } from '../commands/Core/help.js'; -import { ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js'; +import { createEmbed } from '../utils/embeds.js'; import { logger } from '../utils/logger.js'; +const VOICE_CMD_DETAILS = { + activity: { usage: '>activity [type]', desc: 'Start a Discord Activity in your voice channel.\nAvailable types: `youtube`, `poker`, `chess`, `checkers`, `letter-league`, `spellcast`, `sketch`, `blazing8s`, `puttparty`, `landio`, `bobble`, `knowwhat`\n\n**Requires:** Create Invite in the voice channel' }, + vcmute: { usage: '>vcmute @user', desc: 'Server-mute a user so they cannot speak in voice.\n\n**Requires:** Mute Members' }, + vcunmute: { usage: '>vcunmute @user', desc: 'Remove server-mute from a user in voice.\n\n**Requires:** Mute Members' }, + vcdeafen: { usage: '>vcdeafen @user', desc: 'Server-deafen a user so they cannot hear in voice.\n\n**Requires:** Deafen Members' }, + vcundeafen: { usage: '>vcundeafen @user', desc: 'Remove server-deafen from a user in voice.\n\n**Requires:** Deafen Members' }, + drag: { usage: '>drag @user', desc: 'Pull a user from their voice channel into yours.\n\n**Requires:** Move Members' }, + moveall: { usage: '>moveall #channel', desc: 'Move all members from your current VC to another voice channel.\n\n**Requires:** Move Members' }, + vcname: { usage: '>vcname <new name>', desc: 'Rename your current voice channel.\n\n**Requires:** Manage Channels' }, + vclimit: { usage: '>vclimit <0-99>', desc: 'Set the user limit for your VC. Use `0` for unlimited.\n\n**Requires:** Manage Channels' }, + vcdisconnect: { usage: '>vcdisconnect @user', desc: 'Disconnect a user from voice. Alias: `>vckick`\n\n**Requires:** Move Members' }, + vclock: { usage: '>vclock', desc: 'Lock your VC so no new members can join.\n\n**Requires:** Manage Channels' }, + vcunlock: { usage: '>vcunlock', desc: 'Unlock your VC to restore access.\n\n**Requires:** Manage Channels' }, + vcbitrate: { usage: '>vcbitrate <8-384>', desc: 'Set the bitrate of your voice channel in kbps (max depends on server boost level).\n\n**Requires:** Manage Channels' }, + vcinfo: { usage: '>vcinfo', desc: 'Show information about your current voice channel: members, bitrate, lock status, and channel ID.' }, + muteall: { usage: '>muteall', desc: 'Server-mute all non-bot members in your VC.\n\n**Requires:** Mute Members' }, + unmuteall: { usage: '>unmuteall', desc: 'Remove server-mute from all members in your VC.\n\n**Requires:** Mute Members' }, + disconnectall: { usage: '>disconnectall', desc: 'Disconnect everyone except yourself from your VC.\n\n**Requires:** Move Members' }, +}; + const COMMAND_LIST_ID = "help-command-list"; const BACK_BUTTON_ID = "help-back-to-main"; const PAGINATION_PREFIX = "help-page"; -const BUG_REPORT_BUTTON_ID = "help-bug-report"; export const helpBackButton = { name: BACK_BUTTON_ID, @@ -17,7 +36,7 @@ export const helpBackButton = { await interaction.deferUpdate(); } - const { embeds, components } = await createInitialHelpMenu(client); + const { embeds, components } = await createInitialHelpMenu(client, interaction.member); await interaction.editReply({ embeds, components, @@ -38,46 +57,47 @@ export const helpBackButton = { }, }; -export const helpBugReportButton = { - name: BUG_REPORT_BUTTON_ID, - async execute(interaction, client) { - const githubButton = new ButtonBuilder() - .setLabel('🐛 Report Bug on GitHub') - .setStyle(ButtonStyle.Link) - .setURL('https://github.com/codebymitch/TitanBot/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!', - color: 'error' - }); - bugReportEmbed.setFooter({ - text: 'TitanBot Bug Reporting System', - iconURL: client.user.displayAvatarURL() + +export const helpCmdButton = { + name: 'help-cmd', + async execute(interaction, client, args) { + const displayName = args[0]; + if (!displayName) return; + + const [baseName, ...subParts] = displayName.split(' '); + const command = client.commands.get(baseName); + + let description = 'No description available.'; + + if (command) { + const rawData = command.data; + const jsonData = typeof rawData?.toJSON === 'function' ? rawData.toJSON() : rawData; + description = jsonData?.description || description; + + let options = (jsonData?.options || []).map(o => + typeof o?.toJSON === 'function' ? o.toJSON() : o + ); + for (const part of subParts) { + const opt = options.find(o => o.name === part); + if (opt) { + description = opt.description || description; + options = (opt.options || []).map(o => + typeof o?.toJSON === 'function' ? o.toJSON() : o + ); + } + } + } + + const embed = createEmbed({ + title: `/${displayName}`, + description, + color: 'secondary', }); - bugReportEmbed.setTimestamp(); await interaction.reply({ - embeds: [bugReportEmbed], - components: [bugRow], - flags: MessageFlags.Ephemeral + embeds: [embed], + flags: MessageFlags.Ephemeral, }); - }, -}; - -export const helpReportCommand = { - name: COMMAND_LIST_ID, - categoryName: null, - async execute(interaction, client) { - } }; @@ -100,6 +120,24 @@ function getPaginationInfo(components) { return { currentPage: 1, totalPages: 1 }; } +export const vcCmdButton = { + name: 'vc-cmd', + async execute(interaction, client, args) { + const cmdName = args[0]; + const cmd = VOICE_CMD_DETAILS[cmdName]; + + const embed = createEmbed({ + title: `>${cmdName}`, + description: cmd + ? `**Usage:** \`${cmd.usage}\`\n\n${cmd.desc}` + : 'No details available for this command.', + color: 'secondary', + }); + + await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral }); + }, +}; + export const helpPaginationButton = { name: `${PAGINATION_PREFIX}_next`, async execute(interaction, client) { @@ -129,7 +167,7 @@ export const helpPaginationButton = { break; } - const { embeds, components } = await createAllCommandsMenu(nextPage, client); + const { embeds, components } = await createAllCommandsMenu(nextPage, client, interaction.member); await interaction.editReply({ embeds, components }); } catch (error) { if (error?.code === 40060 || error?.code === 10062) { diff --git a/src/handlers/helpSelectMenus.js b/src/handlers/helpSelectMenus.js index b71a26b3d2..4b75ea8c47 100644 --- a/src/handlers/helpSelectMenus.js +++ b/src/handlers/helpSelectMenus.js @@ -3,7 +3,7 @@ import { createButton, getPaginationRow } from '../utils/components.js'; import fs from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; -import { Collection, ActionRowBuilder, MessageFlags } from 'discord.js'; +import { Collection, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags, PermissionFlagsBits } from 'discord.js'; import { logger } from '../utils/logger.js'; const __filename = fileURLToPath(import.meta.url); @@ -30,12 +30,50 @@ const CATEGORY_ICONS = { Counter: "🔢", Tools: "🛠️", Search: "🔍", - Reaction_Roles: "🎭", + Reaction_roles: "🎭", Community: "👥", Birthday: "🎂", Config: "⚙️", + Jointocreate: "🎙️", + Serverstats: "📈", + Logging: "📋", + Voice: "🔊", + Verification: "✅", }; +// Returns true if the member has permission to use the command +export function canMemberUseCommand(member, commandData) { + if (!member) return true; + if (member.permissions.has(PermissionFlagsBits.Administrator)) return true; + + const defaultPerms = commandData?.default_member_permissions; + if (defaultPerms == null) return true; + + try { + const required = BigInt(defaultPerms); + if (required === 0n) return false; + return member.permissions.has(required); + } catch { + return true; + } +} + +// Returns a Set of category directory names the member has at least one accessible command in +export function getAccessibleCategories(client, member) { + if (!member) return null; + const accessible = new Set(); + for (const command of client.commands.values()) { + if (!command.category) continue; + const cmdData = typeof command.data?.toJSON === 'function' + ? command.data.toJSON() + : command.data; + if (canMemberUseCommand(member, cmdData)) { + accessible.add(command.category); + } + } + return accessible; +} + function buildHelpEntries(command, category) { const commandData = normalizeCommandData(command); if (!commandData?.name) { @@ -109,7 +147,7 @@ function normalizeCommandData(command) { }; } -async function createCategoryCommandsMenu(category, client) { +async function createCategoryCommandsMenu(category, client, member) { const categoryName = category.charAt(0).toUpperCase() + category.slice(1).toLowerCase(); const icon = CATEGORY_ICONS[categoryName] || "🔍"; @@ -135,6 +173,9 @@ async function createCategoryCommandsMenu(category, client) { ) continue; + // Only show commands the member can use + if (!canMemberUseCommand(member, commandData)) continue; + categoryCommands.push(...buildHelpEntries(command, categoryName)); } } @@ -159,76 +200,44 @@ async function createCategoryCommandsMenu(category, client) { logger.error('Error fetching registered commands:', error); } + const MAX_CMD_BUTTONS = 20; // 4 rows × 5 + const displayedEntries = categoryCommands.slice(0, MAX_CMD_BUTTONS); + const hiddenCount = categoryCommands.length - displayedEntries.length; + const embed = createEmbed({ title: `${icon} ${categoryName} Commands`, description: categoryCommands.length > 0 - ? `Click any command mention below to use it:` - : `No commands found in the **${categoryName}** category.` + ? `**${categoryCommands.length}** command${categoryCommands.length !== 1 ? 's' : ''} available${hiddenCount > 0 ? ` · showing first ${MAX_CMD_BUTTONS}` : ''}. Click any command for details.` + : `You don't have permission to use any commands in the **${categoryName}** category.` }); - if (categoryCommands.length > 0) { - const commandMentions = categoryCommands - .map((cmd) => { - const registeredCmd = registeredCommands.get(cmd.baseName); - if (registeredCmd && registeredCmd.id) { - return `</${cmd.displayName}:${registeredCmd.id}> · ${cmd.description}`; - } - return `\`/${cmd.displayName}\` · ${cmd.description}`; - }) - .join("\n"); - - const maxLength = 1000; - if (commandMentions.length <= maxLength) { - embed.addFields({ - name: "Commands", - value: commandMentions, - inline: false, - }); - } else { - const chunks = []; - let currentChunk = ""; - const lines = commandMentions.split("\n"); - - for (const line of lines) { - if ((currentChunk + "\n" + line).length > maxLength) { - if (currentChunk) chunks.push(currentChunk); - currentChunk = line; - } else { - currentChunk += (currentChunk ? "\n" : "") + line; - } - } - if (currentChunk) chunks.push(currentChunk); - - chunks.forEach((chunk, index) => { - embed.addFields({ - name: `Commands (Part ${index + 1})`, - value: chunk, - inline: false, - }); - }); - } - } - embed.setFooter({ text: FOOTER_TEXT }); embed.setTimestamp(); - const backButton = createButton( - BACK_BUTTON_ID, - "Back", - "primary", - "⬅️", - false, - ); + const cmdButtonRows = []; + for (let i = 0; i < displayedEntries.length; i += 5) { + const chunk = displayedEntries.slice(i, i + 5); + const row = new ActionRowBuilder().addComponents( + chunk.map(entry => + new ButtonBuilder() + .setCustomId(`help-cmd:${entry.displayName}`) + .setLabel(`/${entry.displayName}`.substring(0, 80)) + .setStyle(ButtonStyle.Secondary) + ) + ); + cmdButtonRows.push(row); + } - const buttonRow = new ActionRowBuilder().addComponents(backButton); + const backButton = createButton(BACK_BUTTON_ID, "Back", "primary", "⬅️", false); + const navRow = new ActionRowBuilder().addComponents(backButton); return { embeds: [embed], - components: [buttonRow], + components: [...cmdButtonRows, navRow], }; } -export async function createAllCommandsMenu(page = 1, client) { +export async function createAllCommandsMenu(page = 1, client, member) { const commandsPerPage = 45; const allCommands = []; @@ -264,6 +273,9 @@ export async function createAllCommandsMenu(page = 1, client) { ) continue; + // Only show commands the member can use + if (!canMemberUseCommand(member, commandData)) continue; + const categoryName = category.charAt(0).toUpperCase() + category.slice(1).toLowerCase(); @@ -300,7 +312,7 @@ export async function createAllCommandsMenu(page = 1, client) { const embed = createEmbed({ title: "📋 All Commands", - description: `(${allCommands.length} total commands, including subcommands)` + description: `${allCommands.length} commands available to you` }); embed.setFooter({ text: FOOTER_TEXT }); @@ -371,16 +383,17 @@ export const helpCategorySelectMenu = { await interaction.deferUpdate(); } + const member = interaction.member; const selectedCategory = interaction.values[0]; if (selectedCategory === ALL_COMMANDS_ID) { - const { embeds, components } = await createAllCommandsMenu(1, client); + const { embeds, components } = await createAllCommandsMenu(1, client, member); await interaction.editReply({ embeds, components, }); } else { - const { embeds, components } = await createCategoryCommandsMenu(selectedCategory, client); + const { embeds, components } = await createCategoryCommandsMenu(selectedCategory, client, member); await interaction.editReply({ embeds, components, @@ -407,7 +420,3 @@ export const helpCategorySelectMenu = { } }, }; - - - - diff --git a/src/handlers/interactions.js b/src/handlers/interactions.js index ba1b0e8a30..1d5de1083a 100644 --- a/src/handlers/interactions.js +++ b/src/handlers/interactions.js @@ -1,58 +1,23 @@ import { readdir } from 'fs/promises'; import { join } from 'path'; -import { fileURLToPath } from 'url'; -import { dirname } from 'path'; +import { fileURLToPath, pathToFileURL } from 'url'; import { logger } from '../utils/logger.js'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const interactionTypes = ['buttons', 'selectMenus', 'modals']; - -export default async (client) => { - try { - const interactionsPath = join(__dirname, '../interactions'); - - for (const type of interactionTypes) { - const typePath = join(interactionsPath, type); - - try { - const interactionFiles = (await readdir(typePath)).filter(file => file.endsWith('.js')); - let loadedCount = 0; - - for (const file of interactionFiles) { - try { - const module = await import(`../interactions/${type}/${file}`); - const moduleExport = module.default; - const interactions = Array.isArray(moduleExport) ? moduleExport : [moduleExport]; - - for (const interaction of interactions) { - if (!interaction?.name || !interaction?.execute) { - logger.warn(`Interaction ${file} in ${type} is missing required properties.`); - continue; - } - - client[type].set(interaction.name, interaction); - loadedCount += 1; - logger.info(`Loaded ${type.slice(0, -1)}: ${interaction.name}`); - } - } catch (error) { - logger.error(`Error loading interaction ${file} in ${type}:`, error); - } - } - - logger.info(`Loaded ${loadedCount} ${type}`); - } catch (error) { - if (error.code !== 'ENOENT') { - logger.error(`Error loading ${type}:`, error); - } else { - logger.debug(`No ${type} directory found, skipping...`); - } +const root = join(fileURLToPath(new URL('.', import.meta.url)), '../modules/interactions'); +export default async function loadInteractions(client) { + for (const type of ['buttons', 'selectMenus', 'modals']) { + const directory = join(root, type); + let files = []; + try { files = (await readdir(directory)).filter(file => file.endsWith('.js')); } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + for (const file of files) { + const mod = (await import(pathToFileURL(join(directory, file)).href)).default; + for (const handler of (Array.isArray(mod) ? mod : [mod])) { + if (!handler?.name || typeof handler.execute !== 'function') throw new Error(`Invalid ${type} handler: ${file}`); + client[type].set(handler.name, handler); } } - } catch (error) { - logger.error('Error loading interactions:', error); + logger.info(`Loaded ${client[type].size} ${type}`); } -}; - - +} diff --git a/src/handlers/serverLogging.js b/src/handlers/serverLogging.js new file mode 100644 index 0000000000..f09c5af49e --- /dev/null +++ b/src/handlers/serverLogging.js @@ -0,0 +1,87 @@ +import { AuditLogEvent, Events } from 'discord.js'; +import { findAuditEntry, auditActor } from '../services/auditLogService.js'; +import { EVENT_TYPES, logEvent } from '../services/loggingService.js'; +import { getConfig } from '../modules/community/store.js'; +import { logger } from '../utils/logger.js'; + +const seen = new Map(); +const oldUnavailable = 'התוכן המקורי לא היה זמין במטמון.'; +const deletedUnavailable = 'ההודעה נמחקה, אך התוכן שלה לא היה זמין במטמון.'; +const value = v => v === null || v === undefined || v === '' ? '—' : String(v); +const channelRef = id => id ? `<#${id}>` : 'לא ידוע'; +const actor = entry => ({ name: 'מבצע הפעולה', value: auditActor(entry), inline: true }); + +function duplicate(key) { + const now = Date.now(); + for (const [id, expiry] of seen) if (expiry <= now) seen.delete(id); + if (seen.has(key)) return true; + seen.set(key, now + 10_000); + return false; +} +async function emit(guild, eventType, data) { + if (!guild) return; + const config = await getConfig(guild.client, guild.id); + if (!config.logging?.enabled || !config.logging.channelId || data.channelId === config.logging.channelId || config.logging.enabledEvents?.[eventType] === false) return; + await logEvent({ client: guild.client, guildId: guild.id, eventType, data }); +} +const ignoredMessage = m => !m?.guild || m.author?.bot || Boolean(m.webhookId); +const listen = (client, event, fn) => client.on(event, (...args) => Promise.resolve(fn(...args)).catch(error => logger.error(`Logging event ${event} failed`, error))); + +async function edited(oldMessage, newMessage, raw = false) { + const m = newMessage || oldMessage; + if (ignoredMessage(m) || (!raw && oldMessage.content === newMessage.content) || duplicate(`edit:${m.guildId}:${m.id}:${m.editedTimestamp || m.content}`)) return; + await emit(m.guild, EVENT_TYPES.MESSAGE_EDIT, { title: '✏️ הודעה נערכה', userId: m.author?.id, channelId: m.channelId, description: m.url ? `[מעבר להודעה](${m.url})` : undefined, fields: [{ name: 'מחבר', value: m.author ? `${m.author} (\`${m.author.id}\`)` : 'לא ידוע' }, { name: 'ערוץ', value: channelRef(m.channelId), inline: true }, { name: 'תוכן מקורי', value: raw || oldMessage.partial ? oldUnavailable : value(oldMessage.content) }, { name: 'תוכן חדש', value: value(m.content) }] }); +} +async function removed(m, raw = false) { + if (ignoredMessage(m) || duplicate(`delete:${m.guildId}:${m.id}`)) return; + await emit(m.guild, EVENT_TYPES.MESSAGE_DELETE, { title: '🗑️ הודעה נמחקה', userId: m.author?.id, channelId: m.channelId, description: raw || m.partial ? deletedUnavailable : value(m.content), fields: [{ name: 'מחבר', value: m.author ? `${m.author} (\`${m.author.id}\`)` : 'לא ידוע' }, { name: 'ערוץ', value: channelRef(m.channelId), inline: true }, { name: 'מזהה הודעה', value: `\`${m.id}\``, inline: true }, { name: 'קבצים מצורפים', value: !raw && m.attachments?.size ? m.attachments.map(a => a.url).join('\n') : 'ללא / לא זמין' }] }); +} + +export default function registerServerLogging(client) { + if (client.__serverLoggingRegistered) return; + client.__serverLoggingRegistered = true; + listen(client, Events.MessageUpdate, edited); + listen(client, Events.MessageDelete, removed); + listen(client, Events.MessageBulkDelete, async (messages, channel) => { + if (!channel.guild || duplicate(`bulk:${channel.id}:${[...messages.keys()].sort()}`)) return; + const entry = await findAuditEntry(channel.guild, AuditLogEvent.MessageBulkDelete, channel.id); + const known = messages.filter(m => m.content).first(10).map(m => `${m.author?.tag || 'לא ידוע'}: ${m.content}`).join('\n') || deletedUnavailable; + await emit(channel.guild, EVENT_TYPES.MESSAGE_BULK_DELETE, { title: '🗑️ מחיקת הודעות מרובה', channelId: channel.id, fields: [{ name: 'ערוץ', value: `${channel}` }, { name: 'כמות', value: String(messages.size), inline: true }, actor(entry), { name: 'תוכן ידוע', value: known }] }); + }); + listen(client, Events.Raw, async packet => { + if (!packet.d?.guild_id || !['MESSAGE_UPDATE', 'MESSAGE_DELETE'].includes(packet.t)) return; + const guild = client.guilds.cache.get(packet.d.guild_id), channel = guild?.channels.cache.get(packet.d.channel_id); + if (!guild || !channel || channel.messages?.cache?.has(packet.d.id)) return; + const m = { id: packet.d.id, guild, guildId: guild.id, channelId: channel.id, client, content: packet.d.content, author: packet.d.author, webhookId: packet.d.webhook_id, partial: true, editedTimestamp: packet.d.edited_timestamp, url: `https://discord.com/channels/${guild.id}/${channel.id}/${packet.d.id}` }; + if (packet.t === 'MESSAGE_UPDATE' && Object.hasOwn(packet.d, 'content')) await edited(m, m, true); + if (packet.t === 'MESSAGE_DELETE') await removed(m, true); + }); + + listen(client, Events.GuildMemberAdd, async member => { const days = Math.floor((Date.now() - member.user.createdTimestamp) / 86400000); await emit(member.guild, EVENT_TYPES.MEMBER_JOIN, { title: '📥 חבר הצטרף לשרת', userId: member.id, fields: [{ name: 'חבר', value: `${member} (${member.user.tag})` }, { name: 'יצירת החשבון', value: `<t:${Math.floor(member.user.createdTimestamp / 1000)}:F>` }, { name: 'גיל החשבון', value: `${days} ימים`, inline: true }, { name: 'חשבון חדש מ-7 ימים', value: days < 7 ? 'כן' : 'לא', inline: true }, { name: 'מספר חברים', value: String(member.guild.memberCount), inline: true }] }); }); + listen(client, Events.GuildMemberRemove, async member => { + const kick = await findAuditEntry(member.guild, AuditLogEvent.MemberKick, member.id, { delayMs: 1200, maxAgeMs: 12_000 }); + const memberRoles = member.roles?.cache?.filter(r => r.id !== member.guild.id && !r.managed).map(String).join(', ') || 'ללא'; + await emit(member.guild, kick ? EVENT_TYPES.MODERATION_KICK : EVENT_TYPES.MEMBER_LEAVE, { title: kick ? '👢 חבר הוסר מהשרת' : '📤 חבר עזב את השרת', userId: member.id, fields: [{ name: 'משתמש', value: `${member.user.tag} (\`${member.id}\`)` }, { name: 'תאריך הצטרפות', value: member.joinedTimestamp ? `<t:${Math.floor(member.joinedTimestamp / 1000)}:F>` : 'לא זמין' }, { name: 'תפקידים', value: memberRoles }, { name: 'מספר חברים', value: String(member.guild.memberCount), inline: true }, ...(kick ? [actor(kick), { name: 'סיבה', value: kick.reason || 'לא צוינה' }] : [])] }); + }); + listen(client, Events.GuildMemberUpdate, async (before, after) => { + const fields = []; + if (before.nickname !== after.nickname) fields.push({ name: 'כינוי', value: `${value(before.nickname)} → ${value(after.nickname)}` }); + const added = after.roles.cache.filter(r => !before.roles.cache.has(r.id) && !r.managed), gone = before.roles.cache.filter(r => !after.roles.cache.has(r.id) && !r.managed); + if (added.size) fields.push({ name: 'תפקידים שנוספו', value: added.map(String).join(', ') }); + if (gone.size) fields.push({ name: 'תפקידים שהוסרו', value: gone.map(String).join(', ') }); + if (before.communicationDisabledUntilTimestamp !== after.communicationDisabledUntilTimestamp) { const entry = await findAuditEntry(after.guild, AuditLogEvent.MemberUpdate, after.id); fields.push({ name: after.isCommunicationDisabled() ? 'סיום השהיה' : 'השהיה הוסרה', value: after.communicationDisabledUntilTimestamp ? `<t:${Math.floor(after.communicationDisabledUntilTimestamp / 1000)}:F>` : 'הוסרה' }, actor(entry), { name: 'סיבה', value: entry?.reason || 'לא צוינה' }); } + if (fields.length) await emit(after.guild, EVENT_TYPES.MEMBER_UPDATE, { title: '👤 חבר עודכן', userId: after.id, description: `${after.user}`, fields }); + }); + listen(client, Events.UserUpdate, async (before, after) => { const fields=[]; if(before.username!==after.username)fields.push({name:'שם משתמש',value:`${before.username} → ${after.username}`}); if(before.globalName!==after.globalName)fields.push({name:'שם תצוגה',value:`${value(before.globalName)} → ${value(after.globalName)}`}); if(before.avatar!==after.avatar)fields.push({name:'תמונת פרופיל',value:'השתנתה'}); for(const guild of client.guilds.cache.values())if(fields.length&&guild.members.cache.has(after.id))await emit(guild,EVENT_TYPES.MEMBER_UPDATE,{title:'👤 חשבון משתמש עודכן',userId:after.id,fields}); }); + for (const [event, action, title, type] of [[Events.GuildBanAdd,AuditLogEvent.MemberBanAdd,'⛔ משתמש הורחק',EVENT_TYPES.MODERATION_BAN],[Events.GuildBanRemove,AuditLogEvent.MemberBanRemove,'✅ הרחקה בוטלה',EVENT_TYPES.MODERATION_UNBAN]]) listen(client,event,async ban=>{const entry=await findAuditEntry(ban.guild,action,ban.user.id,{delayMs:500});await emit(ban.guild,type,{title,userId:ban.user.id,fields:[{name:'משתמש',value:`${ban.user} (\`${ban.user.id}\`)`},actor(entry),{name:'סיבה',value:entry?.reason||ban.reason||'לא צוינה'}]});}); + + for(const [event,action,title,type] of [[Events.ChannelCreate,AuditLogEvent.ChannelCreate,'➕ ערוץ נוצר',EVENT_TYPES.CHANNEL_CHANGE],[Events.ChannelDelete,AuditLogEvent.ChannelDelete,'➖ ערוץ נמחק',EVENT_TYPES.CHANNEL_CHANGE]])listen(client,event,async ch=>{if(!ch.guild)return;const entry=await findAuditEntry(ch.guild,action,ch.id,{delayMs:400});await emit(ch.guild,type,{title,channelId:ch.id,fields:[{name:'שם',value:ch.name},{name:'סוג',value:String(ch.type),inline:true},{name:'קטגוריה',value:ch.parent?.name||'ללא',inline:true},actor(entry)]});}); + listen(client,Events.ChannelUpdate,async(b,a)=>{const props=[['name','שם'],['topic','נושא'],['parentId','קטגוריה'],['rateLimitPerUser','מצב איטי'],['nsfw','NSFW']];const fields=props.filter(([k])=>b[k]!==a[k]).map(([k,l])=>({name:l,value:`${value(b[k])} → ${value(a[k])}`}));const perms=x=>JSON.stringify([...x.permissionOverwrites.cache.values()].map(p=>p.toJSON()));if(perms(b)!==perms(a))fields.push({name:'הרשאות ערוץ',value:'השתנו'});if(fields.length)await emit(a.guild,EVENT_TYPES.CHANNEL_CHANGE,{title:'📝 ערוץ עודכן',channelId:a.id,fields});}); + for(const [event,action,title,type] of [[Events.GuildRoleCreate,AuditLogEvent.RoleCreate,'➕ תפקיד נוצר',EVENT_TYPES.ROLE_CREATE],[Events.GuildRoleDelete,AuditLogEvent.RoleDelete,'➖ תפקיד נמחק',EVENT_TYPES.ROLE_DELETE]])listen(client,event,async role=>{const entry=await findAuditEntry(role.guild,action,role.id,{delayMs:400});await emit(role.guild,type,{title,fields:[{name:'שם',value:role.name},{name:'צבע',value:role.hexColor,inline:true},{name:'מיקום',value:String(role.position),inline:true},actor(entry)]});}); + listen(client,Events.GuildRoleUpdate,async(b,a)=>{const props=[['name','שם'],['hexColor','צבע'],['position','מיקום'],['mentionable','ניתן לאזכור'],['hoist','מוצג בנפרד']];const fields=props.filter(([k])=>b[k]!==a[k]).map(([k,l])=>({name:l,value:`${value(b[k])} → ${value(a[k])}`}));if(!b.permissions.equals(a.permissions))fields.push({name:'הרשאות',value:`${b.permissions.bitfield} → ${a.permissions.bitfield}`});if(fields.length)await emit(a.guild,EVENT_TYPES.ROLE_UPDATE,{title:'🎭 תפקיד עודכן',fields});}); + listen(client,Events.VoiceStateUpdate,async(b,a)=>{const fields=[];let title;if(b.channelId!==a.channelId){title=b.channelId&&a.channelId?'🔊 משתמש עבר חדר קולי':a.channelId?'🔊 משתמש הצטרף לחדר קולי':'🔇 משתמש עזב חדר קולי';fields.push({name:'לפני',value:channelRef(b.channelId),inline:true},{name:'אחרי',value:channelRef(a.channelId),inline:true});}if(b.serverMute!==a.serverMute)fields.push({name:'השתקה על ידי השרת',value:a.serverMute?'הופעלה':'הוסרה'});if(b.serverDeaf!==a.serverDeaf)fields.push({name:'חרשות על ידי השרת',value:a.serverDeaf?'הופעלה':'הוסרה'});if(fields.length)await emit(a.guild,EVENT_TYPES.VOICE_CHANGE,{title:title||'🎙️ מצב קולי עודכן',userId:a.id,fields});}); + for(const [event,title]of[[Events.InviteCreate,'🔗 הזמנה נוצרה'],[Events.InviteDelete,'🔗 הזמנה נמחקה']])listen(client,event,invite=>emit(invite.guild,EVENT_TYPES.INVITE_CHANGE,{title,channelId:invite.channelId,fields:[{name:'קוד',value:`\`${invite.code}\``},{name:'ערוץ',value:channelRef(invite.channelId)},{name:'יוצר',value:invite.inviter?`${invite.inviter}`:'לא זמין'},{name:'מקסימום שימושים',value:String(invite.maxUses||'ללא הגבלה')},{name:'תפוגה',value:invite.expiresTimestamp?`<t:${Math.floor(invite.expiresTimestamp/1000)}:F>`:'ללא'}]})); + for(const [event,title] of [[Events.GuildEmojiCreate,'אימוג׳י נוסף'],[Events.GuildEmojiDelete,'אימוג׳י הוסר'],[Events.GuildStickerCreate,'מדבקה נוספה'],[Events.GuildStickerDelete,'מדבקה הוסרה']]) listen(client,event,item=>emit(item.guild,EVENT_TYPES.EMOJI_STICKER_CHANGE,{title,fields:[{name:'שם',value:item.name},{name:'מזהה',value:`\`${item.id}\``}]})); + for(const [event,title] of [[Events.GuildEmojiUpdate,'שם אימוג׳י השתנה'],[Events.GuildStickerUpdate,'שם מדבקה השתנה']]) listen(client,event,(before,after)=>{if(before.name!==after.name)return emit(after.guild,EVENT_TYPES.EMOJI_STICKER_CHANGE,{title,fields:[{name:'לפני',value:before.name},{name:'אחרי',value:after.name},{name:'מזהה',value:`\`${after.id}\``}]});}); + listen(client,Events.GuildUpdate,async(b,a)=>{const props=[['name','שם שרת'],['icon','סמל'],['verificationLevel','רמת אימות'],['defaultMessageNotifications','התראות'],['afkChannelId','ערוץ AFK'],['systemChannelId','ערוץ מערכת']];const fields=props.filter(([k])=>b[k]!==a[k]).map(([k,l])=>({name:l,value:`${value(b[k])} → ${value(a[k])}`}));if(fields.length)await emit(a,EVENT_TYPES.SERVER_UPDATE,{title:'⚙️ השרת עודכן',fields});}); +} diff --git a/src/handlers/wipedataButtons.js b/src/handlers/wipedataButtons.js index ab7eaf9d75..f3fb419d80 100644 --- a/src/handlers/wipedataButtons.js +++ b/src/handlers/wipedataButtons.js @@ -19,28 +19,13 @@ const wipedataConfirmHandler = { const dataKeyPatterns = [ - `economy:${guildId}:${userId}`, `level:${guildId}:${userId}`, `xp:${guildId}:${userId}`, - `inventory:${guildId}:${userId}`, - `bank:${guildId}:${userId}`, - `wallet:${guildId}:${userId}`, `cooldowns:${guildId}:${userId}`, - `shop:${guildId}:${userId}`, - `shop_data:${guildId}:${userId}`, `counter:${guildId}:${userId}`, `birthday:${guildId}:${userId}`, - `balance:${guildId}:${userId}`, `user:${guildId}:${userId}`, `leveling:${guildId}:${userId}`, - `crimexp:${guildId}:${userId}`, - `robxp:${guildId}:${userId}`, - `crime_cooldown:${guildId}:${userId}`, - `rob_cooldown:${guildId}:${userId}`, - `lastDaily:${guildId}:${userId}`, - `lastWork:${guildId}:${userId}`, - `lastCrime:${guildId}:${userId}`, - `lastRob:${guildId}:${userId}`, ]; let deletedCount = 0; @@ -65,8 +50,6 @@ const wipedataConfirmHandler = { if (client.db.list && typeof client.db.list === 'function') { const searchPrefixes = [ `${guildId}:${userId}`, - `${guildId}:`, - `economy:${guildId}:`, `level:${guildId}:`, `xp:${guildId}:`, `user:${guildId}:` @@ -108,7 +91,7 @@ const wipedataConfirmHandler = { `✅ **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.*`; + `*All your levels, XP, and personal data have been removed.*`; await interaction.editReply({ embeds: [successEmbed(successMessage, '🗑️ Data Wipe Complete')], diff --git a/src/interactions/buttons/countdown.js b/src/interactions/buttons/countdown.js deleted file mode 100644 index 6503f40ca5..0000000000 --- a/src/interactions/buttons/countdown.js +++ /dev/null @@ -1,12 +0,0 @@ -import countdownButtonHandler from '../../handlers/countdownButtons.js'; - -export default [ - { - name: 'countdown_pause', - execute: countdownButtonHandler, - }, - { - name: 'countdown_cancel', - execute: countdownButtonHandler, - }, -]; \ No newline at end of file diff --git a/src/interactions/buttons/counterDelete.js b/src/interactions/buttons/counterDelete.js deleted file mode 100644 index df171dcb10..0000000000 --- a/src/interactions/buttons/counterDelete.js +++ /dev/null @@ -1,3 +0,0 @@ -import counterDeleteActionHandler from '../../handlers/counterButtons.js'; - -export default counterDeleteActionHandler; \ No newline at end of file diff --git a/src/interactions/buttons/giveaway.js b/src/interactions/buttons/giveaway.js deleted file mode 100644 index 962b6f5da4..0000000000 --- a/src/interactions/buttons/giveaway.js +++ /dev/null @@ -1,20 +0,0 @@ -import { - giveawayJoinHandler, - giveawayEndHandler, - giveawayRerollHandler, - giveawayViewHandler, -} from '../../handlers/giveawayButtons.js'; - -function fromCustomId(handler) { - return { - name: handler.customId, - execute: handler.execute, - }; -} - -export default [ - fromCustomId(giveawayJoinHandler), - fromCustomId(giveawayEndHandler), - fromCustomId(giveawayRerollHandler), - fromCustomId(giveawayViewHandler), -]; \ No newline at end of file diff --git a/src/interactions/buttons/help.js b/src/interactions/buttons/help.js deleted file mode 100644 index 9e7a011262..0000000000 --- a/src/interactions/buttons/help.js +++ /dev/null @@ -1,19 +0,0 @@ -import { - helpBackButton, - helpBugReportButton, - helpPaginationButton, -} from '../../handlers/helpButtons.js'; - -const paginationIds = [ - 'help-page_first', - 'help-page_prev', - 'help-page_next', - 'help-page_last', -]; - -const paginationInteractions = paginationIds.map((name) => ({ - name, - execute: helpPaginationButton.execute, -})); - -export default [helpBackButton, helpBugReportButton, ...paginationInteractions]; \ No newline at end of file diff --git a/src/interactions/buttons/logging.js b/src/interactions/buttons/logging.js deleted file mode 100644 index 4c34e6e429..0000000000 --- a/src/interactions/buttons/logging.js +++ /dev/null @@ -1,20 +0,0 @@ -import loggingButtonsHandler from '../../handlers/loggingButtons.js'; - -export default [ - { - name: 'logging_toggle', - execute: loggingButtonsHandler.execute, - }, - { - name: 'logging_refresh_status', - execute: loggingButtonsHandler.execute, - }, - { - name: 'log_dash_toggle', - execute: loggingButtonsHandler.execute, - }, - { - name: 'log_dash_refresh', - execute: loggingButtonsHandler.execute, - }, -]; \ No newline at end of file diff --git a/src/interactions/buttons/ticket.js b/src/interactions/buttons/ticket.js deleted file mode 100644 index bcc2521f71..0000000000 --- a/src/interactions/buttons/ticket.js +++ /dev/null @@ -1,20 +0,0 @@ -import createTicketHandler, { - closeTicketHandler, - claimTicketHandler, - priorityTicketHandler, - pinTicketHandler, - unclaimTicketHandler, - reopenTicketHandler, - deleteTicketHandler, -} from '../../handlers/ticketButtons.js'; - -export default [ - createTicketHandler, - closeTicketHandler, - claimTicketHandler, - priorityTicketHandler, - pinTicketHandler, - unclaimTicketHandler, - reopenTicketHandler, - deleteTicketHandler, -]; \ No newline at end of file diff --git a/src/interactions/buttons/ticketFeedback.js b/src/interactions/buttons/ticketFeedback.js deleted file mode 100644 index ba898cc5a1..0000000000 --- a/src/interactions/buttons/ticketFeedback.js +++ /dev/null @@ -1,158 +0,0 @@ -import { EmbedBuilder } from 'discord.js'; -import { getTicketData, saveTicketData } from '../../utils/database.js'; -import { logger } from '../../utils/logger.js'; -import { getColor } from '../../config/bot.js'; -import { getGuildConfig } from '../../services/guildConfig.js'; - -const STAR_LABELS = { - '1': '⭐ 1 — Poor', - '2': '⭐⭐ 2 — Below Average', - '3': '⭐⭐⭐ 3 — Average', - '4': '⭐⭐⭐⭐ 4 — Good', - '5': '⭐⭐⭐⭐⭐ 5 — Excellent', -}; - -const feedbackHandler = { - name: 'ticket_feedback', - - async execute(interaction, client, args) { - // args = [guildId, channelId, rating] - const [guildId, channelId, ratingStr] = args; - - if (!guildId || !channelId || !ratingStr) { - await interaction.update({ - embeds: [ - new EmbedBuilder() - .setTitle('⚠️ Invalid Feedback Link') - .setDescription('This feedback link appears to be malformed.') - .setColor(getColor('error')), - ], - components: [], - }); - return; - } - - let ticketData; - try { - ticketData = await getTicketData(guildId, channelId); - } catch (err) { - logger.warn('ticketFeedback: failed to load ticket data', { guildId, channelId, error: err.message }); - } - - if (!ticketData) { - await interaction.update({ - embeds: [ - new EmbedBuilder() - .setTitle('⚠️ Ticket Not Found') - .setDescription('Could not find the ticket associated with this survey.') - .setColor(getColor('error')), - ], - components: [], - }); - return; - } - - if (interaction.user.id !== ticketData.userId) { - await interaction.reply({ - embeds: [ - new EmbedBuilder() - .setTitle('❌ Not Allowed') - .setDescription('Only the ticket creator can submit feedback for this ticket.') - .setColor(getColor('error')), - ], - ephemeral: true, - }); - return; - } - - if (ticketData.feedback?.rating) { - 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!`) - .setColor(getColor('success')), - ], - components: [], - }); - return; - } - - const rating = parseInt(ratingStr, 10); - const ratingLabel = STAR_LABELS[String(rating)] ?? `${rating} stars`; - - try { - ticketData.feedback = { - rating, - submittedAt: new Date().toISOString(), - }; - await saveTicketData(guildId, channelId, ticketData); - } catch (err) { - logger.error('ticketFeedback: failed to save feedback', { guildId, channelId, rating, error: err.message }); - } - - // Send feedback to logs channel - try { - const guildConfig = await getGuildConfig(interaction.client, guildId); - if (guildConfig.ticketLogsChannelId) { - const logsChannel = await interaction.client.channels.fetch(guildConfig.ticketLogsChannelId).catch(() => null); - if (logsChannel && logsChannel.isSendable()) { - const feedbackEmbed = new EmbedBuilder() - .setTitle('📋 Ticket Feedback Received') - .setDescription('User submitted feedback for a ticket') - .setColor(getColor('info')) - .addFields( - { name: 'Ticket ID', value: `\`${channelId}\``, inline: true }, - { name: 'Rating', value: ratingLabel, inline: true }, - { name: 'User', value: `<@${interaction.user.id}>`, inline: true }, - { name: 'Submitted', value: `<t:${Math.floor(Date.now() / 1000)}:R>`, inline: true }, - ) - .setThumbnail(interaction.user.displayAvatarURL()) - .setFooter({ text: `User ID: ${interaction.user.id}` }) - .setTimestamp(); - - await logsChannel.send({ embeds: [feedbackEmbed] }); - } - } - } catch (err) { - logger.warn('ticketFeedback: failed to send log', { guildId, channelId, error: err.message }); - } - - await interaction.update({ - 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!`) - .setColor(getColor('success')) - .setFooter({ text: 'Thank you for using our support system.' }) - .setTimestamp(), - ], - components: [], - }); - - logger.info('Ticket feedback submitted', { - guildId, - channelId, - userId: interaction.user.id, - rating, - }); - }, -}; - -const declineHandler = { - name: 'ticket_feedback_decline', - - async execute(interaction) { - await interaction.update({ - embeds: [ - new EmbedBuilder() - .setTitle('👋 No problem!') - .setDescription('You can always reach out again if you need further support.') - .setColor(getColor('default')), - ], - components: [], - }); - }, -}; - -export default [feedbackHandler, declineHandler]; diff --git a/src/interactions/buttons/todoShared.js b/src/interactions/buttons/todoShared.js deleted file mode 100644 index 02b5fb2009..0000000000 --- a/src/interactions/buttons/todoShared.js +++ /dev/null @@ -1,10 +0,0 @@ -import todoAddHandler, { - sharedTodoCompleteHandler, - sharedTodoRemoveHandler, -} from '../../handlers/todoButtons.js'; - -export default [ - todoAddHandler, - sharedTodoCompleteHandler, - sharedTodoRemoveHandler, -]; \ No newline at end of file diff --git a/src/interactions/buttons/verification.js b/src/interactions/buttons/verification.js deleted file mode 100644 index f0bc0887e3..0000000000 --- a/src/interactions/buttons/verification.js +++ /dev/null @@ -1,6 +0,0 @@ -import verificationButtonHandler from '../../handlers/verificationButtons.js'; - -export default { - name: verificationButtonHandler.customId, - execute: verificationButtonHandler.execute, -}; \ No newline at end of file diff --git a/src/interactions/buttons/wipedata.js b/src/interactions/buttons/wipedata.js deleted file mode 100644 index 38d910b145..0000000000 --- a/src/interactions/buttons/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/modals/calculate.js b/src/interactions/modals/calculate.js deleted file mode 100644 index be3ab8fb20..0000000000 --- a/src/interactions/modals/calculate.js +++ /dev/null @@ -1,10 +0,0 @@ -import calculateModalHandler from '../../handlers/calculateModals.js'; - -const execute = typeof calculateModalHandler === 'function' - ? calculateModalHandler - : calculateModalHandler.execute; - -export default { - name: 'calc_modal', - execute, -}; \ No newline at end of file diff --git a/src/interactions/modals/ticket.js b/src/interactions/modals/ticket.js deleted file mode 100644 index 83eb50a8e5..0000000000 --- a/src/interactions/modals/ticket.js +++ /dev/null @@ -1,6 +0,0 @@ -import { - createTicketModalHandler, - closeTicketModalHandler, -} from '../../handlers/ticketButtons.js'; - -export default [createTicketModalHandler, closeTicketModalHandler]; \ No newline at end of file diff --git a/src/interactions/modals/todoShared.js b/src/interactions/modals/todoShared.js deleted file mode 100644 index 8b15ac337c..0000000000 --- a/src/interactions/modals/todoShared.js +++ /dev/null @@ -1,11 +0,0 @@ -import { - sharedTodoAddModalHandler, - sharedTodoCompleteModalHandler, - sharedTodoRemoveModalHandler, -} from '../../handlers/todoButtons.js'; - -export default [ - sharedTodoAddModalHandler, - sharedTodoCompleteModalHandler, - sharedTodoRemoveModalHandler, -]; \ No newline at end of file diff --git a/src/interactions/selectMenus/helpCategory.js b/src/interactions/selectMenus/helpCategory.js deleted file mode 100644 index ce224ea566..0000000000 --- a/src/interactions/selectMenus/helpCategory.js +++ /dev/null @@ -1,3 +0,0 @@ -import { helpCategorySelectMenu } from '../../handlers/helpSelectMenus.js'; - -export default helpCategorySelectMenu; \ No newline at end of file diff --git a/src/interactions/selectMenus/reaction_roles.js b/src/interactions/selectMenus/reaction_roles.js deleted file mode 100644 index bdacb5d3e7..0000000000 --- a/src/interactions/selectMenus/reaction_roles.js +++ /dev/null @@ -1,13 +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 -}; - - - diff --git a/src/interactions/selectMenus/ticketFeedback.js b/src/interactions/selectMenus/ticketFeedback.js deleted file mode 100644 index 54524b449c..0000000000 --- a/src/interactions/selectMenus/ticketFeedback.js +++ /dev/null @@ -1,144 +0,0 @@ -import { EmbedBuilder } from 'discord.js'; -import { getTicketData, saveTicketData } from '../../utils/database.js'; -import { logger } from '../../utils/logger.js'; -import { getColor } from '../../config/bot.js'; -import { getGuildConfig } from '../../services/guildConfig.js'; - -const STAR_LABELS = { - '1': '⭐ 1 — Poor', - '2': '⭐⭐ 2 — Below Average', - '3': '⭐⭐⭐ 3 — Average', - '4': '⭐⭐⭐⭐ 4 — Good', - '5': '⭐⭐⭐⭐⭐ 5 — Excellent', -}; - -export default { - name: 'ticket_feedback', - - async execute(interaction, client, args) { - // args = [guildId, channelId] from the customId split on ':' - const [guildId, channelId] = args; - - if (!guildId || !channelId) { - await interaction.update({ - embeds: [ - new EmbedBuilder() - .setTitle('⚠️ Invalid Feedback Link') - .setDescription('This feedback link appears to be malformed.') - .setColor(getColor('error')), - ], - components: [], - }); - return; - } - - // Only the ticket creator should be able to submit - let ticketData; - try { - ticketData = await getTicketData(guildId, channelId); - } catch (err) { - logger.warn('ticketFeedback: failed to load ticket data', { guildId, channelId, error: err.message }); - } - - if (!ticketData) { - await interaction.update({ - embeds: [ - new EmbedBuilder() - .setTitle('⚠️ Ticket Not Found') - .setDescription('Could not find the ticket associated with this survey.') - .setColor(getColor('error')), - ], - components: [], - }); - return; - } - - if (interaction.user.id !== ticketData.userId) { - await interaction.reply({ - embeds: [ - new EmbedBuilder() - .setTitle('❌ Not Allowed') - .setDescription('Only the ticket creator can submit feedback for this ticket.') - .setColor(getColor('error')), - ], - ephemeral: true, - }); - return; - } - - // Guard against duplicate submission - if (ticketData.feedback?.rating) { - 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!`) - .setColor(getColor('success')), - ], - components: [], - }); - return; - } - - const rating = parseInt(interaction.values[0], 10); - const ratingLabel = STAR_LABELS[String(rating)] ?? `${rating} stars`; - - // Persist the feedback - try { - ticketData.feedback = { - rating, - submittedAt: new Date().toISOString(), - }; - await saveTicketData(guildId, channelId, ticketData); - } catch (err) { - logger.error('ticketFeedback: failed to save feedback', { guildId, channelId, rating, error: err.message }); - } - - // Send feedback to logs channel - try { - const guildConfig = await getGuildConfig(interaction.client, guildId); - if (guildConfig.ticketLogsChannelId) { - const logsChannel = await interaction.client.channels.fetch(guildConfig.ticketLogsChannelId).catch(() => null); - if (logsChannel && logsChannel.isSendable()) { - const feedbackEmbed = new EmbedBuilder() - .setTitle('📋 Ticket Feedback Received') - .setDescription(`User submitted feedback for a ticket`) - .setColor(getColor('info')) - .addFields( - { name: 'Ticket ID', value: `\`${channelId}\``, inline: true }, - { name: 'Rating', value: ratingLabel, inline: true }, - { name: 'User', value: `<@${interaction.user.id}>`, inline: true }, - { name: 'Submitted', value: `<t:${Math.floor(Date.now() / 1000)}:R>`, inline: true } - ) - .setThumbnail(interaction.user.displayAvatarURL()) - .setFooter({ text: `User ID: ${interaction.user.id}` }) - .setTimestamp(); - - await logsChannel.send({ embeds: [feedbackEmbed] }); - } - } - } catch (err) { - logger.warn('ticketFeedback: failed to send log', { guildId, channelId, error: err.message }); - } - - // Edit the DM message to remove the select and show thanks - 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!`) - .setColor(getColor('success')) - .setFooter({ text: 'Thank you for using our support system.' }) - .setTimestamp(); - - await interaction.update({ - embeds: [thankYouEmbed], - components: [], - }); - - logger.info('Ticket feedback submitted', { - guildId, - channelId, - userId: interaction.user.id, - rating, - }); - }, -}; diff --git a/src/modules/community/permissions.js b/src/modules/community/permissions.js new file mode 100644 index 0000000000..68f320cb1c --- /dev/null +++ b/src/modules/community/permissions.js @@ -0,0 +1,38 @@ +import { MessageFlags, PermissionFlagsBits } from 'discord.js'; +import { getConfig } from './store.js'; +import { createEmbed } from '../../utils/embeds.js'; + +export const AccessLevel = Object.freeze({ EVERYONE: 0, VERIFIED: 1, HELPER: 2, MODERATOR: 3, ADMIN: 4, OWNER: 5 }); + +export async function memberAccessLevel(interaction, client) { + if (!interaction.inGuild() || !interaction.member) return AccessLevel.EVERYONE; + if (interaction.guild?.ownerId === interaction.user.id) return AccessLevel.OWNER; + const p = interaction.member.permissions; + const config = await getConfig(client, interaction.guildId); + const hasRole = id => Boolean(id && interaction.member.roles?.cache?.has(id)); + if (hasRole(config.staffRoles?.administrator)) return AccessLevel.ADMIN; + if (p.has(PermissionFlagsBits.Administrator) || p.has(PermissionFlagsBits.ManageGuild)) return AccessLevel.ADMIN; + if (hasRole(config.staffRoles?.moderator)) return AccessLevel.MODERATOR; + if (p.has(PermissionFlagsBits.ModerateMembers) && p.has(PermissionFlagsBits.KickMembers) && p.has(PermissionFlagsBits.ManageMessages)) return AccessLevel.MODERATOR; + if (hasRole(config.staffRoles?.helper)) return AccessLevel.HELPER; + if (p.has(PermissionFlagsBits.ManageMessages)) return AccessLevel.HELPER; + if (hasRole(config.staffRoles?.verified || config.verification.roleId)) return AccessLevel.VERIFIED; + return AccessLevel.EVERYONE; +} + +export async function requireAccess(interaction, client, defaultLevel, commandKey = interaction.commandName) { + if (!interaction.inGuild()) { + await interaction.reply({ embeds: [createEmbed({ title: 'פקודה לא זמינה', description: 'ניתן להשתמש בפקודה זו רק בתוך שרת.', color: 'error' })], flags: MessageFlags.Ephemeral }); + return false; + } + const config = await getConfig(client, interaction.guildId); + const setting = config.commandSettings?.[commandKey] || config.commandSettings?.[interaction.commandName]; + if (setting?.enabled === false) { + await interaction.reply({ embeds: [createEmbed({ title: 'הפקודה מושבתת', description: 'הפקודה הזו הושבתה בהגדרות השרת.', color: 'error' })], flags: MessageFlags.Ephemeral }); + return false; + } + const required = Number(config.commandPermissions?.[commandKey] ?? defaultLevel); + if (await memberAccessLevel(interaction, client) >= required) return true; + await interaction.reply({ embeds: [createEmbed({ title: 'אין הרשאה', description: 'אין לך הרשאה להשתמש בפקודה זו.', color: 'error' })], flags: MessageFlags.Ephemeral }); + return false; +} diff --git a/src/modules/community/store.js b/src/modules/community/store.js new file mode 100644 index 0000000000..257042aa8d --- /dev/null +++ b/src/modules/community/store.js @@ -0,0 +1,61 @@ +const key = guildId => `community:${guildId}:config`; +export const defaults = { + welcome: { + enabled: false, + channelId: null, + message: `ברוכים הבאים ל־EditIL 🇮🇱 + +ברוכים הבאים {member}! 🎉 + +אנחנו שמחים שהצטרפתם לקהילת העריכה הישראלית. + +📜 קראו את החוקים +✅ השלימו את האימות +🎭 בחרו את התפקידים שלכם +🎬 התחילו לשתף את העריכות שלכם ולהכיר את הקהילה! + +תהנו ובהצלחה! 💙` + }, + verification: { enabled: false, channelId: null, roleId: null }, + roles: { panels: [], categories: {} }, + tickets: { + enabled: false, categoryId: null, archiveCategoryId: null, supportRoleId: null, panelChannelId: null, + transcriptChannelId: null, logsChannelId: null, nextNumber: 1, panels: [], maxOpenPerUser: 1, + closeDelaySeconds: 10, staffAlertCooldownSeconds: 300, maxAddedUsers: 5, allowDuplicateTypes: false, + transcriptsEnabled: true, creatorCanClose: true, creatorCanAdd: false, creatorCanRename: false, + claimEnabled: true, priorityEnabled: true, archiveEnabled: false, dmNotifications: true, + enabledTypes: ['general','editing','report','partnership','bot_bug','resource','paid_work','management'] + }, + logging: { enabled: false, channelId: null, enabledEvents: {} }, + leveling: { enabled: false, announceChannelId: null, cooldownMs: 60_000, xpMin: 15, xpMax: 25 } + , contests: { active: null, submissions: [], votes: {} } + , channels: { suggestions: null, reports: null, feedback: null, announcements: null, selfPromotion: null, lookingForEditor: null, lookingForTeam: null } + , staffRoles: { verified: null, newMember: null, helper: null, moderator: null, administrator: null, ticketStaff: null, booster: null, botDeveloper: null, supplier: null, botUpdates: null, friend: null } + , modules: { moderation: true, leveling: true, welcome: true, verification: true, tickets: true, suggestions: true, reports: true, contests: true, rolePanels: true, automod: true } + , commandSettings: {} + , commandPermissions: {} + , community: { + cooldowns: { suggest: 3600, feedback: 1800, report: 900, selfpromo: 86400, lookingforeditor: 21600, lookingforteam: 21600 }, + anonymousFeedback: false, publicPolls: false, allowDiscordInvites: false, publicVoteTotals: true, + enabled: true, editingRoleIds: [] + } +}; +export async function getConfig(client, guildId) { + const saved = await client.db.get(key(guildId), {}); + return { ...defaults, ...saved, welcome: { ...defaults.welcome, ...saved.welcome }, verification: { ...defaults.verification, ...saved.verification }, roles: { ...defaults.roles, ...saved.roles, categories: { ...defaults.roles.categories, ...saved.roles?.categories } }, tickets: { ...defaults.tickets, ...saved.tickets }, logging: { ...defaults.logging, ...saved.logging }, leveling: { ...defaults.leveling, ...saved.leveling }, contests: { ...defaults.contests, ...saved.contests }, channels: { ...defaults.channels, ...saved.channels }, staffRoles: { ...defaults.staffRoles, ...saved.staffRoles }, modules: { ...defaults.modules, ...saved.modules }, commandSettings: { ...defaults.commandSettings, ...saved.commandSettings }, commandPermissions: { ...defaults.commandPermissions, ...saved.commandPermissions }, community: { ...defaults.community, ...saved.community, cooldowns: { ...defaults.community.cooldowns, ...saved.community?.cooldowns } } }; +} +export async function updateConfig(client, guildId, patch) { + const current = await getConfig(client, guildId); + const next = { ...current, ...patch }; + for (const name of ['welcome', 'verification', 'roles', 'tickets', 'logging', 'leveling', 'contests', 'channels', 'staffRoles', 'modules', 'commandSettings', 'commandPermissions', 'community']) next[name] = { ...current[name], ...(patch[name] || {}) }; + next.community.cooldowns = { ...current.community.cooldowns, ...(patch.community?.cooldowns || {}) }; + await client.db.set(key(guildId), next); + return next; +} +export async function resetConfig(client, guildId) { + await client.db.set(key(guildId), structuredClone(defaults)); + return getConfig(client, guildId); +} +export const levelKey = (guildId, userId) => `community:${guildId}:level:${userId}`; +export const ticketKey = (guildId, channelId) => `community:${guildId}:ticket:${channelId}`; +export const warningKey = (guildId, userId) => `community:${guildId}:warnings:${userId}`; diff --git a/src/modules/community/ui.js b/src/modules/community/ui.js new file mode 100644 index 0000000000..1c4aa38694 --- /dev/null +++ b/src/modules/community/ui.js @@ -0,0 +1,7 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; +import { createEmbed } from '../../utils/embeds.js'; +export const success = (title, description) => ({ embeds: [createEmbed({ title, description, color: 'success' })] }); +export const info = (title, description) => ({ embeds: [createEmbed({ title, description, color: 'primary' })] }); +export const error = description => ({ embeds: [createEmbed({ title: 'שגיאה', description, color: 'error' })], ephemeral: true }); +export const verifyPanel = () => ({ embeds: [createEmbed({ title: 'אימות חברים', description: 'לחצו על הכפתור כדי לאמת את חשבונכם.', color: 'primary' })], components: [new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId('verify').setLabel('אימות').setStyle(ButtonStyle.Success))] }); +export const ticketPanel = () => ({ embeds: [createEmbed({ title: 'פתיחת פנייה', description: 'זקוקים לעזרה? לחצו על הכפתור כדי לפתוח פנייה פרטית.', color: 'primary' })], components: [new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId('ticket_open').setLabel('פתיחת פנייה').setStyle(ButtonStyle.Primary))] }); diff --git a/src/modules/interactions/buttons/botupdate_cancel.js b/src/modules/interactions/buttons/botupdate_cancel.js new file mode 100644 index 0000000000..c2776c8a01 --- /dev/null +++ b/src/modules/interactions/buttons/botupdate_cancel.js @@ -0,0 +1 @@ +export default {name:'botupdate_cancel',async execute(interaction,_client,args){if(interaction.user.id!==args[0])return interaction.reply({content:'רק יוצר הפעולה יכול לבטל אותה.',ephemeral:true});return interaction.update({content:'הפעולה בוטלה.',embeds:[],components:[]});}}; diff --git a/src/modules/interactions/buttons/botupdate_confirm.js b/src/modules/interactions/buttons/botupdate_confirm.js new file mode 100644 index 0000000000..eb2bc49a01 --- /dev/null +++ b/src/modules/interactions/buttons/botupdate_confirm.js @@ -0,0 +1,4 @@ +import { MessageFlags } from 'discord.js'; +import { buildUpdateEmbed, getUpdateSettings, postUpdate, resolveUpdateChannel, saveUpdateSettings } from '../../../services/botUpdateService.js'; +import { logger } from '../../../utils/logger.js'; +export default {name:'botupdate_confirm',async execute(interaction,client,args){const [action,ownerId,nonce]=args;if(interaction.user.id!==ownerId||interaction.user.id!==interaction.guild.ownerId)return interaction.reply({content:'רק בעל השרת יכול לאשר פעולה זו.',flags:MessageFlags.Ephemeral});client.botUpdateConfirmLocks??=new Set();const key=`${interaction.guildId}:${nonce}`;if(client.botUpdateConfirmLocks.has(key))return interaction.update({content:'האישור כבר מתבצע.',embeds:[],components:[]});client.botUpdateConfirmLocks.add(key);const dbKey=`botupdate:pending:${interaction.guildId}:${nonce}`,pending=await client.db.get(dbKey);if(!pending){client.botUpdateConfirmLocks.delete(key);return interaction.update({content:'האישור פג או שכבר בוצע.',embeds:[],components:[]});}await client.db.delete(dbKey);try{const s=await getUpdateSettings(client,interaction.guildId);if(action==='post'||action==='resend'||action==='save_announce'){if(action==='save_announce')await saveUpdateSettings(client,interaction.guildId,{currentVersion:pending.content.version,content:pending.content});await postUpdate(client,interaction.guild,pending.content,{authorId:interaction.user.id,pingRole:pending.pingRole,repeated:action==='resend'});}else if(action==='save_version')await saveUpdateSettings(client,interaction.guildId,{currentVersion:pending.content.version,content:pending.content});else{const channel=await resolveUpdateChannel(interaction.guild,s),message=await channel.messages.fetch(s.lastMessageId);if(action==='edit'){await message.edit({embeds:[buildUpdateEmbed(client,pending.content)],allowedMentions:{parse:[]}});await saveUpdateSettings(client,interaction.guildId,{currentVersion:pending.content.version,lastAnnouncedVersion:pending.content.version,content:pending.content,lastAnnouncementAuthor:interaction.user.id,lastAnnouncementAt:new Date().toISOString()});}else{await message.delete();await saveUpdateSettings(client,interaction.guildId,{lastMessageId:null,lastAnnouncementAuthor:interaction.user.id,lastAnnouncementAt:new Date().toISOString()});}}logger.info(`Bot update ${action}`,{guildId:interaction.guildId,userId:interaction.user.id,version:pending.content.version});return interaction.update({content:action==='delete'?'הודעת עדכון הבוט נמחקה בהצלחה.':action==='save_version'?'גרסת הבוט נשמרה בהצלחה.':'עדכון הבוט פורסם בהצלחה.',embeds:[],components:[]});}catch(error){logger.error('Failed update announcement',{guildId:interaction.guildId,error:error.stack||error.message});return interaction.update({content:'אירעה שגיאה בפרסום העדכון. השגיאה נרשמה לבדיקה.',embeds:[],components:[]});}finally{client.botUpdateConfirmLocks.delete(key);}}}; diff --git a/src/modules/interactions/buttons/community_actions.js b/src/modules/interactions/buttons/community_actions.js new file mode 100644 index 0000000000..7ea43155a1 --- /dev/null +++ b/src/modules/interactions/buttons/community_actions.js @@ -0,0 +1,79 @@ +import { ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js'; +import { createEmbed } from '../../../utils/embeds.js'; +import { memberAccessLevel, AccessLevel } from '../../community/permissions.js'; +import { getConfig } from '../../community/store.js'; +import { logEvent, EVENT_TYPES } from '../../../services/loggingService.js'; + +const key = (guildId, kind, id) => `community:${guildId}:${kind}:${id}`; +const counts = record => record.options?.map((_, index) => Object.values(record.votes || {}).filter(v => (Array.isArray(v) ? v : [v]).includes(index)).length) || []; + +const vote = { name: 'community_vote', async execute(interaction, client, args) { + const [kind, id, choice] = args; + const record = await client.db.get(key(interaction.guildId, kind, id)); + if (!record || record.status !== 'open') return interaction.reply({ content: 'ההצבעה אינה פעילה.', flags: MessageFlags.Ephemeral }); + if (record.closesAt && Date.now() >= record.closesAt) return interaction.reply({ content: 'הסקר כבר הסתיים.', flags: MessageFlags.Ephemeral }); + record.votes ||= {}; + if (kind === 'suggest') { + if (record.votes[interaction.user.id] === choice) return interaction.reply({ content: 'כבר הצבעת באפשרות זו.', flags: MessageFlags.Ephemeral }); + record.votes[interaction.user.id] = choice; + } else { + const option = Number(choice); + const previous = record.votes[interaction.user.id]; + if (!record.multiple) { + if (previous === option) return interaction.reply({ content: 'כבר הצבעת לאפשרות זו.', flags: MessageFlags.Ephemeral }); + record.votes[interaction.user.id] = option; + } else { + const selected = Array.isArray(previous) ? previous : []; + record.votes[interaction.user.id] = selected.includes(option) ? selected.filter(value => value !== option) : [...selected, option]; + } + } + await client.db.set(key(interaction.guildId, kind, id), record); + const config = await getConfig(client, interaction.guildId); + let content = 'ההצבעה שלך נשמרה.'; + if (config.community.publicVoteTotals) content += kind === 'suggest' + ? ` בעד: ${Object.values(record.votes).filter(v => v === 'up').length} | נגד: ${Object.values(record.votes).filter(v => v === 'down').length}` + : ` תוצאות: ${counts(record).map((total, index) => `${index + 1}: ${total}`).join(' | ')}`; + return interaction.reply({ content, flags: MessageFlags.Ephemeral }); +} }; + +const status = { name: 'community_status', async execute(interaction, client, args) { + const [kind, id, nextStatus] = args; + const required = kind === 'report' ? AccessLevel.MODERATOR : AccessLevel.HELPER; + if (await memberAccessLevel(interaction, client) < required) return interaction.reply({ content: 'אין לך הרשאה להשתמש בכפתור הזה.', flags: MessageFlags.Ephemeral }); + const record = await client.db.get(key(interaction.guildId, kind, id)); + if (!record) return interaction.reply({ content: 'הרשומה אינה קיימת.', flags: MessageFlags.Ephemeral }); + const previous = record.status; record.status = nextStatus; record.handlerId = interaction.user.id; record.updatedAt = Date.now(); + await client.db.set(key(interaction.guildId, kind, id), record); + const embed = createEmbed({ title: interaction.message.embeds[0]?.title || `רשומה #${id}`, description: interaction.message.embeds[0]?.description || '', fields: interaction.message.embeds[0]?.fields || [], color: ['approved', 'completed', 'resolved'].includes(nextStatus) ? 'success' : nextStatus === 'rejected' || nextStatus === 'closed' ? 'error' : 'warning', footer: { text: `סטטוס: ${nextStatus} • מטפל: ${interaction.user.username}` } }); + await interaction.update({ embeds: [embed], components: interaction.message.components }); + await logEvent({ client, guildId: interaction.guildId, eventType: EVENT_TYPES.SETTINGS_CHANGE, data: { title: `שינוי סטטוס ${kind} #${id}`, description: `${previous} → ${nextStatus} על ידי <@${interaction.user.id}>.` } }); +} }; + +const interest = { name: 'community_interest', async execute(interaction, client, args) { + const [kind, id] = args; const record = await client.db.get(key(interaction.guildId, kind, id)); + if (!record || record.status !== 'open') return interaction.reply({ content: 'הפרסום אינו פעיל.', flags: MessageFlags.Ephemeral }); + if (record.authorId === interaction.user.id) return interaction.reply({ content: 'לא ניתן להביע עניין בפרסום של עצמך.', flags: MessageFlags.Ephemeral }); + const author = await client.users.fetch(record.authorId).catch(() => null); + if (!author) return interaction.reply({ content: 'לא ניתן ליצור קשר עם מפרסם הבקשה.', flags: MessageFlags.Ephemeral }); + await author.send({ embeds: [createEmbed({ title: `התעניינות בפרסום #${id}`, description: `${interaction.user} מעוניין/ת בפרסום שלך.\nמזהה משתמש: \`${interaction.user.id}\``, color: 'success' })] }).catch(() => null); + return interaction.reply({ content: 'פרטי ההתעניינות נשלחו למפרסם באופן פרטי.', flags: MessageFlags.Ephemeral }); +} }; + +const contact = { name: 'community_contact', async execute(interaction, client, args) { + const [kind, id] = args; const record = await client.db.get(key(interaction.guildId, kind, id)); + if (!record) return interaction.reply({ content: 'הפרסום אינו קיים.', flags: MessageFlags.Ephemeral }); + return interaction.reply({ embeds: [createEmbed({ title: 'יצירת קשר', description: record.contact || `<@${record.authorId}>`, color: 'primary' })], flags: MessageFlags.Ephemeral }); +} }; + +const close = { name: 'community_close', async execute(interaction, client, args) { + const [kind, id] = args; const record = await client.db.get(key(interaction.guildId, kind, id)); + if (!record) return interaction.reply({ content: 'הפרסום אינו קיים.', flags: MessageFlags.Ephemeral }); + const staff = await memberAccessLevel(interaction, client) >= AccessLevel.MODERATOR; + if (record.authorId !== interaction.user.id && !staff) return interaction.reply({ content: 'רק המפרסם או צוות הניהול יכולים לסגור את הפרסום.', flags: MessageFlags.Ephemeral }); + record.status = 'closed'; record.closedBy = interaction.user.id; await client.db.set(key(interaction.guildId, kind, id), record); + const disabled = interaction.message.components.map(row => ({ type: 1, components: row.components.map(button => ButtonBuilder.from(button).setDisabled(true)) })); + await interaction.update({ components: disabled }); + await logEvent({ client, guildId: interaction.guildId, eventType: EVENT_TYPES.SETTINGS_CHANGE, data: { title: `פרסום #${id} נסגר`, description: `נסגר על ידי <@${interaction.user.id}>.` } }); +} }; + +export default [vote, status, interest, contact, close]; diff --git a/src/modules/interactions/buttons/role_system.js b/src/modules/interactions/buttons/role_system.js new file mode 100644 index 0000000000..0e41cc9222 --- /dev/null +++ b/src/modules/interactions/buttons/role_system.js @@ -0,0 +1,10 @@ +import { MessageFlags, PermissionsBitField } from 'discord.js'; +import { getConfig, updateConfig } from '../../community/store.js'; +import { panelKey, roleTemplates, validateRoleAction } from '../../../services/roleSystemService.js'; +import { logEvent, EVENT_TYPES } from '../../../services/loggingService.js'; + +const panelButton={name:'role_panel_button',async execute(i,client,args){const[panelId,roleId]=args,panel=await client.db.get(panelKey(i.guildId,panelId));if(!panel||!panel.roleIds.includes(roleId))return i.reply({content:'פאנל התפקידים לא נמצא.',flags:MessageFlags.Ephemeral});const role=i.guild.roles.cache.get(roleId),error=await validateRoleAction(i.guild,i.member,role,{selfAssignable:true});if(error)return i.reply({content:error,flags:MessageFlags.Ephemeral});const has=i.member.roles.cache.has(roleId);if(!has){const count=panel.roleIds.filter(id=>i.member.roles.cache.has(id)).length;if(count>=panel.maxSelections)return i.reply({content:`ניתן לבחור עד ${panel.maxSelections} תפקידים בפאנל זה.`,flags:MessageFlags.Ephemeral});}await i.member.roles[has?'remove':'add'](role,'פאנל תפקידים');return i.reply({content:`${role} ${has?'הוסר':'נוסף'} בהצלחה.`,flags:MessageFlags.Ephemeral});}}; +const panelDelete={name:'role_panel_delete',async execute(i,client,args){const[id,userId]=args;if(i.user.id!==userId)return i.reply({content:'רק מי שביקש את המחיקה יכול לאשר אותה.',flags:MessageFlags.Ephemeral});const panel=await client.db.get(panelKey(i.guildId,id));if(!panel)return i.update({content:'פאנל התפקידים לא נמצא.',embeds:[],components:[]});const channel=i.guild.channels.cache.get(panel.channelId),message=channel?.isTextBased()?await channel.messages.fetch(panel.messageId).catch(()=>null):null;if(message)await message.delete().catch(()=>message.edit({components:[]}));await client.db.delete(panelKey(i.guildId,id));const config=await getConfig(client,i.guildId);await updateConfig(client,i.guildId,{roles:{panels:(config.roles.panels||[]).filter(value=>value!==id)}});await logEvent({client,guildId:i.guildId,eventType:EVENT_TYPES.ROLE_DELETE,data:{title:`פאנל #${id} נמחק`,description:`נמחק על ידי ${i.user}.`}});return i.update({content:`פאנל #${id} נמחק. התפקידים עצמם לא נמחקו.`,embeds:[],components:[]});}}; +const confirm={name:'role_confirm',async execute(i,client,args){const id=args[0],key=`community:${i.guildId}:roleaction:${id}`,action=await client.db.get(key);if(!action||action.actorId!==i.user.id)return i.reply({content:'בקשת האישור אינה קיימת או פגה.',flags:MessageFlags.Ephemeral});if(action.action==='create'){const role=await i.guild.roles.create({name:action.name,color:action.color,hoist:action.hoist,mentionable:action.mentionable,permissions:new PermissionsBitField(roleTemplates[action.template]),position:Math.max(1,i.guild.members.me.roles.highest.position-1),reason:`נוצר על ידי ${i.user.tag}`});await client.db.delete(key);await logEvent({client,guildId:i.guildId,eventType:EVENT_TYPES.ROLE_CREATE,data:{title:'תפקיד נוצר',description:`${role} נוצר על ידי ${i.user}.`}});return i.update({content:`התפקיד ${role} נוצר בהצלחה.`,embeds:[],components:[]});}const role=i.guild.roles.cache.get(action.roleId);if(!role)return i.update({content:'התפקיד שנבחר כבר לא קיים.',embeds:[],components:[]});const error=await validateRoleAction(i.guild,i.member,role);if(error)return i.reply({content:error,flags:MessageFlags.Ephemeral});const name=role.name;await role.delete(`נמחק על ידי ${i.user.tag}`);await client.db.delete(key);await logEvent({client,guildId:i.guildId,eventType:EVENT_TYPES.ROLE_DELETE,data:{title:'תפקיד נמחק',description:`**${name}** נמחק על ידי ${i.user}.`}});return i.update({content:`התפקיד **${name}** נמחק.`,embeds:[],components:[]});}}; +const cancel={name:'role_cancel',async execute(i){return i.update({content:'הפעולה בוטלה.',embeds:[],components:[]});}}; +export default [panelButton,panelDelete,confirm,cancel]; diff --git a/src/modules/interactions/buttons/settings_page.js b/src/modules/interactions/buttons/settings_page.js new file mode 100644 index 0000000000..c191f50bf4 --- /dev/null +++ b/src/modules/interactions/buttons/settings_page.js @@ -0,0 +1,17 @@ +import { MessageFlags } from 'discord.js'; +import { getConfig } from '../../community/store.js'; +import { createSettingsPage, createSettingsComponents } from '../../../services/settingsOverview.js'; + +const pages = new Set(['overview', 'systems', 'access', 'commands', 'logging']); + +export default { + name: 'settings_page', + async execute(interaction, client, args) { + const [ownerId, requested, previous] = args; + if (interaction.user.id !== ownerId) return interaction.reply({ content: 'רק מי שפתח את לוח ההגדרות יכול להשתמש בכפתורים שלו.', flags: MessageFlags.Ephemeral }); + const page = requested === 'refresh' ? previous : requested; + if (!pages.has(page)) return interaction.reply({ content: 'עמוד ההגדרות אינו קיים.', flags: MessageFlags.Ephemeral }); + const config = await getConfig(client, interaction.guildId); + await interaction.update({ embeds: [createSettingsPage(config, page)], components: createSettingsComponents(ownerId, page) }); + } +}; diff --git a/src/modules/interactions/buttons/ticket_actions.js b/src/modules/interactions/buttons/ticket_actions.js new file mode 100644 index 0000000000..7f1679b627 --- /dev/null +++ b/src/modules/interactions/buttons/ticket_actions.js @@ -0,0 +1,10 @@ +import { ActionRowBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, MessageFlags } from 'discord.js'; +import { getConfig } from '../../community/store.js'; +import { buildTranscript, getTicket, saveTicket, ticketAccess, ticketMessagePayload } from '../../../services/ticketSystemService.js'; +import { logEvent, EVENT_TYPES } from '../../../services/loggingService.js'; +const field=(id,label,style=TextInputStyle.Short,required=true)=>new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId(id).setLabel(label).setStyle(style).setRequired(required).setMaxLength(500)); +const action={name:'ticket_action',async execute(i,client,args){const action=args[0],ticket=await getTicket(client,i.guildId,i.channelId);if(!ticket)return i.reply({content:'הכפתור זמין רק בתוך כרטיס תמיכה.',flags:MessageFlags.Ephemeral});if(['close','add','remove','rename'].includes(action)){const modal=new ModalBuilder().setCustomId(`ticket_action_form:${action}`).setTitle('ניהול כרטיס תמיכה');modal.addComponents(field(action==='close'?'reason':action==='rename'?'name':'member',action==='close'?'סיבת הסגירה':action==='rename'?'שם חדש לערוץ':'מזהה המשתמש',TextInputStyle.Short,action!=='close'));return i.showModal(modal);}if(action==='transcript'){if(!await ticketAccess(i,client,ticket))return i.reply({content:'אין לך הרשאה.',flags:MessageFlags.Ephemeral});return i.reply({content:'התמלול נוצר באופן פרטי.',files:[await buildTranscript(i.channel)],flags:MessageFlags.Ephemeral});}if(action==='claim'){if(!await ticketAccess(i,client,ticket,{staffOnly:true}))return i.reply({content:'אין לך הרשאת צוות.',flags:MessageFlags.Ephemeral});if(ticket.assignedStaffId&&ticket.assignedStaffId!==i.user.id)return i.reply({content:'הכרטיס כבר נלקח על ידי איש צוות אחר.',flags:MessageFlags.Ephemeral});ticket.assignedStaffId=i.user.id;ticket.status='claimed';await saveTicket(client,ticket);await i.message.edit(ticketMessagePayload(ticket));return i.reply({content:`הכרטיס נלקח על ידי ${i.user}.`,flags:MessageFlags.Ephemeral});}const config=await getConfig(client,i.guildId),last=Number(ticket.lastStaffAlertAt||0);if(Date.now()-last<config.tickets.staffAlertCooldownSeconds*1000)return i.reply({content:'יש להמתין לפני הזעקת הצוות שוב.',flags:MessageFlags.Ephemeral});ticket.lastStaffAlertAt=Date.now();ticket.lastStaffAlertBy=i.user.id;await saveTicket(client,ticket);await i.channel.send({content:`<@&${config.tickets.supportRoleId}> נדרשת עזרת צוות בכרטיס #${ticket.id}.`,allowedMentions:{roles:[config.tickets.supportRoleId]}});await logEvent({client,guildId:i.guildId,eventType:EVENT_TYPES.TICKET_CLAIM,data:{title:`הזעקת צוות בכרטיס #${ticket.id}`,description:`הופעלה על ידי ${i.user}.`}});return i.reply({content:'צוות התמיכה הוזעק.',flags:MessageFlags.Ephemeral});}}; +const confirm={name:'ticket_close_confirm',async execute(i,client,args){const reason=Buffer.from(args[0]||'','base64url').toString('utf8')||'לא צוינה סיבה';const {closeTicketInteraction}=await import('./ticket_close.js');return closeTicketInteraction(i,client,reason);}}; +const cancel={name:'ticket_close_cancel',async execute(i){return i.update({content:'הסגירה בוטלה.',embeds:[],components:[]});}}; +const panelDelete={name:'ticket_panel_delete',async execute(i,client,args){const[id,userId]=args;if(i.user.id!==userId)return i.reply({content:'רק מי שביקש את המחיקה יכול לאשר.',flags:MessageFlags.Ephemeral});const key=`community:${i.guildId}:ticketpanel:${id}`,panel=await client.db.get(key);if(!panel)return i.update({content:'לוח הכרטיסים לא נמצא.',components:[]});const config=await getConfig(client,i.guildId),channel=i.guild.channels.cache.get(panel.channelId),message=await channel?.messages.fetch(panel.messageId).catch(()=>null);await message?.delete().catch(()=>null);await client.db.delete(key);const{updateConfig}=await import('../../community/store.js');await updateConfig(client,i.guildId,{tickets:{panels:(config.tickets.panels||[]).filter(value=>value!==id)}});return i.update({content:`לוח #${id} נמחק. כרטיסים קיימים נשמרו.`,components:[]});}}; +export default[action,confirm,cancel,panelDelete]; diff --git a/src/modules/interactions/buttons/ticket_close.js b/src/modules/interactions/buttons/ticket_close.js new file mode 100644 index 0000000000..cc93167caf --- /dev/null +++ b/src/modules/interactions/buttons/ticket_close.js @@ -0,0 +1,7 @@ +import { MessageFlags } from 'discord.js'; +import { getConfig } from '../../community/store.js'; +import { buildTranscript, getTicket, saveTicket, ticketAccess, ticketMessagePayload } from '../../../services/ticketSystemService.js'; +import { logEvent, EVENT_TYPES } from '../../../services/loggingService.js'; + +export async function closeTicketInteraction(i,client,reason='לא צוינה סיבה'){const ticket=await getTicket(client,i.guildId,i.channelId);if(!ticket)return i.reply({content:'הפקודה הזאת זמינה רק בתוך כרטיס תמיכה.',flags:MessageFlags.Ephemeral});const config=await getConfig(client,i.guildId),staff=await ticketAccess(i,client,ticket,{staffOnly:true});if(i.user.id!==ticket.creatorId&&!staff||i.user.id===ticket.creatorId&&!config.tickets.creatorCanClose&&!staff)return i.reply({content:'אין לך הרשאה לנהל את הכרטיס הזה.',flags:MessageFlags.Ephemeral});ticket.status='closing';ticket.closedAt=Date.now();ticket.closedBy=i.user.id;ticket.closeReason=reason;let transcript=null;if(config.tickets.transcriptsEnabled){try{transcript=await buildTranscript(i.channel);const destination=i.guild.channels.cache.get(config.tickets.transcriptChannelId||config.tickets.logsChannelId);if(destination?.isTextBased()){const sent=await destination.send({content:`תמלול כרטיס #${ticket.id}`,files:[transcript]});ticket.transcriptLocation=sent.url;transcript=await buildTranscript(i.channel);}}catch{ticket.transcriptLocation='failed';}}await saveTicket(client,ticket);const opening=ticket.openingMessageId?await i.channel.messages.fetch(ticket.openingMessageId).catch(()=>null):null;if(opening)await opening.edit(ticketMessagePayload(ticket,{disabled:true}));await logEvent({client,guildId:i.guildId,eventType:EVENT_TYPES.TICKET_CLOSE,data:{title:`כרטיס #${ticket.id} נסגר`,description:`נסגר על ידי ${i.user}. סיבה: ${reason}`}});if(config.tickets.dmNotifications){const creator=await client.users.fetch(ticket.creatorId).catch(()=>null);await creator?.send(`כרטיס #${ticket.id} נסגר. סיבה: ${reason}`).catch(()=>null);}await i.update?.({content:`הכרטיס נסגר. ${config.tickets.archiveEnabled?'הערוץ יועבר לארכיון':'הערוץ יימחק'} בעוד ${config.tickets.closeDelaySeconds} שניות.`,embeds:[],components:[]}).catch?.(()=>null);setTimeout(async()=>{ticket.status='closed';await saveTicket(client,ticket);if(config.tickets.archiveEnabled&&config.tickets.archiveCategoryId){await i.channel.setParent(config.tickets.archiveCategoryId,{lockPermissions:true}).catch(()=>null);await i.channel.permissionOverwrites.edit(ticket.creatorId,{SendMessages:false}).catch(()=>null);}else await i.channel.delete(`Ticket #${ticket.id} closed`).catch(()=>null);},config.tickets.closeDelaySeconds*1000);} +export default{name:'ticket_close',async execute(i,client){return closeTicketInteraction(i,client);}}; diff --git a/src/modules/interactions/buttons/ticket_open.js b/src/modules/interactions/buttons/ticket_open.js new file mode 100644 index 0000000000..8107e3ac78 --- /dev/null +++ b/src/modules/interactions/buttons/ticket_open.js @@ -0,0 +1,4 @@ +import { MessageFlags } from 'discord.js'; +import { getConfig } from '../../community/store.js'; +import { ticketPanelPayload } from '../../../services/ticketSystemService.js'; +export default{name:'ticket_open',async execute(i,client){const config=await getConfig(client,i.guildId);if(!config.tickets.enabled)return i.reply({content:'מערכת הכרטיסים עדיין לא הוגדרה.',flags:MessageFlags.Ephemeral});return i.reply({...ticketPanelPayload(config),flags:MessageFlags.Ephemeral});}}; diff --git a/src/modules/interactions/buttons/ticket_type_button.js b/src/modules/interactions/buttons/ticket_type_button.js new file mode 100644 index 0000000000..24303903bf --- /dev/null +++ b/src/modules/interactions/buttons/ticket_type_button.js @@ -0,0 +1,2 @@ +import ticketType from '../selectMenus/ticket_type.js'; +export default{name:'ticket_type_button',async execute(i,client,args){i.values=[args[0]];return ticketType.execute(i,client,[args[1]||'default']);}}; diff --git a/src/modules/interactions/buttons/verify.js b/src/modules/interactions/buttons/verify.js new file mode 100644 index 0000000000..f05b5c922d --- /dev/null +++ b/src/modules/interactions/buttons/verify.js @@ -0,0 +1,2 @@ +import { getConfig } from '../../community/store.js'; import { success, error } from '../../community/ui.js'; +export default { name:'verify',async execute(i,client){const c=await getConfig(client,i.guildId);if(!c.verification.enabled||!c.verification.roleId)return i.reply(error('מערכת האימות אינה מוגדרת.'));const role=i.guild.roles.cache.get(c.verification.roleId);if(!role)return i.reply(error('תפקיד האימות כבר אינו קיים.'));if(i.member.roles.cache.has(role.id))return i.reply(error('כבר אומתת.'));await i.member.roles.add(role,'Member verification');await i.reply({...success('האימות הושלם','התפקיד נוסף לחשבונך בהצלחה.'),ephemeral:true});} }; diff --git a/src/modules/interactions/modals/community_form.js b/src/modules/interactions/modals/community_form.js new file mode 100644 index 0000000000..9024d6f0f3 --- /dev/null +++ b/src/modules/interactions/modals/community_form.js @@ -0,0 +1,89 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js'; +import { createEmbed } from '../../../utils/embeds.js'; +import { getConfig } from '../../community/store.js'; +import { logEvent, EVENT_TYPES } from '../../../services/loggingService.js'; +import { createOwnerInboxCase, deliverOwnerInboxCase, isOwnerInboxSubmission } from '../../../services/ownerInboxService.js'; + +const channelNames = { suggest: 'suggestions', feedback: 'feedback', report: 'reports', selfpromo: 'selfPromotion', lookingforeditor: 'lookingForEditor', lookingforteam: 'lookingForTeam' }; +const titles = { suggest: 'הצעה', feedback: 'משוב', report: 'דיווח', selfpromo: 'פרסום עצמי', lookingforeditor: 'חיפוש עורך', lookingforteam: 'חיפוש צוות' }; +const allowedValues = { + feedback: new Set(['השרת', 'הבוט', 'הצוות', 'רעיון לשיפור', 'אחר']), + report: new Set(['משתמש', 'הודעה', 'הטרדה', 'ספאם', 'גניבת תוכן', 'בעיה טכנית', 'אחר']), + selfpromo: new Set(['tiktok', 'youtube', 'instagram', 'twitch', 'portfolio', 'other']) +}; +const values = (interaction, ids) => Object.fromEntries(ids.map(id => [id, interaction.fields.getTextInputValue(id).trim()])); +const statusButton = (kind, id, status, label, style = ButtonStyle.Secondary) => new ButtonBuilder().setCustomId(`community_status:${kind}:${id}:${status}`).setLabel(label).setStyle(style); + +export default { + name: 'community_form', + async execute(interaction, client, args) { + const [kind, encoded] = args; + const config = await getConfig(client, interaction.guildId); + const privateOwnerInbox = isOwnerInboxSubmission(interaction.guildId, kind); + const channelId = config.channels[channelNames[kind]]; + const channel = channelId && interaction.guild.channels.cache.get(channelId); + if (!privateOwnerInbox && !channel?.isTextBased()) return interaction.reply({ content: kind === 'suggest' ? 'ערוץ ההצעות עדיין לא הוגדר.' : 'ערוץ המערכת עדיין לא הוגדר.', flags: MessageFlags.Ephemeral }); + const cooldownSeconds = config.community.cooldowns[kind] || 0; + const cooldownKey = `community:${interaction.guildId}:cooldown:${kind}:${interaction.user.id}`; + const last = Number(await client.db.get(cooldownKey, 0)); + if (Date.now() - last < cooldownSeconds * 1000) return interaction.reply({ content: `יש להמתין עוד ${Math.ceil((last + cooldownSeconds * 1000 - Date.now()) / 60000)} דקות לפני שליחת בקשה נוספת.`, flags: MessageFlags.Ephemeral }); + + let data; let components = []; + if (kind === 'suggest') { + data = values(interaction, ['title', 'description']); + } else if (kind === 'feedback') { + data = values(interaction, ['category', 'content']); + if (!allowedValues.feedback.has(data.category)) return interaction.reply({ content: 'סוג המשוב אינו תקין. יש לבחור אחת מהקטגוריות המוצגות בטופס.', flags: MessageFlags.Ephemeral }); + data.anonymous = interaction.message == null && interaction.user && false; + // The slash option is unavailable on modal submit; preserve it in the modal id in future calls. + data.anonymous = encoded === 'anonymous'; + } else if (kind === 'report') { + data = values(interaction, ['type', 'reported_user', 'description', 'evidence']); + if (!allowedValues.report.has(data.type)) return interaction.reply({ content: 'סוג הדיווח אינו תקין.', flags: MessageFlags.Ephemeral }); + if (data.evidence && !/^https?:\/\/\S+$/i.test(data.evidence)) return interaction.reply({ content: 'הקישור שסופק אינו תקין.', flags: MessageFlags.Ephemeral }); + } else if (kind === 'selfpromo') { + data = values(interaction, ['platform', 'display_name', 'link', 'description']); + if (!allowedValues.selfpromo.has(data.platform.toLowerCase())) return interaction.reply({ content: 'הפלטפורמה שנבחרה אינה נתמכת.', flags: MessageFlags.Ephemeral }); + if (!/^https?:\/\/\S+$/i.test(data.link)) return interaction.reply({ content: 'הקישור שסופק אינו תקין.', flags: MessageFlags.Ephemeral }); + if (!config.community.allowDiscordInvites && /(?:discord\.gg|discord(?:app)?\.com\/invite)\//i.test(data.link)) return interaction.reply({ content: 'קישורי הזמנה ל־Discord אינם מורשים בפרסום עצמי.', flags: MessageFlags.Ephemeral }); + } else { + data = kind === 'lookingforeditor' + ? values(interaction, ['video_type', 'deadline', 'style', 'contact', 'description']) + : values(interaction, ['project', 'deadline', 'experience', 'contact', 'description']); + Object.assign(data, JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'))); + } + if (privateOwnerInbox) { + const record = await createOwnerInboxCase(client, interaction, kind, data); + const delivered = await deliverOwnerInboxCase(client, record); + await client.db.set(cooldownKey, Date.now()); + return interaction.reply({ content: delivered + ? '✅ ההודעה שלך נשלחה בהצלחה לצוות השרת.' + : '⚠️ אירעה תקלה זמנית בשליחת ההודעה.\nהצוות יקבל אותה ברגע שהחיבור יחזור.', flags: MessageFlags.Ephemeral }); + } + const id = String(await client.db.increment(`community:${interaction.guildId}:sequence:${kind}`)); + const record = { id, kind, authorId: interaction.user.id, ...data, status: 'open', createdAt: Date.now(), handlerId: null }; + const fields = []; + if (kind === 'suggest') fields.push({ name: 'כותרת', value: data.title }, { name: 'פירוט', value: data.description }); + if (kind === 'feedback') fields.push({ name: 'סוג המשוב', value: data.category }, { name: 'תוכן', value: data.content }); + if (kind === 'report') fields.push({ name: 'סוג', value: data.type, inline: true }, { name: 'משתמש מדווח', value: data.reported_user || 'לא צוין', inline: true }, { name: 'תיאור', value: data.description }, { name: 'הוכחה', value: data.evidence || 'לא צורפה' }); + if (kind === 'selfpromo') fields.push({ name: 'יוצר', value: `${interaction.user}`, inline: true }, { name: 'פלטפורמה', value: data.platform, inline: true }, { name: 'שם תצוגה', value: data.display_name, inline: true }, { name: 'תיאור', value: data.description }, { name: 'קישור', value: `[פתיחת העמוד](${data.link})` }); + if (kind.startsWith('lookingfor')) fields.push({ name: 'תשלום', value: data.paid ? `בתשלום — ${data.budget || 'לא צוין תקציב'}` : 'ללא תשלום', inline: true }, { name: 'מועד אחרון', value: data.deadline, inline: true }, { name: 'יצירת קשר', value: data.contact }, { name: 'פרטים', value: data.description }); + if (kind === 'suggest') components = [new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId(`community_vote:suggest:${id}:up`).setLabel('בעד').setEmoji('👍').setStyle(ButtonStyle.Success), + new ButtonBuilder().setCustomId(`community_vote:suggest:${id}:down`).setLabel('נגד').setEmoji('👎').setStyle(ButtonStyle.Danger), + statusButton(kind, id, 'approved', 'אושר', ButtonStyle.Success), statusButton(kind, id, 'rejected', 'נדחה', ButtonStyle.Danger), statusButton(kind, id, 'discussion', 'בדיון') + )]; + if (kind === 'feedback') components = [new ActionRowBuilder().addComponents(statusButton(kind, id, 'reviewed', 'נבדק'), statusButton(kind, id, 'handling', 'בטיפול', ButtonStyle.Primary), statusButton(kind, id, 'completed', 'הושלם', ButtonStyle.Success))]; + if (kind === 'report') components = [new ActionRowBuilder().addComponents(statusButton(kind, id, 'open', 'פתוח'), statusButton(kind, id, 'investigating', 'בבדיקה', ButtonStyle.Primary), statusButton(kind, id, 'resolved', 'טופל', ButtonStyle.Success), statusButton(kind, id, 'closed', 'נסגר', ButtonStyle.Danger))]; + if (kind.startsWith('lookingfor')) components = [new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId(`community_interest:${kind}:${id}`).setLabel('אני מעוניין').setStyle(ButtonStyle.Success), new ButtonBuilder().setCustomId(`community_contact:${kind}:${id}`).setLabel('צור קשר').setStyle(ButtonStyle.Primary), new ButtonBuilder().setCustomId(`community_close:${kind}:${id}`).setLabel('סגירת הפרסום').setStyle(ButtonStyle.Danger))]; + const publicAuthor = kind === 'feedback' && data.anonymous ? 'אנונימי' : `${interaction.user}`; + if (!['selfpromo'].includes(kind)) fields.unshift({ name: kind === 'report' ? 'מדווח' : 'מפרסם', value: publicAuthor, inline: true }, { name: 'תאריך', value: `<t:${Math.floor(Date.now() / 1000)}:F>`, inline: true }); + const message = await channel.send({ embeds: [createEmbed({ title: `${titles[kind]} #${id}`, fields, color: kind === 'report' ? 'warning' : 'primary', footer: { text: `מזהה: ${id}` } })], components }); + Object.assign(record, { messageId: message.id, channelId: channel.id }); + await client.db.set(`community:${interaction.guildId}:${kind}:${id}`, record); + await client.db.set(cooldownKey, Date.now()); + await logEvent({ client, guildId: interaction.guildId, eventType: kind === 'report' ? EVENT_TYPES.REPORT : EVENT_TYPES.SUGGESTION, data: { title: `${titles[kind]} חדש #${id}`, description: `נשלח על ידי <@${interaction.user.id}> לערוץ <#${channel.id}>.` } }); + const confirmations = { suggest: 'ההצעה שלך נשלחה בהצלחה.', feedback: 'המשוב התקבל בהצלחה.', report: `הדיווח התקבל. מספר המקרה שלך הוא #${id}.`, selfpromo: 'הפרסום פורסם בהצלחה.', lookingforeditor: 'בקשת חיפוש העורך פורסמה.', lookingforteam: 'בקשת חיפוש הצוות פורסמה.' }; + return interaction.reply({ content: confirmations[kind], flags: MessageFlags.Ephemeral }); + } +}; diff --git a/src/modules/interactions/modals/ticket_action_form.js b/src/modules/interactions/modals/ticket_action_form.js new file mode 100644 index 0000000000..73130e1ada --- /dev/null +++ b/src/modules/interactions/modals/ticket_action_form.js @@ -0,0 +1,4 @@ +import { MessageFlags, PermissionFlagsBits } from 'discord.js'; +import { getConfig } from '../../community/store.js'; +import { getTicket, saveTicket, safeChannelName, ticketAccess } from '../../../services/ticketSystemService.js'; +export default{name:'ticket_action_form',async execute(i,client,args){const action=args[0],ticket=await getTicket(client,i.guildId,i.channelId);if(!ticket)return i.reply({content:'הפעולה זמינה רק בתוך כרטיס.',flags:MessageFlags.Ephemeral});if(action==='close'){const {closeTicketInteraction}=await import('../buttons/ticket_close.js');i.update=i.reply.bind(i);return closeTicketInteraction(i,client,i.fields.getTextInputValue('reason')||'לא צוינה סיבה');}const config=await getConfig(client,i.guildId),staff=await ticketAccess(i,client,ticket,{staffOnly:true});if(!staff&&!(action==='add'&&config.tickets.creatorCanAdd&&i.user.id===ticket.creatorId)&&!(action==='rename'&&config.tickets.creatorCanRename&&i.user.id===ticket.creatorId))return i.reply({content:'אין לך הרשאה לנהל את הכרטיס הזה.',flags:MessageFlags.Ephemeral});if(action==='rename'){const name=safeChannelName(i.fields.getTextInputValue('name'),ticket.id);await i.channel.setName(name);ticket.channelName=name;}else{const id=i.fields.getTextInputValue('member').replace(/\D/g,''),member=await i.guild.members.fetch(id).catch(()=>null);if(!member||member.user.bot&&!i.member.permissions.has(PermissionFlagsBits.Administrator))return i.reply({content:'לא ניתן לנהל את המשתמש הזה.',flags:MessageFlags.Ephemeral});if(action==='remove'&&(member.id===ticket.creatorId||member.id===i.guild.members.me.id))return i.reply({content:'לא ניתן להסיר את יוצר הכרטיס.',flags:MessageFlags.Ephemeral});if(action==='add'){if(ticket.addedMemberIds.includes(member.id))return i.reply({content:'המשתמש כבר נוסף.',flags:MessageFlags.Ephemeral});if(ticket.addedMemberIds.length>=config.tickets.maxAddedUsers)return i.reply({content:'הגעתם למספר המשתמשים המרבי.',flags:MessageFlags.Ephemeral});await i.channel.permissionOverwrites.edit(member,{ViewChannel:true,SendMessages:true,AttachFiles:true,EmbedLinks:true,ReadMessageHistory:true});ticket.addedMemberIds.push(member.id);}else{await i.channel.permissionOverwrites.delete(member.id);ticket.addedMemberIds=ticket.addedMemberIds.filter(value=>value!==member.id);}}await saveTicket(client,ticket);return i.reply({content:'הפעולה בוצעה בהצלחה.',flags:MessageFlags.Ephemeral});}}; diff --git a/src/modules/interactions/modals/ticket_create.js b/src/modules/interactions/modals/ticket_create.js new file mode 100644 index 0000000000..16d7c311ab --- /dev/null +++ b/src/modules/interactions/modals/ticket_create.js @@ -0,0 +1,5 @@ +import { ChannelType, MessageFlags, PermissionFlagsBits } from 'discord.js'; +import { getConfig, updateConfig, ticketKey } from '../../community/store.js'; +import { normalizeTicket, safeChannelName, saveTicket, ticketMessagePayload, TICKET_TYPES } from '../../../services/ticketSystemService.js'; +import { logEvent, EVENT_TYPES } from '../../../services/loggingService.js'; +export default{name:'ticket_create',async execute(i,client,args){const[type,panelId]=args,config=await getConfig(client,i.guildId),category=i.guild.channels.cache.get(config.tickets.categoryId),support=i.guild.roles.cache.get(config.tickets.supportRoleId);if(!config.tickets.enabled||category?.type!==ChannelType.GuildCategory||!support)return i.reply({content:'מערכת הכרטיסים עדיין לא הוגדרה.',flags:MessageFlags.Ephemeral});if(!i.guild.members.me.permissions.has(PermissionFlagsBits.ManageChannels))return i.reply({content:'לבוט חסרות הרשאות לניהול ערוץ הכרטיס.',flags:MessageFlags.Ephemeral});const keys=await client.db.list(`community:${i.guildId}:ticket:`),open=[];for(const key of keys){const ticket=await client.db.get(key);if((ticket.creatorId||ticket.ownerId)===i.user.id&&!['closed','closing'].includes(ticket.status||'open'))open.push(ticket);}if(open.length>=config.tickets.maxOpenPerUser)return i.reply({content:'הגעת למספר הכרטיסים הפתוחים המותר.',flags:MessageFlags.Ephemeral});if(!config.tickets.allowDuplicateTypes&&open.some(ticket=>(ticket.type||'general')===type))return i.reply({content:'כבר יש לך כרטיס פתוח מהסוג הזה.',flags:MessageFlags.Ephemeral});const id=String(config.tickets.nextNumber),definition=TICKET_TYPES[type]||TICKET_TYPES.general,get=id=>{try{return i.fields.getTextInputValue(id)?.trim()||null}catch{return null}},title=get('title'),description=get('description');const channel=await i.guild.channels.create({name:safeChannelName(i.user.username,id,definition.prefix),type:ChannelType.GuildText,parent:category,reason:`Ticket #${id} by ${i.user.tag}`,permissionOverwrites:[{id:i.guild.id,deny:[PermissionFlagsBits.ViewChannel]},{id:i.user.id,allow:[PermissionFlagsBits.ViewChannel,PermissionFlagsBits.SendMessages,PermissionFlagsBits.AttachFiles,PermissionFlagsBits.EmbedLinks,PermissionFlagsBits.ReadMessageHistory,PermissionFlagsBits.AddReactions]},{id:support.id,allow:[PermissionFlagsBits.ViewChannel,PermissionFlagsBits.SendMessages,PermissionFlagsBits.AttachFiles,PermissionFlagsBits.EmbedLinks,PermissionFlagsBits.ReadMessageHistory,PermissionFlagsBits.ManageMessages]},{id:i.guild.members.me.id,allow:[PermissionFlagsBits.ViewChannel,PermissionFlagsBits.SendMessages,PermissionFlagsBits.ManageChannels,PermissionFlagsBits.ManageMessages,PermissionFlagsBits.ReadMessageHistory]}]});const ticket=normalizeTicket({id,guildId:i.guildId,channelId:channel.id,creatorId:i.user.id,type,title,description,software:get('software'),budget:get('budget'),evidence:get('evidence'),supportRoleId:support.id,panelId,status:'open',priority:'normal',createdAt:Date.now()});const message=await channel.send({content:`${i.user} ${support}`,...ticketMessagePayload(ticket)});ticket.openingMessageId=message.id;await saveTicket(client,ticket);await updateConfig(client,i.guildId,{tickets:{nextNumber:Number(id)+1}});await logEvent({client,guildId:i.guildId,eventType:EVENT_TYPES.TICKET_CREATE,data:{title:`כרטיס #${id} נוצר`,description:`נוצר על ידי ${i.user} ב־${channel}.`}});return i.reply({content:`הכרטיס נפתח בהצלחה: ${channel}`,flags:MessageFlags.Ephemeral});}}; diff --git a/src/modules/interactions/selectMenus/editing_roles.js b/src/modules/interactions/selectMenus/editing_roles.js new file mode 100644 index 0000000000..14d81477f5 --- /dev/null +++ b/src/modules/interactions/selectMenus/editing_roles.js @@ -0,0 +1,16 @@ +import { MessageFlags } from 'discord.js'; +import { getConfig } from '../../community/store.js'; + +export default { name: 'editing_roles', async execute(interaction, client, args) { + if (args[0] !== interaction.user.id) return interaction.reply({ content: 'רק מי שפתח את התפריט יכול להשתמש בו.', flags: MessageFlags.Ephemeral }); + const config = await getConfig(client, interaction.guildId); + const protectedIds = Object.values(config.staffRoles || {}).filter(Boolean); + const allowed = (config.community.editingRoleIds || []).filter(id => !protectedIds.includes(id)); + const roles = allowed.map(id => interaction.guild.roles.cache.get(id)).filter(role => role && !role.managed && role.position < interaction.guild.members.me.roles.highest.position); + const selected = interaction.values.filter(id => roles.some(role => role.id === id)); + const current = roles.filter(role => interaction.member.roles.cache.has(role.id)).map(role => role.id); + const add = selected.filter(id => !current.includes(id)); const remove = current.filter(id => !selected.includes(id)); + if (add.length) await interaction.member.roles.add(add, 'בחירת תחומי עריכה'); + if (remove.length) await interaction.member.roles.remove(remove, 'עדכון תחומי עריכה'); + return interaction.update({ content: `הבחירה עודכנה. נוספו ${add.length} תפקידים והוסרו ${remove.length}.`, embeds: [], components: [] }); +} }; diff --git a/src/modules/interactions/selectMenus/general_help.js b/src/modules/interactions/selectMenus/general_help.js new file mode 100644 index 0000000000..cbb7881716 --- /dev/null +++ b/src/modules/interactions/selectMenus/general_help.js @@ -0,0 +1,21 @@ +import { MessageFlags } from 'discord.js'; +import { createEmbed } from '../../../utils/embeds.js'; +import { createHelpView, getVisibleHelpCommands, HELP_CATEGORIES } from '../../../commands/general/factory.js'; + +export default { + name: 'general_help', + async execute(interaction, client, args) { + if (args[0] !== interaction.user.id) { + return interaction.reply({ + embeds: [createEmbed({ title: 'אין גישה לתפריט', description: 'רק מי שפתח את תפריט העזרה יכול להשתמש בו.', color: 'error' })], + flags: MessageFlags.Ephemeral + }); + } + const category = interaction.values[0]; + if (!HELP_CATEGORIES[category]) { + return interaction.reply({ content: 'הקטגוריה שנבחרה אינה קיימת.', flags: MessageFlags.Ephemeral }); + } + const commands = await getVisibleHelpCommands(interaction, client); + return interaction.update(createHelpView(commands, category, interaction.user.id)); + } +}; diff --git a/src/modules/interactions/selectMenus/role_select.js b/src/modules/interactions/selectMenus/role_select.js new file mode 100644 index 0000000000..e0d2cdcdc8 --- /dev/null +++ b/src/modules/interactions/selectMenus/role_select.js @@ -0,0 +1,2 @@ +import { success, error } from '../../community/ui.js'; +export default { name:'role_select',async execute(i){const role=i.guild.roles.cache.get(i.values[0]);if(!role||role.managed||role.position>=i.guild.members.me.roles.highest.position)return i.reply(error('לא ניתן לנהל את התפקיד הזה.'));const has=i.member.roles.cache.has(role.id);await i.member.roles[has?'remove':'add'](role,'Self-role panel');await i.reply({...success('לוח תפקידים',has?`התפקיד ${role} הוסר.`:`התפקיד ${role} נוסף.`),ephemeral:true});} }; diff --git a/src/modules/interactions/selectMenus/role_system.js b/src/modules/interactions/selectMenus/role_system.js new file mode 100644 index 0000000000..281701ef1f --- /dev/null +++ b/src/modules/interactions/selectMenus/role_system.js @@ -0,0 +1,7 @@ +import { MessageFlags } from 'discord.js'; +import { getConfig } from '../../community/store.js'; +import { panelKey, validateRoleAction } from '../../../services/roleSystemService.js'; + +const selfRoles={name:'self_roles',async execute(i,client,args){const[userId,categoryId]=args;if(userId!==i.user.id)return i.reply({content:'רק מי שפתח את התפריט יכול להשתמש בו.',flags:MessageFlags.Ephemeral});const config=await getConfig(client,i.guildId),category=config.roles.categories?.[categoryId];if(!category||category.enabled===false)return i.reply({content:'קטגוריית התפקידים אינה זמינה.',flags:MessageFlags.Ephemeral});const allowed=[];for(const id of category.roleIds||[]){const role=i.guild.roles.cache.get(id);if(role&&!await validateRoleAction(i.guild,i.member,role,{selfAssignable:true}))allowed.push(role);}const selected=i.values.filter(id=>allowed.some(r=>r.id===id)).slice(0,category.maxSelections||allowed.length),current=allowed.filter(r=>i.member.roles.cache.has(r.id)).map(r=>r.id),add=selected.filter(id=>!current.includes(id)),remove=current.filter(id=>!selected.includes(id));if(add.length)await i.member.roles.add(add,'בחירת תפקידים עצמית');if(remove.length)await i.member.roles.remove(remove,'עדכון תפקידים עצמיים');return i.update({content:`השינויים נשמרו: ${add.length} נוספו, ${remove.length} הוסרו.`,embeds:[],components:[]});}}; +const panelSelect={name:'role_panel_select',async execute(i,client,args){const panel=await client.db.get(panelKey(i.guildId,args[0]));if(!panel)return i.reply({content:'פאנל התפקידים לא נמצא.',flags:MessageFlags.Ephemeral});const changed=[];let assigned=panel.roleIds.filter(id=>i.member.roles.cache.has(id)).length;for(const id of i.values.slice(0,panel.maxSelections)){const role=i.guild.roles.cache.get(id);const error=await validateRoleAction(i.guild,i.member,role,{selfAssignable:true});if(error)continue;const has=i.member.roles.cache.has(id);if(!has&&assigned>=panel.maxSelections)continue;await i.member.roles[has?'remove':'add'](role,'פאנל תפקידים');assigned+=has?-1:1;changed.push(`${role}: ${has?'הוסר':'נוסף'}`);}return i.reply({content:changed.join('\n')||`לא בוצעו שינויים. ניתן לבחור עד ${panel.maxSelections} תפקידים.`,flags:MessageFlags.Ephemeral});}}; +export default [selfRoles,panelSelect]; diff --git a/src/modules/interactions/selectMenus/ticket_type.js b/src/modules/interactions/selectMenus/ticket_type.js new file mode 100644 index 0000000000..706a96569c --- /dev/null +++ b/src/modules/interactions/selectMenus/ticket_type.js @@ -0,0 +1,4 @@ +import { ActionRowBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, MessageFlags } from 'discord.js'; +import { getConfig } from '../../community/store.js'; +import { TICKET_TYPES } from '../../../services/ticketSystemService.js'; +export default{name:'ticket_type',async execute(i,client,args){const type=i.values[0],definition=TICKET_TYPES[type],config=await getConfig(client,i.guildId);if(!definition||!config.tickets.enabledTypes.includes(type))return i.reply({content:'סוג הכרטיס אינו זמין.',flags:MessageFlags.Ephemeral});const modal=new ModalBuilder().setCustomId(`ticket_create:${type}:${args[0]||'default'}`).setTitle(`${definition.emoji} פתיחת פנייה | ${definition.label}`);const field=(id,label,{style=TextInputStyle.Short,required=true,placeholder,maxLength}={})=>{const input=new TextInputBuilder().setCustomId(id).setLabel(label).setStyle(style).setRequired(required).setMaxLength(maxLength||(style===TextInputStyle.Paragraph?1500:300));if(placeholder)input.setPlaceholder(placeholder);return new ActionRowBuilder().addComponents(input);};modal.addComponents(field('title','נושא הפנייה',{placeholder:'כתבו בקצרה במה נוכל לעזור',maxLength:100}),field('description','פרטי הפנייה',{style:TextInputStyle.Paragraph,placeholder:'תארו את הבקשה בפירוט כדי שנוכל לעזור במהירות...'}));if(definition.fields.includes('software'))modal.addComponents(field('software','תוכנה וגרסה',{placeholder:'לדוגמה: Premiere Pro 2025'}));if(definition.fields.includes('budget'))modal.addComponents(field('budget','תקציב משוער',{placeholder:'לדוגמה: 300–500 ₪'}));if(definition.fields.includes('evidence')&&modal.components.length<5)modal.addComponents(field('evidence','קישור או הוכחה',{required:false,placeholder:'קישור לצילום מסך, סרטון או קובץ (לא חובה)'}));return i.showModal(modal);}}; diff --git a/src/services/antiNsfwService.js b/src/services/antiNsfwService.js new file mode 100644 index 0000000000..9ab1b5d6bc --- /dev/null +++ b/src/services/antiNsfwService.js @@ -0,0 +1,217 @@ +import { logger } from '../utils/logger.js'; +import { createEmbed } from '../utils/embeds.js'; + +const CONFIG_KEY = guildId => `antinsfw:config:${guildId}`; + +const DEFAULT_CONFIG = { + enabled: false, + action: 'delete', + timeoutDuration: 5 * 60 * 1000, + logChannelId: null, + exemptChannels: [], + exemptRoles: [], + customWords: [], + imageScanning: true, + nudityThreshold: 0.75, +}; + +// Known adult content domains to block +const BLOCKED_DOMAINS = [ + 'pornhub.com', 'xvideos.com', 'xnxx.com', 'xhamster.com', + 'redtube.com', 'youporn.com', 'tube8.com', 'spankbang.com', + 'beeg.com', 'eporner.com', 'thisvid.com', 'motherless.com', + 'hentaihaven.xxx', 'rule34.xxx', 'gelbooru.com', 'danbooru.donmai.us', + 'e621.net', 'nhentai.net', 'hanime.tv', 'literotica.com', + 'chaturbate.com', 'cam4.com', 'bongacams.com', 'stripchat.com', + 'onlyfans.com', 'fansly.com', +]; + +// Base keyword list — users can extend via /antinsfw words add +const BASE_NSFW_WORDS = [ + 'pornhub', 'xvideos', 'onlyfans', 'fansly', 'camgirl', + 'nsfw', 'hentai', 'nude', 'nudes', 'nudity', + 'sex tape', 'sextape', 'xxx', 'porn', +]; + +export const AntiNsfwService = { + async getConfig(client, guildId) { + const stored = await client.db.get(CONFIG_KEY(guildId), null); + return { ...DEFAULT_CONFIG, ...(stored || {}) }; + }, + + async setConfig(client, guildId, updates) { + const current = await this.getConfig(client, guildId); + const updated = { ...current, ...updates }; + await client.db.set(CONFIG_KEY(guildId), updated); + return updated; + }, + + // Returns true if the message was flagged and acted upon + async checkMessage(client, message) { + try { + const config = await this.getConfig(client, message.guild.id); + if (!config.enabled) return false; + + // Skip NSFW-marked channels (they're allowed to have adult content) + if (message.channel.nsfw) return false; + + if (config.exemptChannels.includes(message.channel.id)) return false; + + if (config.exemptRoles.length > 0) { + const member = message.member + ?? await message.guild.members.fetch(message.author.id).catch(() => null); + if (member && member.roles.cache.some(r => config.exemptRoles.includes(r.id))) { + return false; + } + } + + // Text / link check + const textViolation = this._checkText(message.content, config); + if (textViolation) { + await this._handleViolation(client, message, config, textViolation); + return true; + } + + // Image / video attachment scan via Sightengine + if (config.imageScanning && message.attachments.size > 0) { + const media = [...message.attachments.values()].filter(a => + a.contentType?.startsWith('image/') || a.contentType?.startsWith('video/') + ); + for (const attachment of media) { + const result = await this._scanImage(attachment.url, config.nudityThreshold); + if (result.flagged) { + await this._handleViolation(client, message, config, { + type: 'image', + reason: `Explicit image/video detected (${Math.round(result.score * 100)}% confidence)`, + }); + return true; + } + } + } + + return false; + } catch (err) { + logger.error('AntiNSFW checkMessage error:', err); + return false; + } + }, + + _checkText(content, config) { + if (!content) return null; + const lower = content.toLowerCase(); + + for (const domain of BLOCKED_DOMAINS) { + if (lower.includes(domain)) { + return { type: 'domain', reason: `Link to adult site blocked (${domain})` }; + } + } + + const allWords = [...BASE_NSFW_WORDS, ...(config.customWords || [])]; + for (const word of allWords) { + const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + if (new RegExp(`\\b${escaped}\\b`, 'i').test(lower)) { + return { type: 'keyword', reason: `NSFW keyword detected` }; + } + } + + return null; + }, + + async _scanImage(url, threshold) { + const apiUser = process.env.SIGHTENGINE_API_USER; + const apiSecret = process.env.SIGHTENGINE_API_SECRET; + if (!apiUser || !apiSecret) return { flagged: false, score: 0 }; + + try { + const params = new URLSearchParams({ + url, + models: 'nudity-2.0', + api_user: apiUser, + api_secret: apiSecret, + }); + const res = await fetch(`https://api.sightengine.com/1.0/check.json?${params}`, { + signal: AbortSignal.timeout(8000), + }); + if (!res.ok) return { flagged: false, score: 0 }; + const data = await res.json(); + if (data.status !== 'success') return { flagged: false, score: 0 }; + + const n = data.nudity ?? {}; + const score = Math.max( + n.sexual_activity ?? 0, + n.sexual_display ?? 0, + n.erotica ?? 0, + ); + return { flagged: score >= threshold, score }; + } catch (err) { + logger.error('Sightengine scan error:', err); + return { flagged: false, score: 0 }; + } + }, + + async _handleViolation(client, message, config, violation) { + await message.delete().catch(() => {}); + + const member = message.member + ?? await message.guild.members.fetch(message.author.id).catch(() => null); + + if (config.action !== 'delete' && member) { + try { + switch (config.action) { + case 'warn': + await member.send( + `You were warned in **${message.guild.name}** for sending NSFW content.` + ).catch(() => {}); + break; + case 'timeout': + await member.timeout( + config.timeoutDuration, + `Anti-NSFW: ${violation.reason}` + ); + break; + case 'kick': + await member.kick(`Anti-NSFW: ${violation.reason}`); + break; + case 'ban': + await message.guild.members.ban(message.author.id, { + reason: `Anti-NSFW: ${violation.reason}`, + }); + break; + } + } catch (err) { + logger.error('AntiNSFW action error:', err); + } + } + + if (config.logChannelId) { + const logChannel = message.guild.channels.cache.get(config.logChannelId); + if (logChannel?.isTextBased()) { + const embed = createEmbed({ + title: 'NSFW Violation Detected', + description: [ + `**User:** ${message.author} (${message.author.id})`, + `**Channel:** <#${message.channel.id}>`, + `**Detection type:** ${violation.type}`, + `**Reason:** ${violation.reason}`, + `**Action taken:** ${config.action}`, + ].join('\n'), + color: 'red', + }); + logChannel.send({ embeds: [embed] }).catch(() => {}); + } + } + + logger.info( + `AntiNSFW: removed message from ${message.author.id} in ${message.guild.id} ` + + `(${violation.type}) — action: ${config.action}` + ); + }, + + getBlockedDomains() { + return [...BLOCKED_DOMAINS]; + }, + + getBaseWords() { + return [...BASE_NSFW_WORDS]; + }, +}; diff --git a/src/services/auditLogService.js b/src/services/auditLogService.js new file mode 100644 index 0000000000..aed7f7e22f --- /dev/null +++ b/src/services/auditLogService.js @@ -0,0 +1,22 @@ +import { PermissionFlagsBits } from 'discord.js'; +import { logger } from '../utils/logger.js'; + +const recentRequests = new Map(); +export async function findAuditEntry(guild, action, targetId, { maxAgeMs = 15_000, delayMs = 0 } = {}) { + try { + if (!guild?.members?.me?.permissions?.has(PermissionFlagsBits.ViewAuditLog)) return null; + if (delayMs) await new Promise(resolve => setTimeout(resolve, delayMs)); + const key = `${guild.id}:${action}:${targetId}`; + const cached = recentRequests.get(key); + if (cached && Date.now() - cached.at < 2_000) return cached.entry; + const logs = await guild.fetchAuditLogs({ type: action, limit: 6 }); + const now = Date.now(); + const entry = logs.entries.find(item => item.target?.id === targetId && now - item.createdTimestamp <= maxAgeMs) || null; + recentRequests.set(key, { at: now, entry }); + return entry; + } catch (error) { + logger.error('Audit log lookup failed', { error, guildId: guild?.id, action, targetId }); + return null; + } +} +export const auditActor = entry => entry?.executor ? `${entry.executor} (\`${entry.executor.id}\`)` : 'לא ידוע'; diff --git a/src/services/autoresponderService.js b/src/services/autoresponderService.js new file mode 100644 index 0000000000..15530672c3 --- /dev/null +++ b/src/services/autoresponderService.js @@ -0,0 +1,79 @@ +import { logger } from '../utils/logger.js'; +import { randomUUID } from 'crypto'; + +const MAX_TRIGGERS = 50; + +function getKey(guildId) { + return `guild:${guildId}:autoresponder`; +} + +export const AutoresponderService = { + async list(client, guildId) { + return client.db.get(getKey(guildId), []); + }, + + async add(client, guildId, { trigger, response, matchType, createdBy }) { + const triggers = await client.db.get(getKey(guildId), []); + + if (triggers.length >= MAX_TRIGGERS) { + return { success: false, error: `Maximum of ${MAX_TRIGGERS} autoresponders reached.` }; + } + + if (triggers.find(t => t.trigger === trigger)) { + return { success: false, error: `A trigger for \`${trigger}\` already exists. Remove it first.` }; + } + + const id = randomUUID().slice(0, 8); + triggers.push({ id, trigger, response, matchType, createdBy, createdAt: Date.now() }); + await client.db.set(getKey(guildId), triggers); + return { success: true, id }; + }, + + async remove(client, guildId, trigger) { + const triggers = await client.db.get(getKey(guildId), []); + const idx = triggers.findIndex(t => t.trigger === trigger || t.id === trigger); + if (idx === -1) return { success: false }; + triggers.splice(idx, 1); + await client.db.set(getKey(guildId), triggers); + return { success: true }; + }, + + async clear(client, guildId) { + await client.db.set(getKey(guildId), []); + }, + + async check(client, message) { + if (!message.guild) return; + try { + const triggers = await client.db.get(getKey(message.guild.id), []); + if (!triggers.length) return; + + const content = message.content.toLowerCase(); + + for (const t of triggers) { + let matched = false; + switch (t.matchType) { + case 'exact': + matched = content === t.trigger; + break; + case 'startswith': + matched = content.startsWith(t.trigger); + break; + case 'contains': + default: + matched = content.includes(t.trigger); + } + + if (matched) { + const response = t.response.replace(/\{user\}/gi, `<@${message.author.id}>`); + await message.channel.send(response).catch(err => + logger.error('Autoresponder send error:', err) + ); + return; + } + } + } catch (err) { + logger.error('Autoresponder check error:', err); + } + }, +}; diff --git a/src/services/botUpdateService.js b/src/services/botUpdateService.js new file mode 100644 index 0000000000..b943943f2e --- /dev/null +++ b/src/services/botUpdateService.js @@ -0,0 +1,70 @@ +import { ChannelType, EmbedBuilder, PermissionFlagsBits } from 'discord.js'; +import { DEFAULT_UPDATE_CHANNEL_ID, DEFAULT_UPDATE_CONTENT } from '../config/botUpdates.js'; +import { logger } from '../utils/logger.js'; + +const key = guildId => `guild:${guildId}:bot_updates`; +export const defaultUpdateSettings = () => ({ + guildId: null, channelId: DEFAULT_UPDATE_CHANNEL_ID, roleId: null, + currentVersion: DEFAULT_UPDATE_CONTENT.version, lastAnnouncedVersion: null, + automaticEnabled: true, lastMessageId: null, lastAnnouncementAt: null, + lastAnnouncementAuthor: null, content: { ...DEFAULT_UPDATE_CONTENT } +}); + +export async function getUpdateSettings(client, guildId) { + const saved = await client.db.get(key(guildId), {}); + return { ...defaultUpdateSettings(), ...(saved || {}), guildId, + content: { ...DEFAULT_UPDATE_CONTENT, ...(saved?.content || {}) } }; +} +export async function saveUpdateSettings(client, guildId, patch) { + const current = await getUpdateSettings(client, guildId); + const next = { ...current, ...patch, guildId, + content: patch.content ? { ...current.content, ...patch.content } : current.content }; + await client.db.set(key(guildId), next); + return next; +} +const bullets = values => (values?.length ? values.map(v => `• ${v}`).join('\n') : 'אין פריטים בעדכון זה.'); +export function parseLines(value) { return value ? value.split(/\r?\n/).map(v => v.trim().replace(/^[•*-]\s*/, '')).filter(Boolean).slice(0, 20) : []; } +export function buildUpdateEmbed(client, content, { repeated = false } = {}) { + const embed = new EmbedBuilder().setColor(0x5865F2) + .setTitle(`${repeated ? '🔁 פרסום חוזר • ' : ''}${content.title || DEFAULT_UPDATE_CONTENT.title}`) + .setDescription('גרסה חדשה של הבוט זמינה עכשיו!') + .addFields( + { name: '✨ מה חדש', value: bullets(content.newFeatures) }, + { name: '🛠️ תיקונים', value: bullets(content.fixes) }, + { name: '🚀 שיפורים', value: bullets(content.improvements) }) + .setFooter({ text: `EditIL Assistant • גרסה ${content.version}` }).setTimestamp(); + const avatar = client.user?.displayAvatarURL?.(); if (avatar) embed.setThumbnail(avatar); + if (content.imageUrl) embed.setImage(content.imageUrl); + if (content.changelogUrl) embed.setURL(content.changelogUrl); + return embed; +} +export async function resolveUpdateChannel(guild, settings) { + const channel = guild.channels.cache.get(settings.channelId) || await guild.channels.fetch(settings.channelId).catch(() => null); + if (!channel || channel.guildId !== guild.id || ![ChannelType.GuildText, ChannelType.GuildAnnouncement].includes(channel.type)) throw new Error('ערוץ עדכוני הבוט לא נמצא.'); + const permissions = channel.permissionsFor(guild.members.me); + if (!permissions?.has([PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.EmbedLinks])) throw new Error('לבוט אין הרשאה לשלוח הודעות בערוץ עדכוני הבוט.'); + return channel; +} +export async function postUpdate(client, guild, content, { authorId = 'automatic', pingRole = true, repeated = false } = {}) { + const settings = await getUpdateSettings(client, guild.id); const channel = await resolveUpdateChannel(guild, settings); + const role = pingRole && settings.roleId ? guild.roles.cache.get(settings.roleId) : null; + const message = await channel.send({ content: role ? `<@&${role.id}>` : undefined, + embeds: [buildUpdateEmbed(client, content, { repeated })], allowedMentions: { parse: [], roles: role ? [role.id] : [] } }); + await saveUpdateSettings(client, guild.id, { currentVersion: content.version, lastAnnouncedVersion: content.version, + lastMessageId: message.id, lastAnnouncementAt: new Date().toISOString(), lastAnnouncementAuthor: authorId, content }); + logger.info('Bot update posted', { guildId: guild.id, channelId: channel.id, messageId: message.id, version: content.version, authorId, repeated }); + return message; +} +export async function runStartupUpdateCheck(client) { + if (client.botUpdateStartupChecked) return; client.botUpdateStartupChecked = true; + // Never announce automatically when writes would only reach MemoryStorage. + // Otherwise every process restart forgets the announced version and reposts it. + if (typeof client.db?.isAvailable === 'function' && !client.db.isAvailable()) { + logger.warn('Skipping automatic bot updates because persistent database storage is unavailable'); + return; + } + for (const guild of client.guilds.cache.values()) { + try { const s = await getUpdateSettings(client, guild.id); if (s.automaticEnabled && s.currentVersion !== s.lastAnnouncedVersion) await postUpdate(client, guild, { ...s.content, version: s.currentVersion }); } + catch (error) { logger.error('Failed update announcement', { guildId: guild.id, error: error.stack || error.message }); } + } +} diff --git a/src/services/communityPollService.js b/src/services/communityPollService.js new file mode 100644 index 0000000000..3b872d6ecf --- /dev/null +++ b/src/services/communityPollService.js @@ -0,0 +1,41 @@ +import { createEmbed } from '../utils/embeds.js'; +import { logger } from '../utils/logger.js'; +import { logEvent, EVENT_TYPES } from './loggingService.js'; + +const timers = new Map(); +const pollKey = (guildId, id) => `community:${guildId}:poll:${id}`; + +export async function closePoll(client, guildId, id) { + const key = pollKey(guildId, id); + const poll = await client.db.get(key); + if (!poll || poll.status !== 'open') return false; + poll.status = 'closed'; poll.closedAt = Date.now(); + await client.db.set(key, poll); + const guild = client.guilds.cache.get(guildId); + const channel = guild?.channels.cache.get(poll.channelId) || await guild?.channels.fetch(poll.channelId).catch(() => null); + const message = channel?.isTextBased() ? await channel.messages.fetch(poll.messageId).catch(() => null) : null; + if (poll.nativePoll && message?.poll && !message.poll.resultsFinalized) await message.poll.end().catch(() => null); + const totals = poll.nativePoll && message?.poll + ? [...message.poll.answers.values()].map(answer => answer.voteCount || 0) + : poll.options.map((_, index) => Object.values(poll.votes || {}).filter(value => (Array.isArray(value) ? value : [value]).includes(index)).length); + if (message) await message.edit({ embeds: [createEmbed({ title: `📊 תוצאות סופיות — ${poll.question}`, description: poll.options.map((option, index) => `**${index + 1}. ${option}** — ${totals[index]} קולות`).join('\n'), color: 'success', footer: { text: `סקר #${id} הסתיים` } })], components: [] }); + await logEvent({ client, guildId, eventType: EVENT_TYPES.SETTINGS_CHANGE, data: { title: `סקר #${id} נסגר`, description: `התקבלו ${totals.reduce((sum, value) => sum + value, 0)} בחירות.` } }); + timers.delete(key); return true; +} + +export function schedulePollClosure(client, guildId, id, closesAt) { + const key = pollKey(guildId, id); + if (timers.has(key)) clearTimeout(timers.get(key)); + const delay = Math.max(0, Number(closesAt) - Date.now()); + timers.set(key, setTimeout(() => closePoll(client, guildId, id).catch(error => logger.error('Failed to close community poll', { guildId, id, error })), Math.min(delay, 2_147_000_000))); +} + +export async function resumeCommunityPolls(client) { + const keys = await client.db.list('community:'); + for (const key of keys.filter(value => /^community:[^:]+:poll:[^:]+$/.test(value))) { + const poll = await client.db.get(key); + if (!poll || poll.status !== 'open') continue; + const [, guildId, , id] = key.split(':'); + schedulePollClosure(client, guildId, id, poll.closesAt); + } +} diff --git a/src/services/economy.js b/src/services/economy.js deleted file mode 100644 index da439a355b..0000000000 --- a/src/services/economy.js +++ /dev/null @@ -1,306 +0,0 @@ -import { BotConfig } from "../config/bot.js"; -import { getEconomyKey } from '../utils/database.js'; -import { getFromDb, setInDb } from '../utils/database.js'; -import { logger } from '../utils/logger.js'; -import { normalizeEconomyData } from '../utils/schemas.js'; - -const BASE_BANK_CAPACITY = BotConfig.economy.baseBankCapacity; -const BANK_CAPACITY_PER_LEVEL = BotConfig.economy.bankCapacityPerLevel || 5000; - -const DEFAULT_ECONOMY_DATA = { - wallet: 0, - bank: 0, - bankLevel: 0, - dailyStreak: 0, - lastDaily: 0, - lastWork: 0, - lastCrime: 0, - lastRob: 0, - inventory: {}, - cooldowns: {} -}; - - - - - - -export function getMaxBankCapacity(userData) { - if (!userData) return BASE_BANK_CAPACITY; - - const bankLevel = userData.bankLevel || 0; - return BASE_BANK_CAPACITY + (bankLevel * BANK_CAPACITY_PER_LEVEL); -} - - - - - - - - -export async function getEconomyData(client, guildId, userId) { - try { - const key = getEconomyKey(guildId, userId); - - const data = await getFromDb(key, DEFAULT_ECONOMY_DATA); - - return normalizeEconomyData(data, DEFAULT_ECONOMY_DATA); - } catch (error) { - logger.error(`Error getting economy data for user ${userId} in guild ${guildId}:`, error); - - if (error.message && (error.message.includes('ECONNREFUSED') || error.message.includes('connection'))) { - if (client._economyFallback) { - const key = getEconomyKey(guildId, userId); - const cached = client._economyFallback.get(key); - return normalizeEconomyData(cached, DEFAULT_ECONOMY_DATA); - } - } - - return normalizeEconomyData({}, DEFAULT_ECONOMY_DATA); - } -} - - - - - - - - - -export async function setEconomyData(client, guildId, userId, newData) { - let mergedData = null; - try { - const key = getEconomyKey(guildId, userId); - - const existingData = await getEconomyData(client, guildId, userId); - mergedData = normalizeEconomyData( - { ...existingData, ...newData }, - DEFAULT_ECONOMY_DATA - ); - - await setInDb(key, mergedData); - return true; - } catch (error) { - logger.error('Error saving economy data:', error); - - if (error.message && (error.message.includes('ECONNREFUSED') || error.message.includes('connection'))) { - logger.warn(`PostgreSQL unavailable, using memory fallback for guild ${guildId}`); - - if (!client._economyFallback) { - client._economyFallback = new Map(); - } - client._economyFallback.set(key, mergedData); - return true; - } - - return false; - } -} - - - - - - - - - - -export async function addMoney(client, guildId, userId, amount, type = 'wallet') { - try { - if (amount <= 0) { - return { success: false, error: 'Amount must be positive' }; - } - - const userData = await getEconomyData(client, guildId, userId); - - if (type === 'bank') { - const maxBank = getMaxBankCapacity(userData); - if ((userData.bank || 0) + amount > maxBank) { - return { - success: false, - error: 'Bank capacity exceeded', - current: userData.bank || 0, - max: maxBank - }; - } - userData.bank = (userData.bank || 0) + amount; - } else { - userData.wallet = (userData.wallet || 0) + amount; - } - - await setEconomyData(client, guildId, userId, userData); - - return { - success: true, - newBalance: type === 'bank' ? userData.bank : userData.wallet, - ...(type === 'bank' ? { maxBank: getMaxBankCapacity(userData) } : {}) - }; - } catch (error) { - logger.error(`Error adding money to ${type} for user ${userId} in guild ${guildId}:`, error); - return { success: false, error: 'An error occurred while processing your request' }; - } -} - - - - - - - - - - -export async function removeMoney(client, guildId, userId, amount, type = 'wallet') { - try { - if (amount <= 0) { - return { success: false, error: 'Amount must be positive' }; - } - - const userData = await getEconomyData(client, guildId, userId); - - if (type === 'bank') { - if ((userData.bank || 0) < amount) { - return { - success: false, - error: 'Insufficient funds in bank', - current: userData.bank || 0, - required: amount - }; - } - userData.bank = (userData.bank || 0) - amount; - } else { - if ((userData.wallet || 0) < amount) { - return { - success: false, - error: 'Insufficient funds in wallet', - current: userData.wallet || 0, - required: amount - }; - } - userData.wallet = (userData.wallet || 0) - amount; - } - - await setEconomyData(client, guildId, userId, userData); - - return { - success: true, - newBalance: type === 'bank' ? userData.bank : userData.wallet - }; - } catch (error) { - logger.error(`Error removing money from ${type} for user ${userId} in guild ${guildId}:`, error); - return { success: false, error: 'An error occurred while processing your request' }; - } -} - - - - - - - - - - -export async function transferMoney(client, guildId, userId, amount, direction) { - try { - if (amount <= 0) { - return { success: false, error: 'Amount must be positive' }; - } - - const userData = await getEconomyData(client, guildId, userId); - - if (direction === 'deposit') { - if (userData.wallet < amount) { - return { - success: false, - error: 'Insufficient funds in wallet', - current: userData.wallet, - required: amount - }; - } - - const maxBank = getMaxBankCapacity(userData); - if (userData.bank + amount > maxBank) { - return { - success: false, - error: 'Bank capacity exceeded', - current: userData.bank, - max: maxBank, - required: amount - }; - } - - userData.wallet -= amount; - userData.bank += amount; - userData.lastDeposit = Date.now(); - - } else if (direction === 'withdraw') { - if (userData.bank < amount) { - return { - success: false, - error: 'Insufficient funds in bank', - current: userData.bank, - required: amount - }; - } - - userData.bank -= amount; - userData.wallet += amount; - userData.lastWithdraw = Date.now(); - - } else { - return { success: false, error: 'Invalid transfer direction' }; - } - - await setEconomyData(client, guildId, userId, userData); - - return { - success: true, - wallet: userData.wallet, - bank: userData.bank, - ...(direction === 'deposit' ? { maxBank: getMaxBankCapacity(userData) } : {}) - }; - - } catch (error) { - logger.error(`Error transferring money (${direction}) for user ${userId} in guild ${guildId}:`, error); - return { success: false, error: 'An error occurred while processing your request' }; - } -} - - - - - - - - - - -export async function checkCooldown(client, guildId, userId, action, cooldownTime) { - try { - const userData = await getEconomyData(client, guildId, userId); - const now = Date.now(); - const lastAction = userData.lastAction || {}; - const lastTime = lastAction[action] || 0; - const timeLeft = (lastTime + cooldownTime) - now; - - if (timeLeft > 0) { - return { onCooldown: true, timeLeft }; - } - - lastAction[action] = now; - userData.lastAction = lastAction; - await setEconomyData(client, guildId, userId, userData); - - return { onCooldown: false }; - - } catch (error) { - logger.error(`Error checking cooldown for ${action} for user ${userId} in guild ${guildId}:`, error); - return { onCooldown: true, timeLeft: cooldownTime }; - } -} - - diff --git a/src/services/economyService.js b/src/services/economyService.js deleted file mode 100644 index aaffcb241b..0000000000 --- a/src/services/economyService.js +++ /dev/null @@ -1,558 +0,0 @@ - - - - - - - - - - - - - - - - - - - -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'; - -class EconomyService { - - - static DAILY_COOLDOWN = 24 * 60 * 60 * 1000; - static WORK_COOLDOWN = 30 * 60 * 1000; - static GAMBLE_COOLDOWN = 5 * 60 * 1000; - static CRIME_COOLDOWN = 60 * 60 * 1000; - static ROB_COOLDOWN = 4 * 60 * 60 * 1000; - static MINE_COOLDOWN = 60 * 60 * 1000; - static FISH_COOLDOWN = 45 * 60 * 1000; - static BEG_COOLDOWN = 30 * 60 * 1000; - - static DAILY_AMOUNT = 1000; - static MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER; - - static assertSafeBalance(value, context = {}) { - if (!Number.isSafeInteger(value) || value < 0 || value > this.MAX_SAFE_INTEGER) { - throw createError( - "Invalid balance state", - ErrorTypes.VALIDATION, - "Operation would create an invalid account balance.", - { value, ...context } - ); - } - } - - - - - - - - - static async claimDaily(client, guildId, userId) { - logger.debug(`[ECONOMY_SERVICE] claimDaily requested`, { userId, guildId }); - - const userData = await getEconomyData(client, guildId, userId); - if (!userData) { - logger.error(`[ECONOMY_SERVICE] Failed to load economy data for daily`); - throw createError( - "Failed to load economy data", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - const now = Date.now(); - const lastDaily = userData.lastDaily || 0; - const remaining = lastDaily + this.DAILY_COOLDOWN - now; - - if (remaining > 0) { - logger.warn(`[ECONOMY_SERVICE] Daily cooldown active`, { - userId, - timeRemaining: remaining - }); - throw createError( - "Daily cooldown active", - ErrorTypes.RATE_LIMIT, - `You need to wait before claiming daily again. Try again in **${this.formatDuration(remaining)}**.`, - { remaining, cooldownType: 'daily' } - ); - } - - const earned = this.DAILY_AMOUNT; - const nextWallet = (userData.wallet || 0) + earned; - this.assertSafeBalance(nextWallet, { operation: 'claimDaily', userId, guildId }); - userData.wallet = nextWallet; - userData.lastDaily = now; - - try { - await setEconomyData(client, guildId, userId, userData); - - logger.info(`[ECONOMY_TRANSACTION] Daily claimed`, { - userId, - guildId, - amount: earned, - newWallet: userData.wallet, - timestamp: new Date().toISOString(), - source: 'claim_daily' - }); - - return { - earned, - newWallet: userData.wallet, - nextClaimTime: new Date(now + this.DAILY_COOLDOWN) - }; - } catch (error) { - logger.error(`[ECONOMY_SERVICE] Failed to save daily claim`, error, { - userId, - guildId, - amount: earned - }); - throw createError( - "Failed to save daily claim", - ErrorTypes.DATABASE, - "Failed to process your daily. Please try again.", - { userId, guildId } - ); - } - } - - - - - - - - - - - static async transferMoney(client, guildId, senderId, receiverId, amount) { - logger.debug(`[ECONOMY_SERVICE] transferMoney requested`, { - senderId, - receiverId, - amount, - guildId - }); - - - if (amount <= 0) { - throw createError( - "Invalid transfer amount", - ErrorTypes.VALIDATION, - "Amount must be greater than zero.", - { amount, senderId } - ); - } - - if (senderId === receiverId) { - throw createError( - "Cannot pay self", - ErrorTypes.VALIDATION, - "You cannot pay yourself.", - { senderId, receiverId } - ); - } - - this.validateAmount(amount, { operation: 'transfer', senderId, receiverId }); - - - const [senderData, receiverData] = await Promise.all([ - getEconomyData(client, guildId, senderId), - getEconomyData(client, guildId, receiverId) - ]); - - if (!senderData || !receiverData) { - logger.error(`[ECONOMY_SERVICE] Failed to load economy data for transfer`, { - senderLoaded: !!senderData, - receiverLoaded: !!receiverData - }); - throw createError( - "Failed to load economy data", - ErrorTypes.DATABASE, - "Failed to load economy data. Please try again later.", - { senderId, receiverId, guildId } - ); - } - - - if (senderData.wallet < amount) { - logger.warn(`[ECONOMY_SERVICE] Insufficient funds for transfer`, { - senderId, - required: amount, - available: senderData.wallet - }); - throw createError( - "Insufficient funds", - ErrorTypes.VALIDATION, - `You only have **$${senderData.wallet.toLocaleString()}** in cash.`, - { required: amount, available: senderData.wallet, senderId } - ); - } - - - const walletBefore = senderData.wallet; - const senderNext = (senderData.wallet || 0) - amount; - const receiverNext = (receiverData.wallet || 0) + amount; - - this.assertSafeBalance(senderNext, { operation: 'transfer.sender', senderId, amount }); - this.assertSafeBalance(receiverNext, { operation: 'transfer.receiver', receiverId, amount }); - - senderData.wallet = senderNext; - receiverData.wallet = receiverNext; - - try { - // Step 1: Deduct from sender - await setEconomyData(client, guildId, senderId, senderData); - - try { - // Step 2: Add to receiver - await setEconomyData(client, guildId, receiverId, receiverData); - } catch (receiverError) { - // ROLLBACK: Try to restore sender's money if receiver update fails - logger.error(`[ECONOMY_CRITICAL] Failed to credit receiver ${receiverId}. Attempting rollback for sender ${senderId}...`, receiverError); - - senderData.wallet = walletBefore; - try { - await setEconomyData(client, guildId, senderId, senderData); - logger.info(`[ECONOMY_ROLLBACK] Successfully rolled back sender ${senderId} after receiver credit failure.`); - } catch (rollbackError) { - logger.error(`[ECONOMY_FATAL] ROLLBACK FAILED for sender ${senderId}! Data is now inconsistent.`, rollbackError); - // At this point, manual intervention is needed. - } - - throw receiverError; - } - - logger.info(`[ECONOMY_TRANSACTION] Money transferred`, { - type: 'transfer', - senderId, - receiverId, - guildId, - amount, - senderNewBalance: senderData.wallet, - receiverNewBalance: receiverData.wallet, - timestamp: new Date().toISOString() - }); - - return { - success: true, - senderNewBalance: senderData.wallet, - receiverNewBalance: receiverData.wallet - }; - } catch (error) { - logger.error(`[ECONOMY_SERVICE] Transfer execution failed, DATA MAY BE INCONSISTENT`, error, { - senderId, - receiverId, - amount, - guildId, - senderBefore: walletBefore, - senderAfter: senderData.wallet, - receiverAfter: receiverData.wallet - }); - throw createError( - "Failed to save transfer", - ErrorTypes.DATABASE, - "Failed to process transfer. Please try again.", - { senderId, receiverId, amount } - ); - } - } - - - - - - - - - - - static async addMoney(client, guildId, userId, amount, source = 'unknown') { - if (amount <= 0) { - throw createError( - "Invalid amount", - ErrorTypes.VALIDATION, - "Amount must be positive", - { amount, userId, source } - ); - } - - this.validateAmount(amount, { operation: 'addMoney', userId, source }); - - const userData = await getEconomyData(client, guildId, userId); - const balanceBefore = userData.wallet || 0; - const nextWallet = balanceBefore + amount; - this.assertSafeBalance(nextWallet, { operation: 'addMoney', userId, source, amount }); - userData.wallet = nextWallet; - - await setEconomyData(client, guildId, userId, userData); - - logger.info(`[ECONOMY_TRANSACTION] Money added`, { - userId, - guildId, - amount, - source, - balanceBefore, - balanceAfter: userData.wallet, - delta: amount, - timestamp: new Date().toISOString() - }); - - return userData; - } - - - - - - - - - - - static async removeMoney(client, guildId, userId, amount, reason = 'unknown') { - if (amount <= 0) { - throw createError( - "Invalid amount", - ErrorTypes.VALIDATION, - "Amount must be positive", - { amount, userId, reason } - ); - } - - this.validateAmount(amount, { operation: 'removeMoney', userId, reason }); - - const userData = await getEconomyData(client, guildId, userId); - const balanceBefore = userData.wallet || 0; - - if (balanceBefore < amount) { - throw createError( - "Insufficient funds", - ErrorTypes.VALIDATION, - `You only have **$${balanceBefore.toLocaleString()}**.`, - { required: amount, available: balanceBefore, reason } - ); - } - - userData.wallet = balanceBefore - amount; - - await setEconomyData(client, guildId, userId, userData); - - logger.info(`[ECONOMY_TRANSACTION] Money removed`, { - userId, - guildId, - amount, - reason, - balanceBefore, - balanceAfter: userData.wallet, - delta: -amount, - timestamp: new Date().toISOString() - }); - - return userData; - } - - - - - - - - - - static async depositToBank(client, guildId, userId, amount) { - this.validateAmount(amount, { operation: 'deposit', userId }); - - const userData = await getEconomyData(client, guildId, userId); - const maxBank = getMaxBankCapacity(userData); - - if (userData.wallet < amount) { - throw createError( - "Insufficient cash", - ErrorTypes.VALIDATION, - `You only have **$${userData.wallet.toLocaleString()}** in cash.`, - { required: amount, available: userData.wallet } - ); - } - - const currentBank = userData.bank || 0; - if (currentBank + amount > maxBank) { - throw createError( - "Bank capacity exceeded", - ErrorTypes.VALIDATION, - `Your bank can only hold **$${maxBank.toLocaleString()}**. You would exceed capacity by **$${(currentBank + amount - maxBank).toLocaleString()}**.`, - { capacity: maxBank, current: currentBank, requested: amount } - ); - } - - const nextWallet = userData.wallet - amount; - const nextBank = (userData.bank || 0) + amount; - - this.assertSafeBalance(nextWallet, { operation: 'deposit.wallet', userId, amount }); - this.assertSafeBalance(nextBank, { operation: 'deposit.bank', userId, amount }); - - userData.wallet = nextWallet; - userData.bank = nextBank; - - await setEconomyData(client, guildId, userId, userData); - - logger.info(`[ECONOMY_TRANSACTION] Money deposited to bank`, { - userId, - guildId, - amount, - walletAfter: userData.wallet, - bankAfter: userData.bank, - timestamp: new Date().toISOString() - }); - - return userData; - } - - - - - - - - - - static async withdrawFromBank(client, guildId, userId, amount) { - this.validateAmount(amount, { operation: 'withdraw', userId }); - - const userData = await getEconomyData(client, guildId, userId); - const bank = userData.bank || 0; - - if (bank < amount) { - throw createError( - "Insufficient bank balance", - ErrorTypes.VALIDATION, - `You only have **$${bank.toLocaleString()}** in your bank.`, - { required: amount, available: bank } - ); - } - - const nextWallet = (userData.wallet || 0) + amount; - const nextBank = bank - amount; - - this.assertSafeBalance(nextWallet, { operation: 'withdraw.wallet', userId, amount }); - this.assertSafeBalance(nextBank, { operation: 'withdraw.bank', userId, amount }); - - userData.wallet = nextWallet; - userData.bank = nextBank; - - await setEconomyData(client, guildId, userId, userData); - - logger.info(`[ECONOMY_TRANSACTION] Money withdrawn from bank`, { - userId, - guildId, - amount, - walletAfter: userData.wallet, - bankAfter: userData.bank, - timestamp: new Date().toISOString() - }); - - return userData; - } - - - - - - - - - static checkCooldown(userData, action, cooldownMs) { - const lastActionField = `last${action.charAt(0).toUpperCase() + action.slice(1)}`; - const lastTime = userData[lastActionField] || 0; - const now = Date.now(); - const remaining = Math.max(0, lastTime + cooldownMs - now); - - return { - isOnCooldown: remaining > 0, - remaining, - formatted: this.formatDuration(remaining), - nextAvailable: new Date(lastTime + cooldownMs) - }; - } - - - - - - - static validateAmount(amount, context = {}) { - if (!Number.isInteger(amount)) { - throw createError( - "Invalid amount - not an integer", - ErrorTypes.VALIDATION, - "Amount must be a whole number", - context - ); - } - - if (amount <= 0) { - throw createError( - "Invalid amount - not positive", - ErrorTypes.VALIDATION, - "Amount must be positive", - context - ); - } - - if (amount > this.MAX_SAFE_INTEGER) { - logger.error(`[ECONOMY] Amount exceeds MAX_SAFE_INTEGER`, { amount, context }); - throw createError( - "Amount too large", - ErrorTypes.VALIDATION, - "The amount is too large to process", - context - ); - } - } - - - - - - - static formatDuration(ms) { - const totalSeconds = Math.floor(ms / 1000); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - - if (hours > 0) { - return `${hours}h ${minutes}m ${seconds}s`; - } - if (minutes > 0) { - return `${minutes}m ${seconds}s`; - } - return `${seconds}s`; - } - - - - - - - static formatCooldownDisplay(ms) { - const duration = this.formatDuration(ms); - return `**${duration}**`; - } -} - -wrapServiceClassMethods(EconomyService, (methodName) => ({ - service: 'EconomyService', - operation: methodName, - message: `Economy service operation failed: ${methodName}`, - userMessage: 'An economy operation failed. Please try again in a moment.' -})); - -export default EconomyService; diff --git a/src/services/gorillaService.js b/src/services/gorillaService.js new file mode 100644 index 0000000000..9badd609fc --- /dev/null +++ b/src/services/gorillaService.js @@ -0,0 +1,166 @@ +import { EmbedBuilder } from 'discord.js'; +import { logger } from '../utils/logger.js'; + +const GT_STATUS_URL = 'https://status.gorilla.sc/api/v2/status.json'; +const STEAM_URL = 'https://api.steampowered.com/ISteamUserStats/GetNumberOfCurrentPlayers/v1/?appid=1533390'; +const STEAM_NEWS = 'https://api.steampowered.com/ISteamNews/GetNewsForApp/v2/?appid=1533390&count=5&maxlength=600&format=json'; + +const STATUS_CONFIG = { + none: { color: 0x57F287, emoji: '🟢', label: 'All Systems Operational' }, + minor: { color: 0xFEE75C, emoji: '🟡', label: 'Minor Service Disruption' }, + major: { color: 0xE67E22, emoji: '🟠', label: 'Partial System Outage' }, + critical: { color: 0xED4245, emoji: '🔴', label: 'Major System Outage' }, +}; + +function cleanContent(text) { + return text + .replace(/\[([^\]]+)\]/g, '') // strip BBcode tags like [h1], [b], [url=...] + .replace(/\s{3,}/g, '\n\n') + .replace(/\n{3,}/g, '\n\n') + .trim() + .slice(0, 500); +} + +export async function fetchGorillaStatus() { + const [statusRes, steamRes, newsRes] = await Promise.all([ + fetch(GT_STATUS_URL).then(r => r.json()).catch(() => null), + fetch(STEAM_URL).then(r => r.json()).catch(() => null), + fetch(`${STEAM_NEWS}&count=1`).then(r => r.json()).catch(() => null), + ]); + + const latestItem = newsRes?.appnews?.newsitems?.[0] ?? null; + + return { + indicator: statusRes?.status?.indicator ?? 'none', + description: statusRes?.status?.description ?? 'Unknown', + steamCount: steamRes?.response?.player_count ?? null, + latestPatch: latestItem ? { title: latestItem.title, url: latestItem.url, date: new Date(latestItem.date * 1000) } : null, + }; +} + +export async function fetchPatchNotes(count = 1) { + const data = await fetch(`${STEAM_NEWS}&count=${count}`) + .then(r => r.json()) + .catch(() => null); + + const items = data?.appnews?.newsitems ?? []; + return items.map(item => ({ + gid: item.gid, + title: item.title, + url: item.url, + content: cleanContent(item.contents), + date: new Date(item.date * 1000), + author: item.author, + })); +} + +export function buildPatchEmbed(note) { + return new EmbedBuilder() + .setColor(0x9B59B6) + .setAuthor({ name: 'Gorilla Tag Update', iconURL: 'https://cdn.cloudflare.steamstatic.com/steam/apps/1533390/header.jpg' }) + .setTitle(note.title) + .setURL(note.url) + .setDescription(note.content + (note.content.length >= 500 ? `\n\n[Read more](${note.url})` : '')) + .setFooter({ text: `Posted by ${note.author} • Steam` }) + .setTimestamp(note.date); +} + +export function buildStatusEmbed({ indicator, description, steamCount, latestPatch }) { + const cfg = STATUS_CONFIG[indicator] ?? STATUS_CONFIG.none; + + const embed = new EmbedBuilder() + .setColor(cfg.color) + .setTitle('🦍 Gorilla Tag — Server Status') + .addFields( + { name: 'Status', value: `${cfg.emoji} **${description}**`, inline: false }, + { + name: '🎮 Steam Players Online', + value: steamCount != null ? `**${steamCount.toLocaleString()}**` : 'Unavailable', + inline: true, + }, + ) + .setFooter({ text: 'Updates every 5 minutes • status.gorilla.sc' }) + .setTimestamp(); + + if (latestPatch) { + embed.addFields({ + name: '📋 Latest Update', + value: `[${latestPatch.title}](${latestPatch.url})\n<t:${Math.floor(latestPatch.date.getTime() / 1000)}:R>`, + inline: false, + }); + } + + return embed; +} + +export async function checkAndUpdateGorillaStatus(client) { + try { + const guilds = client.guilds.cache; + if (!guilds.size) return; + + const status = await fetchGorillaStatus(); + const embed = buildStatusEmbed(status); + + for (const [guildId] of guilds) { + const data = await client.db.get(`gorilla:${guildId}`); + if (!data?.channelId) continue; + + try { + const channel = await client.channels.fetch(data.channelId).catch(() => null); + if (!channel) continue; + + if (data.messageId) { + const msg = await channel.messages.fetch(data.messageId).catch(() => null); + if (msg) { + await msg.edit({ embeds: [embed] }); + + if (data.lastIndicator && data.lastIndicator !== status.indicator && status.indicator !== 'none') { + const cfg = STATUS_CONFIG[status.indicator]; + await channel.send({ content: `${cfg.emoji} **Gorilla Tag status changed:** ${status.description}` }); + } + + await client.db.set(`gorilla:${guildId}`, { ...data, lastIndicator: status.indicator }); + continue; + } + } + + const msg = await channel.send({ embeds: [embed] }); + await client.db.set(`gorilla:${guildId}`, { ...data, messageId: msg.id, lastIndicator: status.indicator }); + } catch (err) { + logger.warn(`Gorilla status update failed for guild ${guildId}: ${err.message}`); + } + } + } catch (err) { + logger.error('checkAndUpdateGorillaStatus error:', err); + } +} + +export async function checkAndPostPatchNotes(client) { + try { + const notes = await fetchPatchNotes(1); + if (!notes.length) return; + const latest = notes[0]; + + for (const [guildId] of client.guilds.cache) { + const data = await client.db.get(`gorilla:${guildId}`); + if (!data?.channelId) continue; + if (data.lastPatchGid === latest.gid) continue; + + try { + const channel = await client.channels.fetch(data.channelId).catch(() => null); + if (!channel) continue; + + await channel.send({ + content: '📢 **New Gorilla Tag Update!**', + embeds: [buildPatchEmbed(latest)], + }); + + await client.db.set(`gorilla:${guildId}`, { ...data, lastPatchGid: latest.gid }); + } catch (err) { + logger.warn(`Gorilla patch note post failed for guild ${guildId}: ${err.message}`); + } + } + } catch (err) { + logger.error('checkAndPostPatchNotes error:', err); + } +} diff --git a/src/services/leveling.js b/src/services/leveling.js index a79e1654ce..df4f143ba5 100644 --- a/src/services/leveling.js +++ b/src/services/leveling.js @@ -8,11 +8,11 @@ import { logger } from '../utils/logger.js'; import { getGuildConfig, setGuildConfig } from '../services/guildConfig.js'; import { TitanBotError, ErrorTypes } from '../utils/errorHandler.js'; import { addXp } from './xpSystem.js'; +import { MAX_LEVEL } from '../utils/levelLimits.js'; const BASE_XP = 100; const XP_MULTIPLIER = 1.5; -const MAX_LEVEL = 1000; const MIN_LEVEL = 0; @@ -554,4 +554,3 @@ export async function deleteUserLevelData(client, guildId, userId) { } - diff --git a/src/services/loggingService.js b/src/services/loggingService.js index 5244b7c7c1..46476bb145 100644 --- a/src/services/loggingService.js +++ b/src/services/loggingService.js @@ -1,5 +1,5 @@ -import { EmbedBuilder, ChannelType } from 'discord.js'; -import { getGuildConfig } from './guildConfig.js'; +import { EmbedBuilder, ChannelType, PermissionFlagsBits } from 'discord.js'; +import { getConfig as getCommunityConfig, updateConfig as updateCommunityConfig } from '../modules/community/store.js'; import { logger } from '../utils/logger.js'; @@ -14,6 +14,9 @@ const EVENT_TYPES = { MODERATION_MUTE: 'moderation.mute', MODERATION_WARN: 'moderation.warn', MODERATION_PURGE: 'moderation.purge', + MODERATION_UNBAN: 'moderation.unban', + MODERATION_LOCK: 'moderation.lock', + MODERATION_UNLOCK: 'moderation.unlock', TICKET_CREATE: 'ticket.create', @@ -31,6 +34,7 @@ const EVENT_TYPES = { MESSAGE_DELETE: 'message.delete', MESSAGE_EDIT: 'message.edit', MESSAGE_BULK_DELETE: 'message.bulkdelete', + MESSAGE_CREATE: 'message.create', ROLE_CREATE: 'role.create', @@ -41,6 +45,18 @@ const EVENT_TYPES = { MEMBER_JOIN: 'member.join', MEMBER_LEAVE: 'member.leave', MEMBER_NAME_CHANGE: 'member.namechange', + MEMBER_UPDATE: 'member.update', + CHANNEL_CHANGE: 'channel.change', + VOICE_CHANGE: 'voice.change', + INVITE_CHANGE: 'invite.change', + EMOJI_STICKER_CHANGE: 'emoji_sticker.change', + SERVER_UPDATE: 'server.update', + VERIFICATION: 'verification.complete', + SUGGESTION: 'suggestion.create', + REPORT: 'report.create', + CONTEST_ACTION: 'contest.action', + SETTINGS_CHANGE: 'settings.change', + COMMAND_ERROR: 'command.error', REACTION_ROLE_ADD: 'reactionrole.add', @@ -152,30 +168,31 @@ export async function logEvent({ if (!guild) { logger.warn(`logEvent: Guild not found: ${guildId}`); - return; + return { ok: false, reason: 'guild_not_found' }; } - const config = await getGuildConfig(client, guildId); + const config = await getCommunityConfig(client, guildId); const ignoredUsers = config.logIgnore?.users || []; const ignoredChannels = config.logIgnore?.channels || []; if (data?.userId && ignoredUsers.includes(data.userId)) { - return; + return { ok: false, reason: 'ignored' }; } if (data?.channelId && ignoredChannels.includes(data.channelId)) { - return; + return { ok: false, reason: 'ignored' }; } if (!isLoggingEnabled(config, eventType)) { - return; + return { ok: false, reason: 'disabled' }; } const logChannelId = getLogChannelForEvent(config, eventType); if (!logChannelId) { - return; + logger.warn(`Log channel is not configured for guild ${guildId}. Configure it with /settings channel.`); + return { ok: false, reason: 'not_configured' }; } const channel = guild.channels.cache.get(logChannelId) || @@ -183,13 +200,18 @@ export async function logEvent({ if (!channel || channel.type !== ChannelType.GuildText) { logger.warn(`logEvent: Invalid log channel ${logChannelId} for guild ${guildId}`); - return; + return { ok: false, reason: 'channel_not_found' }; } const permissions = channel.permissionsFor(guild.members.me); - if (!permissions || !permissions.has(['SendMessages', 'EmbedLinks'])) { + const requiredPermissions = [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.EmbedLinks, + ]; + if (!permissions || !permissions.has(requiredPermissions)) { logger.warn(`logEvent: Missing permissions in channel ${logChannelId}`); - return; + return { ok: false, reason: 'missing_permissions' }; } const embed = createLogEmbed(guild, eventType, data); @@ -201,9 +223,11 @@ export async function logEvent({ await channel.send(messageOptions); logger.info(`Event logged: ${eventType} in guild ${guildId}`); + return { ok: true }; } catch (error) { - logger.error(`Error in logEvent:`, error); + logger.error('Error in logEvent', { error, guildId, eventType }); + return { ok: false, reason: 'send_failed' }; } } @@ -218,7 +242,9 @@ function isLoggingEnabled(config, eventType) { return false; } - if (!config.logging || !config.logging.enabled) { + // Legacy guilds may only have logChannelId. Treat logging as enabled unless + // it was explicitly disabled, so existing configuration keeps working. + if (config.logging?.enabled === false) { return false; } @@ -285,22 +311,39 @@ function createLogEmbed(guild, eventType, data) { }); - const title = data.title || `${icon} ${formatEventType(eventType)}`; + const title = String(data?.title || `${icon} ${formatEventType(eventType)}`).slice(0, 256); embed.setTitle(title); if (data.description) { - embed.setDescription(data.description); + embed.setDescription(String(data.description).slice(0, 4096)); } if (data.fields && Array.isArray(data.fields)) { - embed.addFields(data.fields); + embed.addFields(data.fields.slice(0, 25).map(field => ({ + name: String(field.name || 'פרט').slice(0, 256), + value: String(field.value ?? '—').slice(0, 1024), + inline: Boolean(field.inline), + }))); } return embed; } +// Stable API for commands and services. Logging failures are intentionally +// returned to the caller instead of being thrown into the original action. +export async function sendLog(guild, logType, title, description, fields = [], options = {}) { + if (!guild?.client || !guild.id) return { ok: false, reason: 'guild_not_found' }; + return logEvent({ + client: guild.client, + guildId: guild.id, + eventType: logType, + data: { title, description, fields, ...options.data }, + attachments: options.attachments || [], + }); +} + @@ -324,7 +367,7 @@ function formatEventType(eventType) { export async function getLoggingStatus(client, guildId) { - const config = await getGuildConfig(client, guildId); + const config = await getCommunityConfig(client, guildId); const logging = config.logging || {}; return { @@ -345,8 +388,7 @@ export async function getLoggingStatus(client, guildId) { export async function toggleEventLogging(client, guildId, eventTypes, enabled) { try { - const { updateGuildConfig } = await import('./guildConfig.js'); - const config = await getGuildConfig(client, guildId); + const config = await getCommunityConfig(client, guildId); const logging = config.logging || { enabled: false, enabledEvents: {} }; const types = Array.isArray(eventTypes) ? eventTypes : [eventTypes]; @@ -366,7 +408,7 @@ export async function toggleEventLogging(client, guildId, eventTypes, enabled) { } }); - await updateGuildConfig(client, guildId, { logging }); + await updateCommunityConfig(client, guildId, { logging }); return true; } catch (error) { logger.error('Error toggling event logging:', error); @@ -383,14 +425,13 @@ export async function toggleEventLogging(client, guildId, eventTypes, enabled) { export async function setLoggingChannel(client, guildId, channelId) { try { - const { updateGuildConfig } = await import('./guildConfig.js'); - const config = await getGuildConfig(client, guildId); + const config = await getCommunityConfig(client, guildId); const logging = config.logging || { enabled: false, enabledEvents: {} }; logging.channelId = channelId; logging.enabled = true; - await updateGuildConfig(client, guildId, { logging }); + await updateCommunityConfig(client, guildId, { logging }); return true; } catch (error) { logger.error('Error setting logging channel:', error); @@ -407,13 +448,12 @@ export async function setLoggingChannel(client, guildId, channelId) { export async function setLoggingEnabled(client, guildId, enabled) { try { - const { updateGuildConfig } = await import('./guildConfig.js'); - const config = await getGuildConfig(client, guildId); + const config = await getCommunityConfig(client, guildId); const logging = config.logging || { enabledEvents: {} }; logging.enabled = enabled; - await updateGuildConfig(client, guildId, { logging }); + await updateCommunityConfig(client, guildId, { logging }); return true; } catch (error) { logger.error('Error setting logging enabled:', error); diff --git a/src/services/musicService.js b/src/services/musicService.js new file mode 100644 index 0000000000..bbbd0a78ae --- /dev/null +++ b/src/services/musicService.js @@ -0,0 +1,192 @@ +import { + joinVoiceChannel, + createAudioPlayer, + createAudioResource, + AudioPlayerStatus, + VoiceConnectionStatus, + entersState, +} from '@discordjs/voice'; +import playdl from 'play-dl'; +import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; +import { logger } from '../utils/logger.js'; + +const queues = new Map(); + +function buildControls(paused = false) { + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('music_pause') + .setEmoji(paused ? '▶️' : '⏸') + .setLabel(paused ? 'Resume' : 'Pause') + .setStyle(paused ? ButtonStyle.Success : ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('music_skip') + .setEmoji('⏭') + .setLabel('Skip') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId('music_stop') + .setEmoji('⏹') + .setLabel('Stop') + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId('music_queue') + .setEmoji('📋') + .setLabel('Queue') + .setStyle(ButtonStyle.Secondary), + ); +} + +function getThumb(url) { + const m = url.match(/(?:v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/); + return m ? `https://img.youtube.com/vi/${m[1]}/mqdefault.jpg` : null; +} + +class GuildQueue { + constructor({ voiceChannel, textChannel, connection, guildId }) { + this.guildId = guildId; + this.voiceChannel = voiceChannel; + this.textChannel = textChannel; + this.connection = connection; + this.player = createAudioPlayer(); + this.songs = []; + this.current = null; + this.nowPlayingMsg = null; + this.paused = false; + + connection.subscribe(this.player); + + this.player.on(AudioPlayerStatus.Idle, () => this._advance()); + this.player.on('error', err => { + logger.error(`Music player error in guild ${this.guildId}: ${err.message}`); + this._advance(); + }); + } + + async _advance() { + if (this.nowPlayingMsg) { + this.nowPlayingMsg.edit({ components: [] }).catch(() => {}); + this.nowPlayingMsg = null; + } + + if (!this.songs.length) { + this.current = null; + return; + } + + this.current = this.songs.shift(); + + try { + const stream = await playdl.stream(this.current.url, { quality: 2 }); + const resource = createAudioResource(stream.stream, { inputType: stream.type }); + this.player.play(resource); + + const embed = new EmbedBuilder() + .setColor(0xFF0000) + .setAuthor({ name: '🎵 Now Playing' }) + .setTitle(this.current.title) + .setURL(this.current.url) + .addFields( + { name: '⏱ Duration', value: this.current.duration, inline: true }, + { name: '📺 Channel', value: this.current.channel, inline: true }, + { name: '👤 Requested by', value: this.current.requestedBy, inline: true }, + ) + .setTimestamp(); + + const thumb = getThumb(this.current.url); + if (thumb) embed.setThumbnail(thumb); + + this.nowPlayingMsg = await this.textChannel.send({ + embeds: [embed], + components: [buildControls(false)], + }).catch(() => null); + } catch (err) { + logger.error(`Failed to stream in guild ${this.guildId}: ${err.message}`); + this._advance(); + } + } +} + +export function getQueue(guildId) { + return queues.get(guildId); +} + +export async function joinAndQueue(song, voiceChannel, textChannel) { + const guildId = voiceChannel.guild.id; + let queue = queues.get(guildId); + + if (!queue) { + const connection = joinVoiceChannel({ + channelId: voiceChannel.id, + guildId, + adapterCreator: voiceChannel.guild.voiceAdapterCreator, + selfDeaf: true, + }); + + try { + await entersState(connection, VoiceConnectionStatus.Ready, 30_000); + } catch { + connection.destroy(); + throw new Error('Could not connect to the voice channel.'); + } + + queue = new GuildQueue({ voiceChannel, textChannel, connection, guildId }); + queues.set(guildId, queue); + + connection.on(VoiceConnectionStatus.Disconnected, async () => { + try { + await Promise.race([ + entersState(connection, VoiceConnectionStatus.Signalling, 5_000), + entersState(connection, VoiceConnectionStatus.Connecting, 5_000), + ]); + } catch { + connection.destroy(); + queues.delete(guildId); + } + }); + } + + const wasIdle = queue.player.state.status === AudioPlayerStatus.Idle && !queue.current; + queue.songs.push(song); + + if (wasIdle) { + await queue._advance(); + return { queue, queued: false }; + } + + return { queue, queued: true }; +} + +export function skipSong(guildId) { + const q = queues.get(guildId); + if (!q) return false; + q.player.stop(); + return true; +} + +export function stopMusic(guildId) { + const q = queues.get(guildId); + if (!q) return false; + q.songs = []; + q.current = null; + if (q.nowPlayingMsg) q.nowPlayingMsg.edit({ components: [] }).catch(() => {}); + q.player.stop(); + q.connection.destroy(); + queues.delete(guildId); + return true; +} + +export function togglePause(guildId) { + const q = queues.get(guildId); + if (!q || !q.current) return null; + if (q.paused) { + q.player.unpause(); + q.paused = false; + } else { + q.player.pause(); + q.paused = true; + } + return q.paused; +} + +export { buildControls }; diff --git a/src/services/ownerInboxService.js b/src/services/ownerInboxService.js new file mode 100644 index 0000000000..79e1a50558 --- /dev/null +++ b/src/services/ownerInboxService.js @@ -0,0 +1,130 @@ +import { EmbedBuilder } from 'discord.js'; +import { logger } from '../utils/logger.js'; + +export const OWNER_INBOX_GUILD_ID = '1526671786387705907'; +export const OWNER_INBOX_USER_ID = '1127099544560205914'; + +const caseKey = caseId => `owner_inbox:case:${caseId}`; +const sequenceKey = kind => `owner_inbox:sequence:${kind}`; +const prefix = kind => kind === 'suggest' ? 'SUG' : 'REP'; +const line = value => value?.trim() || 'לא צוין'; + +async function nextSequence(client, kind) { + if (client.db?.isAvailable?.() && client.db.db?.pool) { + const { pgConfig } = await import('../config/postgres.js'); + const result = await client.db.db.pool.query( + `INSERT INTO ${pgConfig.tables.temp_data} (key, value, expires_at) + VALUES ($1, '1'::jsonb, NULL) + ON CONFLICT (key) DO UPDATE + SET value = to_jsonb(COALESCE((${pgConfig.tables.temp_data}.value #>> '{}')::bigint, 0) + 1), expires_at = NULL + RETURNING value`, [sequenceKey(kind)] + ); + return Number(result.rows[0].value); + } + return client.db.increment(sequenceKey(kind)); +} + +export function isOwnerInboxSubmission(guildId, kind) { + return guildId === OWNER_INBOX_GUILD_ID && (kind === 'suggest' || kind === 'report'); +} + +export async function createOwnerInboxCase(client, interaction, kind, data) { + const sequence = await nextSequence(client, kind); + const caseId = `${prefix(kind)}-${String(sequence).padStart(6, '0')}`; + const createdAt = new Date().toISOString(); + const record = { + caseId, kind, guildId: interaction.guildId, channelId: interaction.channelId, + authorId: interaction.user.id, data, createdAt, deliveryStatus: 'pending', replies: [], + context: { username: interaction.user.username, displayName: interaction.member?.displayName || interaction.user.globalName || interaction.user.username, + guildName: interaction.guild.name, channelName: interaction.channel?.name || 'לא ידוע' } + }; + await client.db.set(caseKey(caseId), record); + logger.info(kind === 'suggest' ? 'Suggestion received' : 'Report received', { caseId, guildId: interaction.guildId, authorId: interaction.user.id }); + return record; +} + +export function buildOwnerInboxEmbed(record) { + const { kind, data, caseId, createdAt } = record; + const sender = `• Username: ${record.context.username}\n• Display Name: ${record.context.displayName}\n• Mention: <@${record.authorId}>\n• User ID: \`${record.authorId}\``; + const fields = [{ name: kind === 'suggest' ? '👤 שולח' : '👤 המדווח', value: sender }]; + if (kind === 'suggest') fields.push( + { name: '📝 כותרת', value: line(data.title) }, + { name: '📄 פירוט', value: line(data.description) } + ); + else fields.push( + { name: '👤 המשתמש שדווח', value: line(data.reported_user) }, + { name: '📂 סוג הדיווח', value: line(data.type) }, + { name: '📄 תיאור', value: line(data.description) }, + { name: '🔗 קישור', value: line(data.evidence) } + ); + fields.push( + { name: '🏠 שרת', value: `${record.context.guildName}\n\`${record.guildId}\``, inline: true }, + { name: '📍 ערוץ', value: `${record.context.channelName}\n\`${record.channelId}\``, inline: true }, + { name: '🕒 זמן', value: `<t:${Math.floor(new Date(createdAt).getTime() / 1000)}:F>` }, + { name: '🆔 Case ID', value: `\`${caseId}\`` } + ); + const urls = Object.values(data).flatMap(value => typeof value === 'string' ? value.match(/https?:\/\/\S+/gi) || [] : []); + if (urls.length) fields.push({ name: '🔗 קישורים שצורפו', value: urls.slice(0, 5).join('\n') }); + return new EmbedBuilder().setColor(kind === 'report' ? 0xED4245 : 0x5865F2) + .setTitle(kind === 'suggest' ? '💡 הצעה חדשה' : '🚨 דיווח חדש').addFields(fields).setTimestamp(new Date(createdAt)); +} + +export async function deliverOwnerInboxCase(client, record) { + try { + const owner = await client.users.fetch(OWNER_INBOX_USER_ID); + const message = await owner.send({ embeds: [buildOwnerInboxEmbed(record)], allowedMentions: { parse: [] } }); + const delivered = { ...record, deliveryStatus: 'delivered', ownerMessageId: message.id, deliveredAt: new Date().toISOString() }; + await client.db.set(caseKey(record.caseId), delivered); + logger.info('DM delivered', { caseId: record.caseId, ownerMessageId: message.id }); + return true; + } catch (error) { + await client.db.set(caseKey(record.caseId), { ...record, deliveryStatus: 'pending', deliveryError: error.code || error.message }); + logger.error('DM failed', { caseId: record.caseId, error: error.stack || error.message }); + return false; + } +} + +export async function retryPendingOwnerInboxCases(client) { + if (typeof client.db?.isAvailable === 'function' && !client.db.isAvailable()) return; + const keys = await client.db.list('owner_inbox:case:'); + for (const key of keys) { + const record = await client.db.get(key); + if (record?.deliveryStatus !== 'pending') continue; + await deliverOwnerInboxCase(client, record); + } +} + +export async function handleOwnerInboxReply(message) { + if (message.author.id !== OWNER_INBOX_USER_ID || message.guild || !message.content.startsWith('/reply ')) return false; + const match = /^\/reply\s+((?:SUG|REP)-\d{6})\s+([\s\S]+)$/i.exec(message.content.trim()); + if (!match) { + await message.reply('שימוש נכון: `/reply CASE-ID תוכן התגובה`'); + return true; + } + const caseId = match[1].toUpperCase(), replyText = match[2].trim(); + const result = await replyToOwnerInboxCase(message.client, message.author.id, caseId, replyText); + await message.reply(result.message); + return true; +} + +export async function replyToOwnerInboxCase(client, ownerId, caseId, replyText) { + if (ownerId !== OWNER_INBOX_USER_ID) return { ok: false, code: 'FORBIDDEN', message: 'אין לך הרשאה להשתמש בפקודה זו.' }; + caseId = String(caseId || '').trim().toUpperCase(); + replyText = String(replyText || '').trim(); + if (!/^(?:SUG|REP)-\d{6}$/.test(caseId) || !replyText) return { ok: false, code: 'INVALID', message: 'מזהה המקרה או תוכן התגובה אינם תקינים.' }; + const record = await client.db.get(caseKey(caseId)); + if (!record) return { ok: false, code: 'NOT_FOUND', message: 'מזהה המקרה לא נמצא.' }; + try { + const user = await client.users.fetch(record.authorId); + await user.send({ embeds: [new EmbedBuilder().setColor(0x5865F2).setTitle('📩 תגובה מצוות EditIL') + .addFields({ name: '🆔 Case ID', value: `\`${caseId}\`` }, { name: '💬 תגובת הצוות', value: replyText }, + { name: '🕒 זמן', value: `<t:${Math.floor(Date.now() / 1000)}:F>` }).setTimestamp()], allowedMentions: { parse: [] } }); + record.replies = [...(record.replies || []), { authorId: ownerId, content: replyText, createdAt: new Date().toISOString() }]; + await client.db.set(caseKey(caseId), record); + logger.info('Owner replied', { caseId, recipientId: record.authorId }); + return { ok: true, code: 'SENT', message: `התגובה למקרה \`${caseId}\` נשלחה בהצלחה.` }; + } catch (error) { + logger.error('Owner reply failed', { caseId, error: error.stack || error.message }); + return { ok: false, code: 'DM_FAILED', message: 'שליחת התגובה נכשלה. המקרה נשמר במסד הנתונים.' }; + } +} diff --git a/src/services/roleSystemService.js b/src/services/roleSystemService.js new file mode 100644 index 0000000000..92dcbd5b8b --- /dev/null +++ b/src/services/roleSystemService.js @@ -0,0 +1,70 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, PermissionFlagsBits, StringSelectMenuBuilder } from 'discord.js'; +import { createEmbed } from '../utils/embeds.js'; +import { getConfig } from '../modules/community/store.js'; +import { logger } from '../utils/logger.js'; +import { logEvent, EVENT_TYPES } from './loggingService.js'; + +export const PANEL_CATEGORIES = Object.freeze({ software: 'תוכנות עריכה', editing: 'סוגי עריכה', notifications: 'התראות', languages: 'שפות' }); +export const panelKey = (guildId, id) => `community:${guildId}:rolepanel:${id}`; + +export async function validateRoleAction(guild, actor, role, { selfAssignable = false, allowAdministrator = false } = {}) { + const fail = async message => { + if (!selfAssignable && guild.client) await logEvent({ client: guild.client, guildId: guild.id, eventType: EVENT_TYPES.ROLE_UPDATE, data: { title: 'פעולת תפקיד נכשלה', description: message, fields: [{ name: 'מבצע', value: actor ? `<@${actor.id}>` : 'לא ידוע' }, { name: 'תפקיד', value: role ? `${role} (\`${role.id}\`)` : 'לא קיים' }] } }); + return message; + }; + if (!guild.members.me.permissions.has(PermissionFlagsBits.ManageRoles)) return fail('לבוט חסרה הרשאת ניהול תפקידים.'); + if (!role || role.id === guild.id) return fail('לא ניתן לנהל את התפקיד @everyone.'); + if (role.managed || role.tags?.botId || role.tags?.integrationId || role.tags?.premiumSubscriberRole) return fail('לא ניתן לנהל תפקיד שמנוהל על ידי Discord או אינטגרציה.'); + if (role.position >= guild.members.me.roles.highest.position) return fail('תפקיד הבוט נמוך מדי בהיררכיית התפקידים.'); + if (!selfAssignable && actor?.id !== guild.ownerId && role.position >= actor.roles.highest.position) return fail('לא ניתן לנהל תפקיד שממוקם מעל התפקיד שלך.'); + if (role.permissions.has(PermissionFlagsBits.Administrator) && !(allowAdministrator && actor?.id === guild.ownerId)) return fail('לא ניתן לנהל תפקיד בעל הרשאת Administrator.'); + if (selfAssignable) { + const config = await getConfig(guild.client, guild.id); + const protectedIds = new Set(Object.values(config.staffRoles || {}).filter(Boolean)); + if (protectedIds.has(role.id) || role.permissions.has([PermissionFlagsBits.ManageGuild, PermissionFlagsBits.ManageRoles, PermissionFlagsBits.BanMembers, PermissionFlagsBits.KickMembers, PermissionFlagsBits.ModerateMembers])) return 'לא ניתן להוסיף את התפקיד הזה באמצעות בחירה עצמית.'; + } + return null; +} + +export function panelPayload(panel, guild) { + const roles = panel.roleIds.map(id => guild.roles.cache.get(id)).filter(Boolean); + const embed = createEmbed({ title: panel.title, description: panel.description || 'בחרו את התפקידים המתאימים לכם.', fields: [{ name: 'קטגוריה', value: PANEL_CATEGORIES[panel.category] || panel.category, inline: true }, { name: 'בחירה מרבית', value: String(panel.maxSelections), inline: true }], color: 'primary', footer: { text: `פאנל תפקידים #${panel.id}` } }); + if (panel.selectionType === 'buttons') { + const buttons = roles.map(role => new ButtonBuilder().setCustomId(`role_panel_button:${panel.id}:${role.id}`).setLabel(role.name.slice(0, 80)).setStyle(ButtonStyle.Secondary)); + return { embeds: [embed], components: [new ActionRowBuilder().addComponents(buttons.slice(0, 5)), ...(buttons.length > 5 ? [new ActionRowBuilder().addComponents(buttons.slice(5))] : [])] }; + } + const menu = new StringSelectMenuBuilder().setCustomId(`role_panel_select:${panel.id}`).setPlaceholder('בחרו תפקידים להוספה או להסרה').setMinValues(1).setMaxValues(Math.min(panel.maxSelections, roles.length)).addOptions(roles.map(role => ({ label: role.name.slice(0, 100), value: role.id }))); + return { embeds: [embed], components: [new ActionRowBuilder().addComponents(menu)] }; +} + +export async function getPanel(client, guildId, reference) { + const id = String(reference || '').match(/(?:channels\/\d+\/\d+\/)?(\d+)$/)?.[1] || String(reference || ''); + let panel = await client.db.get(panelKey(guildId, id)); + if (panel) return panel; + for (const key of await client.db.list(`community:${guildId}:rolepanel:`)) { + const candidate = await client.db.get(key); + if (candidate?.messageId === id) return candidate; + } + return null; +} + +export const roleTemplates = Object.freeze({ + no_permissions: [], member: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory, PermissionFlagsBits.UseApplicationCommands], + helper: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory, PermissionFlagsBits.ManageMessages], + moderator: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ManageMessages, PermissionFlagsBits.ModerateMembers], + supplier: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.AttachFiles, PermissionFlagsBits.EmbedLinks, PermissionFlagsBits.ReadMessageHistory, PermissionFlagsBits.CreatePublicThreads, PermissionFlagsBits.UseExternalEmojis], + bot_developer: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.EmbedLinks, PermissionFlagsBits.AttachFiles, PermissionFlagsBits.ReadMessageHistory, PermissionFlagsBits.UseApplicationCommands, PermissionFlagsBits.ViewAuditLog], + notification: [] +}); + +export async function validateSavedRolePanels(client) { + const keys = await client.db.list('community:'); + for (const key of keys.filter(value => /^community:[^:]+:rolepanel:[^:]+$/.test(value))) { + const panel = await client.db.get(key); const [, guildId] = key.split(':'); + const guild = client.guilds.cache.get(guildId); const channel = guild?.channels.cache.get(panel?.channelId); + const message = channel?.isTextBased() ? await channel.messages.fetch(panel.messageId).catch(() => null) : null; + if (!message) logger.warn('Saved role panel message is missing', { guildId, panelId: panel?.id, channelId: panel?.channelId }); + const invalidRoleIds = (panel?.roleIds || []).filter(id => !guild?.roles.cache.has(id)); + if (invalidRoleIds.length) logger.warn('Saved role panel contains missing roles', { guildId, panelId: panel.id, invalidRoleIds }); + } +} diff --git a/src/services/scamDetectionService.js b/src/services/scamDetectionService.js new file mode 100644 index 0000000000..f94c61644e --- /dev/null +++ b/src/services/scamDetectionService.js @@ -0,0 +1,170 @@ +import { createHash } from 'crypto'; +import axios from 'axios'; + +// ── Text-based detection ───────────────────────────────────────────────────── + +const SCAM_DOMAINS = [ + 'cobratate', 'tatespeech', 'hustlersuniversity', 'cobracasino', + 'cryptofire', 'bit.ly', 'tinyurl', 'shorturl', 'cutt.ly', + 'stake.com', 'rollbit', 'duelbits', 'bc.game', 'roobet', + 'wolf.bet', 'gamdom', 'csgoroll', 'csgoempire', 'skinclub', + 'csgopolygon', 'clash.gg', 'thunderpick', +]; + +const KEYWORD_GROUPS = [ + ['promo code', 'promocode', 'promo-code'], + ['claim your reward', 'claim reward', 'claim your bonus'], + ['withdrawal success', 'withdrawal was successful', 'withdrew'], + ['free money', 'free crypto', 'free usdt', 'free btc', 'free eth'], + ['giveaway', 'giving away', 'giving $', 'giving away $'], + ['casino', 'gambling', 'bet with', 'place your bet'], + ['register now', 'sign up now', 'join now and get'], + ['$2,500', '$2500', '$1,000', '$1000', '$500 bonus', '$100 free'], + ['andrew tate', 'cobratate', 'tate'], +]; + +const HIGH_CONFIDENCE = [ + 'cobratate.com', 'tatespeech.com', 'cryptofire.io', + 'enter the promo code', 'use code launch', 'use code: launch', + 'airdrop is live', 'claim your airdrop', + 'send 1 btc get 2 btc', 'send 1 eth get 2 eth', + 'elon musk giveaway', 'verify your wallet', + 'connect your wallet to claim', +]; + +function isTextScam(content, embeds = []) { + const allText = [ + content, + ...embeds.map(e => [e.title, e.description, e.url, ...(e.fields?.map(f => f.value) ?? [])].join(' ')), + ].join(' ').toLowerCase(); + + if (HIGH_CONFIDENCE.some(kw => allText.includes(kw))) return true; + if (SCAM_DOMAINS.some(d => allText.includes(d))) return true; + + let matched = 0; + for (const group of KEYWORD_GROUPS) { + if (group.some(kw => allText.includes(kw))) matched++; + } + return matched >= 3; +} + +// ── Image hash detection ───────────────────────────────────────────────────── + +// In-memory cache loaded from DB on first use +let _hashCache = null; + +async function loadHashes(db) { + if (_hashCache) return _hashCache; + const stored = await db.get('scam:image-hashes').catch(() => null); + _hashCache = new Set(stored ?? []); + return _hashCache; +} + +async function hashImageUrl(url) { + try { + const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 8000 }); + return createHash('md5').update(Buffer.from(res.data)).digest('hex'); + } catch { + return null; + } +} + +const IMAGE_TYPES = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp']); + +function imageAttachments(message) { + return [...message.attachments.values()].filter(a => { + const ext = a.name?.split('.').pop()?.toLowerCase(); + return IMAGE_TYPES.has(ext) || a.contentType?.startsWith('image/'); + }); +} + +// ── Public API ─────────────────────────────────────────────────────────────── + +export const ScamDetectionService = { + /** Returns true and removes the message if it matches scam text or a registered scam image. */ + async check(client, message) { + if (!message.deletable) return false; + + const content = message.content ?? ''; + const embeds = message.embeds ?? []; + const isGuild = !!message.guild; + + const textScam = isTextScam(content, embeds); + let imageScam = false; + + const images = imageAttachments(message); + if (images.length && client.db?.isAvailable?.()) { + const hashes = await loadHashes(client.db); + for (const img of images) { + const hash = await hashImageUrl(img.url); + if (hash && hashes.has(hash)) { imageScam = true; break; } + } + } + + if (!textScam && !imageScam) return false; + + try { + await message.delete(); + } catch { + // Can't delete (e.g. DM) — just warn + } + + try { + const warn = await message.channel.send({ + content: `🚨 <@${message.author.id}> A message was automatically removed for containing scam content.`, + }); + if (isGuild) setTimeout(() => warn.delete().catch(() => {}), 8000); + } catch {} + + return true; + }, + + /** Register all image attachments in a message as scam images. Returns count added. */ + async registerImages(client, message) { + if (!client.db?.isAvailable?.()) return 0; + + const images = imageAttachments(message); + if (!images.length) return 0; + + const hashes = await loadHashes(client.db); + let added = 0; + + for (const img of images) { + const hash = await hashImageUrl(img.url); + if (hash && !hashes.has(hash)) { + hashes.add(hash); + added++; + } + } + + if (added) await client.db.set('scam:image-hashes', [...hashes]); + return added; + }, + + /** Remove all image attachments in a message from the scam hash list. Returns count removed. */ + async unregisterImages(client, message) { + if (!client.db?.isAvailable?.()) return 0; + + const images = imageAttachments(message); + if (!images.length) return 0; + + const hashes = await loadHashes(client.db); + let removed = 0; + + for (const img of images) { + const hash = await hashImageUrl(img.url); + if (hash && hashes.has(hash)) { + hashes.delete(hash); + removed++; + } + } + + if (removed) await client.db.set('scam:image-hashes', [...hashes]); + return removed; + }, + + /** Wipe the in-memory cache (call after DB changes from another process). */ + clearCache() { + _hashCache = null; + }, +}; diff --git a/src/services/settingsOverview.js b/src/services/settingsOverview.js new file mode 100644 index 0000000000..22f9ffd633 --- /dev/null +++ b/src/services/settingsOverview.js @@ -0,0 +1,75 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; +import { createEmbed } from '../utils/embeds.js'; + +const state = enabled => enabled ? '✅ פעיל' : '❌ כבוי'; +const channel = id => id ? `<#${id}>` : 'לא הוגדר'; +const role = id => id ? `<@&${id}>` : 'לא הוגדר'; +const accessLevels = ['כולם', 'מאומת', 'עוזר', 'מודרטור', 'מנהל', 'בעלים']; + +export function createSettingsOverview(config) { + const modules = Object.entries(config.modules || {}).map(([name, enabled]) => `${enabled ? '✅' : '❌'} ${name}`).join(' • ') || 'אין מודולים'; + const permissions = Object.entries(config.commandPermissions || {}); + const settings = Object.entries(config.commandSettings || {}); + const commandLines = [ + ...permissions.slice(0, 12).map(([name, level]) => `/${name}: ${accessLevels[Number(level)] || `רמה ${level}`}`), + ...settings.slice(0, Math.max(0, 12 - permissions.length)).map(([name, item]) => `/${name}: ${item?.enabled === false ? 'כבוי' : 'פעיל'}`) + ]; + return createEmbed({ title: 'הגדרות השרת', description: 'סקירה מלאה של המערכות והפקודות שדורשות הגדרה.', color: 'primary' }).addFields( + { name: '👋 קבלת פנים', value: `${state(config.welcome.enabled)}\nערוץ: ${channel(config.welcome.channelId)}\nהודעה: ${config.welcome.message ? `\`${String(config.welcome.message).slice(0, 120)}\`` : 'לא הוגדרה'}` }, + { name: '🛡️ אימות', value: `${state(config.verification.enabled)}\nערוץ: ${channel(config.verification.channelId)}\nתפקיד: ${role(config.verification.roleId)}`, inline: true }, + { name: '🎫 פניות', value: `${state(config.tickets.enabled)}\nפאנל: ${channel(config.tickets.panelChannelId)}\nקטגוריה: ${channel(config.tickets.categoryId)}\nצוות: ${role(config.tickets.supportRoleId)}`, inline: true }, + { name: '📋 לוגים', value: `${state(config.logging.enabled)}\nערוץ: ${channel(config.logging.channelId)}\nסוגים כבויים: ${Object.values(config.logging.enabledEvents || {}).filter(v => v === false).length}`, inline: true }, + { name: '📈 רמות', value: `${state(config.leveling.enabled)}\nערוץ עלייה: ${channel(config.leveling.announceChannelId)}\nXP: ${config.leveling.xpMin}–${config.leveling.xpMax}\nהשהיה: ${Math.round(config.leveling.cooldownMs / 1000)} שניות`, inline: true }, + { name: '📣 ערוצי פקודות', value: `הצעות: ${channel(config.channels?.suggestions)}\nדיווחים: ${channel(config.channels?.reports)}\nמשוב: ${channel(config.channels?.feedback)}\nהכרזות: ${channel(config.channels?.announcements)}`, inline: true }, + { name: '👥 תפקידי גישה', value: `מאומת: ${role(config.staffRoles?.verified || config.verification?.roleId)}\nעוזר: ${role(config.staffRoles?.helper)}\nמודרטור: ${role(config.staffRoles?.moderator)}\nמנהל: ${role(config.staffRoles?.administrator)}\nצוות פניות: ${role(config.staffRoles?.ticketStaff || config.tickets?.supportRoleId)}\nבוסטר: ${role(config.staffRoles?.booster)}`, inline: true }, + { name: '🧩 מודולים', value: modules.slice(0, 1024) }, + { name: '🎭 פאנלים ותחרויות', value: `פאנלי תפקידים: **${config.roles?.panels?.length || 0}**\nתחרות פעילה: **${config.contests?.active ? config.contests.active.title : 'אין'}**\nהגשות: **${config.contests?.submissions?.length || 0}**`, inline: true }, + { name: '⚙️ הגדרות פקודות', value: commandLines.length ? commandLines.join('\n').slice(0, 1024) : 'אין דריסות; נעשה שימוש בברירות המחדל.', inline: true } + ); +} + +function systemFields(config) { + return [ + { name: '👋 קבלת פנים', value: `${state(config.welcome.enabled)}\nערוץ: ${channel(config.welcome.channelId)}\nהודעה: ${config.welcome.message ? `\`${String(config.welcome.message).slice(0, 180)}\`` : 'לא הוגדרה'}` }, + { name: '🛡️ אימות', value: `${state(config.verification.enabled)}\nערוץ: ${channel(config.verification.channelId)}\nתפקיד: ${role(config.verification.roleId)}`, inline: true }, + { name: '🎫 פניות', value: `${state(config.tickets.enabled)}\nפאנל: ${channel(config.tickets.panelChannelId)}\nקטגוריה: ${channel(config.tickets.categoryId)}\nצוות: ${role(config.tickets.supportRoleId)}`, inline: true }, + { name: '📈 רמות', value: `${state(config.leveling.enabled)}\nערוץ: ${channel(config.leveling.announceChannelId)}\nXP: ${config.leveling.xpMin}–${config.leveling.xpMax}\nהשהיה: ${Math.round(config.leveling.cooldownMs / 1000)} שניות`, inline: true }, + { name: '🎭 תפקידים ותחרויות', value: `פאנלי תפקידים: **${config.roles?.panels?.length || 0}**\nתחרות: **${config.contests?.active?.title || 'אין'}**\nהגשות: **${config.contests?.submissions?.length || 0}**`, inline: true } + ]; +} + +export function createSettingsPage(config, page = 'overview') { + if (page === 'overview') return createSettingsOverview(config).setFooter({ text: 'בחרו קטגוריה באמצעות הכפתורים למטה' }); + const embed = createEmbed({ title: '⚙️ מרכז הגדרות', description: 'ניהול וסקירת הגדרות השרת במקום אחד.', color: 'primary' }); + if (page === 'systems') embed.setTitle('🧩 מערכות').addFields(systemFields(config)); + if (page === 'access') embed.setTitle('📣 ערוצים ותפקידי גישה').addFields( + { name: 'ערוצי פקודות', value: `הצעות: ${channel(config.channels?.suggestions)}\nדיווחים: ${channel(config.channels?.reports)}\nמשוב: ${channel(config.channels?.feedback)}\nהכרזות: ${channel(config.channels?.announcements)}`, inline: true }, + { name: 'תפקידי גישה', value: `מאומת: ${role(config.staffRoles?.verified || config.verification?.roleId)}\nעוזר: ${role(config.staffRoles?.helper)}\nמודרטור: ${role(config.staffRoles?.moderator)}\nמנהל: ${role(config.staffRoles?.administrator)}\nצוות פניות: ${role(config.staffRoles?.ticketStaff || config.tickets?.supportRoleId)}\nבוסטר: ${role(config.staffRoles?.booster)}`, inline: true } + ); + if (page === 'commands') { + const permissions = Object.entries(config.commandPermissions || {}), settings = Object.entries(config.commandSettings || {}); + embed.setTitle('⌨️ פקודות ומודולים').addFields( + { name: 'מודולים', value: Object.entries(config.modules || {}).map(([name, enabled]) => `${enabled ? '✅' : '❌'} ${name}`).join('\n').slice(0, 1024) || 'אין' }, + { name: 'רמות גישה מותאמות', value: permissions.map(([name, level]) => `/${name}: **${accessLevels[Number(level)] || `רמה ${level}`}**`).join('\n').slice(0, 1024) || 'אין דריסות.' }, + { name: 'הפעלה לפי פקודה', value: settings.map(([name, item]) => `/${name}: ${item?.enabled === false ? '❌ כבוי' : '✅ פעיל'}`).join('\n').slice(0, 1024) || 'כל הפקודות משתמשות בברירת המחדל.' } + ); + } + if (page === 'logging') { + const disabled = Object.entries(config.logging.enabledEvents || {}).filter(([, enabled]) => enabled === false).map(([name]) => `❌ ${name}`); + embed.setTitle('📋 מערכת לוגים').addFields( + { name: 'מצב', value: state(config.logging.enabled), inline: true }, + { name: 'ערוץ', value: channel(config.logging.channelId), inline: true }, + { name: 'אירועים כבויים', value: disabled.join('\n').slice(0, 1024) || '✅ כל סוגי האירועים פעילים' }, + { name: 'ניהול', value: 'השתמשו ב-`/settings logging` כדי לבחור ערוץ, לבדוק אותו, ולהפעיל או לכבות סוגי אירועים.' } + ); + } + return embed.setFooter({ text: 'הנתונים מוצגים בזמן אמת • לחצו רענון לעדכון' }); +} + +export function createSettingsComponents(userId, page = 'overview') { + const button = (id, label, emoji) => new ButtonBuilder().setCustomId(`settings_page:${userId}:${id}`).setLabel(label).setEmoji(emoji).setStyle(id === page ? ButtonStyle.Primary : ButtonStyle.Secondary).setDisabled(id === page); + return [ + new ActionRowBuilder().addComponents(button('overview', 'סקירה', '🏠'), button('systems', 'מערכות', '🧩'), button('access', 'ערוצים ותפקידים', '👥')), + new ActionRowBuilder().addComponents(button('commands', 'פקודות', '⌨️'), button('logging', 'לוגים', '📋'), new ButtonBuilder().setCustomId(`settings_page:${userId}:refresh:${page}`).setLabel('רענון').setEmoji('🔄').setStyle(ButtonStyle.Success)) + ]; +} diff --git a/src/services/shopService.js b/src/services/shopService.js deleted file mode 100644 index 1fe2b934ce..0000000000 --- a/src/services/shopService.js +++ /dev/null @@ -1,210 +0,0 @@ -import { EmbedBuilder } from 'discord.js'; -import { shopConfig, shopItems, getItemById, validatePurchase, getCurrentPrice, getItemsInCategory } from '../config/shop/index.js'; -import { logger } from '../utils/logger.js'; -import { getEconomyData, setEconomyData } from '../utils/economy.js'; - - - - -class ShopService { - constructor() { - this.logger = logger.child({ module: 'ShopService' }); - } - - - - - - - - - - async purchaseItem(userId, itemId, quantity = 1, options = {}) { - try { - const { guildId, client } = options; - - if (!client) { - throw new Error('Client is required for shop operations'); - } - - const item = getItemById(itemId); - if (!item) { - return { success: false, message: 'Item not found in the shop.' }; - } - - const userData = await getEconomyData(client, guildId, userId); - - const totalCost = getCurrentPrice(itemId, { quantity, userData }); - - if (userData.wallet < totalCost) { - const currency = this.getCurrencyInfo(); - return { - success: false, - message: `You don't have enough ${currency.namePlural} to purchase this item.` - }; - } - - const validation = validatePurchase(itemId, userData); - if (!validation.valid) { - return { success: false, message: validation.reason }; - } - - - userData.wallet -= totalCost; - - - await this.addToUserInventory(userId, itemId, quantity, guildId, client, userData); - - - await setEconomyData(client, guildId, userId, userData); - - this.logger.info(`User ${userId} purchased ${quantity}x ${item.name} for ${totalCost} ${this.getCurrencyName()}`); - - return { - success: true, - message: `Successfully purchased ${quantity}x ${item.name} for ${totalCost} ${this.getCurrencyName()}`, - data: { - item, - quantity, - totalCost, - remainingBalance: userData.wallet - } - }; - } catch (error) { - this.logger.error(`Error purchasing item: ${error.message}`, { error, userId, itemId, quantity }); - return { - success: false, - message: 'An error occurred while processing your purchase. Please try again later.' - }; - } - } - - - - - - - - - async getUserInventory(userId, guildId, client) { - try { - const userData = await getEconomyData(client, guildId, userId); - return userData.inventory || {}; - } catch (error) { - this.logger.error(`Error getting user inventory: ${error.message}`, { error, userId, guildId }); - return {}; - } - } - - - - - - async addToUserInventory(userId, itemId, quantity = 1, guildId = null, client = null, userData = null) { - try { - - if (!userData) { - userData = await getEconomyData(client, guildId, userId); - } - - if (!userData.inventory) { - userData.inventory = {}; - } - - const item = getItemById(itemId); - - - if (item && item.type === 'upgrade') { - if (!userData.upgrades) { - userData.upgrades = {}; - } - userData.upgrades[itemId] = true; - } else { - - userData.inventory[itemId] = (userData.inventory[itemId] || 0) + quantity; - } - - this.logger.info(`Added ${quantity}x ${itemId} to user ${userId}'s inventory`); - } catch (error) { - this.logger.error(`Error adding item to inventory: ${error.message}`, { error, userId, itemId, quantity, guildId }); - throw error; - } - } - - - - - - getCurrencyName() { - return shopConfig.currencyName || 'coins'; - } - - - - - - - - - createShopEmbed(options = {}) { - const { category, page = 1 } = options; - - const embed = new EmbedBuilder() - .setTitle('🛒 TitanBot Shop') - .setColor('#5865F2') - .setDescription('Browse and purchase items from the shop. Use the buttons to navigate.') - .setFooter({ text: `Page ${page}` }); - - - return embed; - } - - - - - - getCategories() { - const categories = [ - { - id: 'all', - name: 'All Items', - emoji: '🛍️', - description: 'Browse all available items', - icon: '🛍️' - }, - ...shopConfig.categories - ]; - - return categories; - } - - - - - - getCurrencyInfo() { - return { - name: shopConfig.currencyName, - namePlural: shopConfig.currencyNamePlural, - symbol: shopConfig.currencySymbol - }; - } - - - - - - - getItemsForCategory(categoryId) { - if (categoryId === 'all') { - return shopItems; - } - return getItemsInCategory(categoryId); - } -} - -const shopService = new ShopService(); -export default shopService; - - - diff --git a/src/services/tempRoleService.js b/src/services/tempRoleService.js new file mode 100644 index 0000000000..77ae8967fc --- /dev/null +++ b/src/services/tempRoleService.js @@ -0,0 +1,117 @@ +import { logger } from '../utils/logger.js'; +import { randomUUID } from 'crypto'; + +// setTimeout max is ~24.8 days (2^31 ms); rely on cron for longer durations +const MAX_SETTIMEOUT_MS = 24 * 24 * 60 * 60 * 1000; + +function getKey(guildId) { + return `guild:${guildId}:temproles`; +} + +async function expireEntry(client, guildId, entry) { + try { + const guild = client.guilds.cache.get(guildId); + if (guild) { + const member = await guild.members.fetch(entry.userId).catch(() => null); + if (member && member.roles.cache.has(entry.roleId)) { + await member.roles.remove(entry.roleId, 'Temp role expired'); + logger.info(`Temp role ${entry.roleId} expired for ${entry.userId} in ${guildId}`); + } + } + } catch (err) { + logger.error(`Failed to remove temp role ${entry.roleId} from ${entry.userId}:`, err); + } + await TempRoleService.removeById(client, guildId, entry.id); +} + +export function scheduleTempRoleRemoval(client, guildId, entry) { + const delay = entry.expiresAt - Date.now(); + if (delay <= 0) { + expireEntry(client, guildId, entry); + return; + } + if (delay <= MAX_SETTIMEOUT_MS) { + setTimeout(() => expireEntry(client, guildId, entry), delay); + } + // else: cron job handles it (fires every minute) +} + +export const TempRoleService = { + async list(client, guildId) { + return client.db.get(getKey(guildId), []); + }, + + async add(client, guildId, { userId, roleId, expiresAt, assignedBy }) { + const entries = await client.db.get(getKey(guildId), []); + const filtered = entries.filter(e => !(e.userId === userId && e.roleId === roleId)); + const entry = { + id: randomUUID().slice(0, 8), + userId, + roleId, + expiresAt, + assignedBy, + assignedAt: Date.now(), + }; + filtered.push(entry); + await client.db.set(getKey(guildId), filtered); + return { success: true, entry }; + }, + + async remove(client, guildId, userId, roleId) { + const entries = await client.db.get(getKey(guildId), []); + const idx = entries.findIndex(e => e.userId === userId && e.roleId === roleId); + if (idx === -1) return { success: false }; + entries.splice(idx, 1); + await client.db.set(getKey(guildId), entries); + return { success: true }; + }, + + async removeById(client, guildId, id) { + const entries = await client.db.get(getKey(guildId), []); + await client.db.set(getKey(guildId), entries.filter(e => e.id !== id)); + }, +}; + +// Called on bot ready to reschedule any temp roles that survived a restart +export async function loadAndScheduleTempRoles(client) { + for (const [guildId] of client.guilds.cache) { + try { + const entries = await client.db.get(getKey(guildId), []); + for (const entry of entries) { + scheduleTempRoleRemoval(client, guildId, entry); + } + if (entries.length) { + logger.info(`Scheduled ${entries.length} temp role(s) for guild ${guildId}`); + } + } catch (err) { + logger.error(`Failed to load temp roles for guild ${guildId}:`, err); + } + } +} + +// Cron fallback — catches anything setTimeout missed (e.g. > 24-day durations) +export async function checkTempRoles(client) { + const now = Date.now(); + for (const [guildId, guild] of client.guilds.cache) { + try { + const entries = await client.db.get(getKey(guildId), []); + const expired = entries.filter(e => e.expiresAt <= now); + if (!expired.length) continue; + + for (const entry of expired) { + try { + const member = await guild.members.fetch(entry.userId).catch(() => null); + if (member && member.roles.cache.has(entry.roleId)) { + await member.roles.remove(entry.roleId, 'Temp role expired'); + logger.info(`Temp role ${entry.roleId} expired (cron) for ${entry.userId} in ${guildId}`); + } + } catch (err) { + logger.error(`Failed to remove temp role ${entry.roleId} from ${entry.userId}:`, err); + } + await TempRoleService.removeById(client, guildId, entry.id); + } + } catch (error) { + logger.error(`Temp role cron check error for guild ${guildId}:`, error); + } + } +} diff --git a/src/services/tempbanService.js b/src/services/tempbanService.js new file mode 100644 index 0000000000..060e44ca1a --- /dev/null +++ b/src/services/tempbanService.js @@ -0,0 +1,95 @@ +import { logger } from '../utils/logger.js'; + +// In-memory map so >bancheck can read remaining time: Map<`${guildId}_${userId}`, { timerId, unbanAt }> +export const tempBanTimers = new Map(); + +const DB_KEY = (guildId) => `guild:${guildId}:tempbans`; + +const MAX_ST = 2_147_483_647; +function safeTimeout(fn, ms) { + if (ms <= MAX_ST) return setTimeout(fn, ms); + return setTimeout(() => safeTimeout(fn, ms - MAX_ST), MAX_ST); +} + +async function executeUnban(client, guildId, userId, reason) { + const key = `${guildId}_${userId}`; + tempBanTimers.delete(key); + await removeTempban(client, guildId, userId); + const guild = client.guilds.cache.get(guildId); + if (!guild) return; + await guild.members.unban(userId, reason).catch(() => {}); + logger.info(`Tempban expired for ${userId} in ${guildId}`); + + // DM the user a rejooin invite now that the ban is lifted + try { + const user = await client.users.fetch(userId).catch(() => null); + if (!user) return; + const inviteChannel = guild.systemChannel + ?? guild.channels.cache.find(c => c.type === 0 && c.permissionsFor(guild.members.me)?.has('CreateInstantInvite')); + const invite = inviteChannel + ? await guild.invites.create(inviteChannel, { maxAge: 0, maxUses: 1, reason: 'Tempban expired rejoin invite' }).catch(() => null) + : null; + const { EmbedBuilder } = await import('discord.js'); + const embed = new EmbedBuilder() + .setColor(0x57F287) + .setTitle(`✅ Your ban in ${guild.name} has expired`) + .setDescription('You are welcome to rejoin the server.') + .setTimestamp(); + if (invite) embed.addFields({ name: '🔗 Rejoin', value: `https://discord.gg/${invite.code}` }); + await user.send({ embeds: [embed] }); + } catch {} +} + +function setTimer(client, guildId, userId, ms, unbanAt) { + const key = `${guildId}_${userId}`; + if (tempBanTimers.has(key)) clearTimeout(tempBanTimers.get(key).timerId); + const timerId = safeTimeout(() => executeUnban(client, guildId, userId, 'Tempban expired'), ms); + tempBanTimers.set(key, { timerId, unbanAt }); +} + +export async function scheduleTempBan(client, guildId, userId, ms, reason) { + const unbanAt = Date.now() + ms; + // Persist to DB + const entries = await client.db.get(DB_KEY(guildId), []); + const filtered = entries.filter(e => e.userId !== userId); + filtered.push({ userId, unbanAt, reason }); + await client.db.set(DB_KEY(guildId), filtered); + // Schedule in-memory timer + setTimer(client, guildId, userId, ms, unbanAt); +} + +export async function cancelTempBan(client, guildId, userId) { + const key = `${guildId}_${userId}`; + if (tempBanTimers.has(key)) { + clearTimeout(tempBanTimers.get(key).timerId); + tempBanTimers.delete(key); + } + await removeTempban(client, guildId, userId); +} + +async function removeTempban(client, guildId, userId) { + const entries = await client.db.get(DB_KEY(guildId), []); + await client.db.set(DB_KEY(guildId), entries.filter(e => e.userId !== userId)); +} + +export async function loadAndScheduleTempBans(client) { + let restored = 0; + for (const [guildId] of client.guilds.cache) { + try { + const entries = await client.db.get(DB_KEY(guildId), []); + for (const entry of entries) { + const remaining = entry.unbanAt - Date.now(); + if (remaining <= 0) { + // Already expired while bot was offline — unban now + await executeUnban(client, guildId, entry.userId, 'Tempban expired (caught on restart)'); + } else { + setTimer(client, guildId, entry.userId, remaining, entry.unbanAt); + restored++; + } + } + } catch (err) { + logger.error(`Failed to restore tempbans for guild ${guildId}:`, err); + } + } + if (restored > 0) logger.info(`Restored ${restored} tempban timer(s) from DB`); +} diff --git a/src/services/ticketSystemService.js b/src/services/ticketSystemService.js new file mode 100644 index 0000000000..aac4e6e809 --- /dev/null +++ b/src/services/ticketSystemService.js @@ -0,0 +1,21 @@ +import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle, PermissionFlagsBits, StringSelectMenuBuilder } from 'discord.js'; +import { createEmbed } from '../utils/embeds.js'; +import { ticketKey, getConfig } from '../modules/community/store.js'; +import { memberAccessLevel, AccessLevel } from '../modules/community/permissions.js'; +import { logger } from '../utils/logger.js'; + +export const TICKET_TYPES=Object.freeze({ + general:{emoji:'❓',label:'עזרה כללית',prefix:'ticket',fields:['evidence']},editing:{emoji:'🎬',label:'עזרה בעריכה',prefix:'edit-help',fields:['software','evidence']},report:{emoji:'🚨',label:'דיווח על משתמש',prefix:'report',fields:['evidence']},partnership:{emoji:'💼',label:'שותפות',prefix:'partner',fields:['evidence']},bot_bug:{emoji:'🐛',label:'תקלה בבוט',prefix:'bug',fields:['evidence']},resource:{emoji:'📦',label:'בעיה במשאב',prefix:'resource',fields:['evidence']},paid_work:{emoji:'💰',label:'עבודה בתשלום',prefix:'paid',fields:['budget','evidence']},management:{emoji:'📢',label:'פנייה להנהלה',prefix:'management',fields:['evidence']} +}); +export const TICKET_STATUSES=Object.freeze({open:'📂 פתוח',claimed:'🙋 נלקח',waiting:'⏳ ממתין למשתמש',handling:'🛠️ בטיפול',resolved:'✅ נפתר',closed:'🔒 נסגר',closing:'🔒 בתהליך סגירה'}); +export const TICKET_PRIORITIES=Object.freeze({low:'🟢 נמוכה',normal:'🟡 רגילה',high:'🟠 גבוהה',urgent:'🔴 דחופה'}); + +export const normalizeTicket=(ticket,context={})=>({id:String(ticket.id||ticket.number||context.id||'unknown'),guildId:ticket.guildId||context.guildId,channelId:ticket.channelId||context.channelId,creatorId:ticket.creatorId||ticket.ownerId,type:ticket.type||'general',title:ticket.title||ticket.subject||'פניית תמיכה',description:ticket.description||ticket.subject||'לא סופק תיאור',status:ticket.status||'open',priority:ticket.priority||'normal',assignedStaffId:ticket.assignedStaffId||null,createdAt:ticket.createdAt||Date.now(),closedAt:ticket.closedAt||null,closedBy:ticket.closedBy||null,closeReason:ticket.closeReason||null,transcriptLocation:ticket.transcriptLocation||null,addedMemberIds:ticket.addedMemberIds||[],lastActivityAt:ticket.lastActivityAt||ticket.createdAt||Date.now(),panelId:ticket.panelId||null,supportRoleId:ticket.supportRoleId||null,openingMessageId:ticket.openingMessageId||null,...ticket}); +export async function getTicket(client,guildId,channelId){const raw=await client.db.get(ticketKey(guildId,channelId));if(!raw)return null;const normalized=normalizeTicket(raw,{guildId,channelId});if(JSON.stringify(raw)!==JSON.stringify(normalized))await client.db.set(ticketKey(guildId,channelId),normalized);return normalized;} +export async function saveTicket(client,ticket){ticket.lastActivityAt=Date.now();await client.db.set(ticketKey(ticket.guildId,ticket.channelId),ticket);return ticket;} +export async function ticketAccess(interaction,client,ticket,{staffOnly=false}={}){const config=await getConfig(client,interaction.guildId),level=await memberAccessLevel(interaction,client),staff=interaction.member.roles.cache.has(config.tickets.supportRoleId)||level>=AccessLevel.MODERATOR;if(staffOnly)return staff;return staff||interaction.user.id===ticket.creatorId;} +export function ticketPanelPayload(config,panel={}){const enabled=(panel.types||config.tickets.enabledTypes||Object.keys(TICKET_TYPES)).filter(type=>TICKET_TYPES[type]),embed=createEmbed({title:panel.title||'🎫 מרכז התמיכה של EditIL',description:panel.description||'אנחנו כאן כדי לעזור. בחרו את הנושא המתאים מהרשימה ומלאו את פרטי הפנייה.\n\n• פנייה אחת לכל נושא\n• תיאור מפורט יעזור לנו לענות מהר יותר\n• אין לשתף סיסמאות או מידע רגיש',color:panel.color||'primary'});if(panel.style==='buttons'){const buttons=enabled.map(type=>new ButtonBuilder().setCustomId(`ticket_type_button:${type}:${panel.id||'default'}`).setLabel(TICKET_TYPES[type].label).setEmoji(TICKET_TYPES[type].emoji).setStyle(ButtonStyle.Secondary));return{embeds:[embed],components:[new ActionRowBuilder().addComponents(buttons.slice(0,5)),...(buttons.length>5?[new ActionRowBuilder().addComponents(buttons.slice(5))]:[])]};}const menu=new StringSelectMenuBuilder().setCustomId(`ticket_type:${panel.id||'default'}`).setPlaceholder('בחרו נושא לפתיחת פנייה…').addOptions(enabled.map(value=>({value,label:TICKET_TYPES[value].label,emoji:TICKET_TYPES[value].emoji,description:`פתיחת פנייה בנושא ${TICKET_TYPES[value].label}`})));return{embeds:[embed],components:[new ActionRowBuilder().addComponents(menu)]};} +export function ticketMessagePayload(ticket,{disabled=false}={}){const type=TICKET_TYPES[ticket.type]||TICKET_TYPES.general;const fields=[{name:'מזהה',value:`#${ticket.id}`,inline:true},{name:'סוג',value:`${type.emoji} ${type.label}`,inline:true},{name:'יוצר',value:`<@${ticket.creatorId}>`,inline:true},{name:'נוצר',value:`<t:${Math.floor(ticket.createdAt/1000)}:F>`},{name:'כותרת',value:ticket.title},{name:'תיאור',value:ticket.description},{name:'סטטוס',value:TICKET_STATUSES[ticket.status]||ticket.status,inline:true},{name:'עדיפות',value:TICKET_PRIORITIES[ticket.priority]||ticket.priority,inline:true},{name:'איש צוות מטפל',value:ticket.assignedStaffId?`<@${ticket.assignedStaffId}>`:'טרם הוקצה',inline:true}];if(ticket.software)fields.push({name:'תוכנה',value:ticket.software});if(ticket.budget)fields.push({name:'תקציב',value:ticket.budget});if(ticket.evidence)fields.push({name:'הוכחה / קישור',value:ticket.evidence});const b=(id,label,style=ButtonStyle.Secondary,emoji)=>new ButtonBuilder().setCustomId(id).setLabel(label).setStyle(style).setEmoji(emoji).setDisabled(disabled);return{embeds:[createEmbed({title:`כרטיס תמיכה #${ticket.id}`,fields,color:ticket.status==='closed'?'error':ticket.priority==='urgent'?'warning':'primary'})],components:[new ActionRowBuilder().addComponents(b('ticket_action:close','סגירה',ButtonStyle.Danger,'🔒'),b('ticket_action:claim','לקיחה',ButtonStyle.Success,'🙋'),b('ticket_action:add','הוספת משתמש',ButtonStyle.Secondary,'➕'),b('ticket_action:remove','הסרת משתמש',ButtonStyle.Secondary,'➖'),b('ticket_action:rename','שינוי שם',ButtonStyle.Secondary,'📝')),new ActionRowBuilder().addComponents(b('ticket_action:transcript','תמלול',ButtonStyle.Primary,'📄'),b('ticket_action:alert','הזעקת צוות',ButtonStyle.Secondary,'🔔'))]};} +export async function buildTranscript(channel){const messages=await channel.messages.fetch({limit:100});const lines=[`EditIL ticket transcript: #${channel.name}`,...messages.sort((a,b)=>a.createdTimestamp-b.createdTimestamp).map(message=>{const attachments=[...message.attachments.values()].map(item=>item.url).join(' '),embeds=message.embeds.map(embed=>`[Embed: ${embed.title||'ללא כותרת'}]`).join(' '),edited=message.editedTimestamp?' [נערך]':'';return`[${new Date(message.createdTimestamp).toISOString()}] ${message.author.tag}${edited}: ${message.cleanContent||''} ${attachments} ${embeds}`.trim();})];return new AttachmentBuilder(Buffer.from(lines.join('\n'),'utf8'),{name:`${channel.name}-transcript.txt`});} +export function safeChannelName(value,id,prefix='ticket'){const clean=String(value||'').toLowerCase().normalize('NFKD').replace(/[^a-z0-9-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').slice(0,70)||prefix;return`${clean}-${String(id).padStart(4,'0')}`.slice(0,100);} +export async function validateSavedTickets(client){for(const key of(await client.db.list('community:')).filter(value=>/^community:[^:]+:ticket:[^:]+$/.test(value))){const raw=await client.db.get(key),[,guildId,,channelId]=key.split(':'),ticket=normalizeTicket(raw,{guildId,channelId}),guild=client.guilds.cache.get(guildId),channel=guild?.channels.cache.get(channelId);if(!channel&&!['closed','deleted'].includes(ticket.status)){ticket.status='deleted';ticket.deletedAt=Date.now();await client.db.set(key,ticket);logger.warn('Open ticket channel is missing; marked deleted',{guildId,channelId,ticketId:ticket.id});}else if(channel&&JSON.stringify(raw)!==JSON.stringify(ticket))await client.db.set(key,ticket);}} diff --git a/src/services/utilityService.js b/src/services/utilityService.js index c243e119fd..423dd3f872 100644 --- a/src/services/utilityService.js +++ b/src/services/utilityService.js @@ -328,28 +328,13 @@ class UtilityService { const dataKeyPatterns = [ - `economy:${guildId}:${userId}`, `level:${guildId}:${userId}`, `xp:${guildId}:${userId}`, - `inventory:${guildId}:${userId}`, - `bank:${guildId}:${userId}`, - `wallet:${guildId}:${userId}`, `cooldowns:${guildId}:${userId}`, - `shop:${guildId}:${userId}`, - `shop_data:${guildId}:${userId}`, `counter:${guildId}:${userId}`, `birthday:${guildId}:${userId}`, - `balance:${guildId}:${userId}`, `user:${guildId}:${userId}`, `leveling:${guildId}:${userId}`, - `crimexp:${guildId}:${userId}`, - `robxp:${guildId}:${userId}`, - `crime_cooldown:${guildId}:${userId}`, - `rob_cooldown:${guildId}:${userId}`, - `lastDaily:${guildId}:${userId}`, - `lastWork:${guildId}:${userId}`, - `lastCrime:${guildId}:${userId}`, - `lastRob:${guildId}:${userId}` ]; let deletedCount = 0; diff --git a/src/services/verificationService.js b/src/services/verificationService.js index 9a1542b7a2..3cf2a5322c 100644 --- a/src/services/verificationService.js +++ b/src/services/verificationService.js @@ -13,7 +13,7 @@ import { PermissionFlagsBits } from 'discord.js'; -import { botConfig } from '../config/bot.js'; +import botConfig from '../config/bot.js'; import { logger } from '../utils/logger.js'; import { getGuildConfig, setGuildConfig } from './guildConfig.js'; import { createError, ErrorTypes } from '../utils/errorHandler.js'; diff --git a/src/utils/dmSessions.js b/src/utils/dmSessions.js new file mode 100644 index 0000000000..2a49915984 --- /dev/null +++ b/src/utils/dmSessions.js @@ -0,0 +1,43 @@ +const SESSION_TTL_MS = 24 * 60 * 60 * 1000; + +// Map<targetUserId, { staffId, dmChannelId, dmMessageId, text, threadId, sentAt }> +const dmSessions = new Map(); + +// Reverse lookup: threadId -> targetUserId +const threadToTarget = new Map(); + +export function storeDmSession(targetUserId, { staffId, dmChannelId, dmMessageId, text, threadId }) { + dmSessions.set(targetUserId, { staffId, dmChannelId, dmMessageId, text, threadId, sentAt: Date.now() }); + if (threadId) threadToTarget.set(threadId, targetUserId); +} + +export function getDmSession(targetUserId) { + const session = dmSessions.get(targetUserId); + if (!session) return null; + if (Date.now() - session.sentAt > SESSION_TTL_MS) { + if (session.threadId) threadToTarget.delete(session.threadId); + dmSessions.delete(targetUserId); + return null; + } + return session; +} + +export function updateDmSession(targetUserId, updates) { + const session = dmSessions.get(targetUserId); + if (!session) return; + if (updates.threadId && session.threadId) threadToTarget.delete(session.threadId); + const updated = { ...session, ...updates }; + dmSessions.set(targetUserId, updated); + if (updated.threadId) threadToTarget.set(updated.threadId, targetUserId); +} + +export function getTargetByThread(threadId) { + const targetId = threadToTarget.get(threadId); + if (!targetId) return null; + const session = getDmSession(targetId); + if (!session) { + threadToTarget.delete(threadId); + return null; + } + return { targetId, session }; +} diff --git a/src/utils/economy.js b/src/utils/economy.js deleted file mode 100644 index 589f558508..0000000000 --- a/src/utils/economy.js +++ /dev/null @@ -1,480 +0,0 @@ -import { getColor } from './database.js'; -import { BotConfig } from '../config/bot.js'; -import { normalizeEconomyData } from './schemas.js'; -import { logger } from './logger.js'; -import { validateDiscordId, validateNumber } from './validation.js'; -import { DEFAULT_ECONOMY_DATA } from './constants.js'; - -const ECONOMY_CONFIG = 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 || { -daily: 24 * 60 * 60 * 1000, -work: 60 * 60 * 1000, -crime: 2 * 60 * 60 * 1000, -rob: 4 * 60 * 60 * 1000, -}; - - - - - - - - -export function getEconomyKey(guildId, userId) { - const validGuildId = validateDiscordId(guildId, 'guildId'); - const validUserId = validateDiscordId(userId, 'userId'); - - if (!validGuildId || !validUserId) { - throw new Error('Invalid guild ID or user ID'); - } - - return `economy:${validGuildId}:${validUserId}`; -} - - - - - - -export function getMaxBankCapacity(userData) { - if (!userData) return BASE_BANK_CAPACITY; - - const bankLevel = userData.bankLevel || 0; - let capacity = BASE_BANK_CAPACITY + (bankLevel * BANK_CAPACITY_PER_LEVEL); - - - const upgrades = userData.upgrades || {}; - const inventory = userData.inventory || {}; - - - if (upgrades['bank_upgrade_1']) { - capacity = Math.floor(capacity * 1.5); - } - - - const bankNotes = inventory['bank_note'] || 0; - capacity += (bankNotes * 10000); - - return capacity; -} - - - - - - -export function formatCurrency(amount) { - return `${amount.toLocaleString()} ${ECONOMY_CONFIG.currency || 'coins'}`; -} - - - - - - - - -export async function getEconomyData(client, guildId, userId) { - try { - if (!client.db || typeof client.db.get !== 'function') { - throw new Error('Database not available'); - } - - const key = getEconomyKey(guildId, userId); - const data = await client.db.get(key, {}); - - return normalizeEconomyData(data, DEFAULT_ECONOMY_DATA); - } catch (error) { - logger.error(`Error getting economy data for user ${userId}`, error); - return normalizeEconomyData({}, DEFAULT_ECONOMY_DATA); - } -} - - - - - - - - - -export async function setEconomyData(client, guildId, userId, data) { - try { - if (!client.db || typeof client.db.set !== 'function') { - throw new Error('Database not available'); - } - - const key = getEconomyKey(guildId, userId); - const normalized = normalizeEconomyData(data, DEFAULT_ECONOMY_DATA); - await client.db.set(key, normalized); - return true; - } catch (error) { - logger.error(`Error saving economy data for user ${userId}`, error); - return false; - } -} - - - - - - - - - - - - -export async function updateBalance(client, guildId, userId, options = {}) { - const data = await getEconomyData(client, guildId, userId); - - if (options.wallet !== undefined) { - data.wallet = Math.max(0, (data.wallet || 0) + options.wallet); - } - - if (options.bank !== undefined) { - const maxBank = getMaxBankCapacity(data); - data.bank = Math.min(Math.max(0, (data.bank || 0) + options.bank), maxBank); - } - - if (options.xp !== undefined) { - data.xp = Math.max(0, (data.xp || 0) + options.xp); - - const xpNeeded = Math.floor(5 * Math.pow(data.level || 1, 2) + 50 * (data.level || 1) + 100); - if (data.xp >= xpNeeded) { - data.xp -= xpNeeded; - data.level = (data.level || 1) + 1; - data.leveledUp = true; - } - } - - await setEconomyData(client, guildId, userId, data); - return data; -} - - - - - - - -export function checkCooldown(userData, action) { - const cooldownTime = COOLDOWNS[action] || 0; - const lastUsed = userData[`last${action.charAt(0).toUpperCase() + action.slice(1)}`] || 0; - const now = Date.now(); - const remaining = Math.max(0, (lastUsed + cooldownTime) - now); - - return { - onCooldown: remaining > 0, - remaining, - formatted: formatCooldown(remaining) - }; -} - - - - - - -function formatCooldown(ms) { - if (ms < 1000) return 'now'; - - const seconds = Math.floor(ms / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - - if (days > 0) return `${days}d ${hours % 24}h`; - if (hours > 0) return `${hours}h ${minutes % 60}m`; - if (minutes > 0) return `${minutes}m ${seconds % 60}s`; - return `${seconds}s`; -} - - - - - -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' - ]; - - const job = jobs[Math.floor(Math.random() * jobs.length)]; - - return { - amount, - job, - message: `You ${job} and earned ${formatCurrency(amount)}!` - }; -} - - - - - -export function getCrimeOutcome() { - const outcomes = [ - { - success: true, - amount: Math.floor(Math.random() * 200) + 50, - message: 'You successfully robbed a bank and got away with {amount}!' - }, - { - success: true, - amount: Math.floor(Math.random() * 100) + 20, - message: 'You pickpocketed someone and stole {amount}!' - }, - { - success: true, - amount: Math.floor(Math.random() * 150) + 30, - message: 'You hacked into a bank account and transferred {amount} to yourself!' - }, - { - success: false, - fine: Math.floor(Math.random() * 100) + 50, - message: 'You got caught and had to pay a fine of {fine}!' - }, - { - success: false, - fine: Math.floor(Math.random() * 150) + 50, - message: 'The police caught you! You paid {fine} to get out of jail.' - }, - { - success: false, - fine: 0, - message: 'Your attempt failed, but you managed to escape!' - } - ]; - - return outcomes[Math.floor(Math.random() * outcomes.length)]; -} - - - - - - -export function getRobOutcome(targetBalance) { - if (targetBalance <= 0) { - return { - success: false, - amount: 0, - message: 'The target has no money to steal!' - }; - } - -const success = Math.random() > 0.4; - - if (success) { - const amount = Math.min( -Math.floor(Math.random() * (targetBalance * 0.3)) + 1, - targetBalance - ); - - return { - success: true, - amount, - message: `You successfully robbed them and got away with {amount}!` - }; - } else { - const fine = Math.floor(Math.random() * 200) + 100; - - return { - success: false, - amount: 0, - fine, - message: `You got caught! You had to pay a fine of {fine}.` - }; - } -} - - - - - - - -export function formatShopItem(item, index) { - return `**${index + 1}.** ${item.emoji} **${item.name}** - ${formatCurrency(item.price)}\n${item.description}\n`; -} - - - - - - - - - - - - - - -export async function addMoney(client, guildId, userId, amount, type = 'wallet') { - try { - - const validAmount = validateNumber(amount, 'amount'); - if (validAmount === null || validAmount <= 0) { - return { success: false, error: 'Amount must be a positive number' }; - } - - if (type !== 'wallet' && type !== 'bank') { - logger.warn('[VALIDATION] Invalid money type:', { type }); - return { success: false, error: 'Type must be "wallet" or "bank"' }; - } - - const userData = await getEconomyData(client, guildId, userId); - - if (type === 'bank') { - const maxBank = getMaxBankCapacity(userData); - if ((userData.bank || 0) + validAmount > maxBank) { - return { - success: false, - error: 'Bank capacity exceeded', - current: userData.bank || 0, - max: maxBank - }; - } - userData.bank = (userData.bank || 0) + validAmount; - } else { - userData.wallet = (userData.wallet || 0) + validAmount; - } - - await setEconomyData(client, guildId, userId, userData); - - return { - success: true, - newBalance: type === 'bank' ? userData.bank : userData.wallet, - ...(type === 'bank' ? { maxBank: getMaxBankCapacity(userData) } : {}) - }; - } catch (error) { - logger.error(`Error adding money to ${type} for user ${userId} in guild ${guildId}`, error); - return { success: false, error: 'An error occurred while processing your request' }; - } -} - - - - - - - - - - -export async function removeMoney(client, guildId, userId, amount, type = 'wallet') { - try { - - const validAmount = validateNumber(amount, 'amount'); - if (validAmount === null || validAmount <= 0) { - return { success: false, error: 'Amount must be a positive number' }; - } - - if (type !== 'wallet' && type !== 'bank') { - logger.warn('[VALIDATION] Invalid money type:', { type }); - return { success: false, error: 'Type must be "wallet" or "bank"' }; - } - - const userData = await getEconomyData(client, guildId, userId); - - if (type === 'bank') { - if ((userData.bank || 0) < validAmount) { - return { - success: false, - error: 'Insufficient funds in bank', - current: userData.bank || 0, - required: validAmount - }; - } - userData.bank = (userData.bank || 0) - validAmount; - } else { - if ((userData.wallet || 0) < validAmount) { - return { - success: false, - error: 'Insufficient funds in wallet', - current: userData.wallet || 0, - required: validAmount - }; - } - userData.wallet = (userData.wallet || 0) - validAmount; - } - - await setEconomyData(client, guildId, userId, userData); - - return { - success: true, - newBalance: type === 'bank' ? userData.bank : userData.wallet - }; - } catch (error) { - logger.error(`Error removing money from ${type} for user ${userId} in guild ${guildId}`, error); - return { success: false, error: 'An error occurred while processing your request' }; - } -} - -export function getShopInventory() { - return [ - { - id: 'fishing_rod', - name: 'Fishing Rod', - emoji: '🎣', - price: 500, - description: 'Catch fish to sell for profit!', - type: 'tool' - }, - { - id: 'hunting_rifle', - name: 'Hunting Rifle', - emoji: '🔫', - price: 1000, - description: 'Hunt animals for meat and fur!', - type: 'tool' - }, - { - id: 'laptop', - name: 'Laptop', - emoji: '💻', - price: 2000, - description: 'Work as a programmer for higher pay!', - type: 'tool', - workMultiplier: 1.5 - }, - { - id: 'bank_loan', - name: 'Bank Loan', - emoji: '🏦', - price: 5000, - description: 'Increases your bank capacity by 50,000!', - type: 'upgrade', - effect: 'bank_capacity', - value: 50000 - }, - { - id: 'lottery_ticket', - name: 'Lottery Ticket', - emoji: '🎫', - price: 100, - description: 'A chance to win big!', - type: 'consumable', - use: 'gamble' - } - ]; -} - - - diff --git a/src/utils/embeds.js b/src/utils/embeds.js index b3ca906076..a90322ead9 100644 --- a/src/utils/embeds.js +++ b/src/utils/embeds.js @@ -129,10 +129,10 @@ export function errorEmbed(message, error = null, options = {}) { }); } -export function successEmbed(message, title = '✅ Success') { +export function successEmbed(title, description = '') { return createEmbed({ title, - description: message, + description, color: 'success', timestamp: true }); diff --git a/src/utils/levelLimits.js b/src/utils/levelLimits.js new file mode 100644 index 0000000000..d295177e77 --- /dev/null +++ b/src/utils/levelLimits.js @@ -0,0 +1,10 @@ +export const MAX_LEVEL = 50; +export const MAX_COMMUNITY_XP = MAX_LEVEL * MAX_LEVEL * 100; + +export function communityLevelFromXp(xp) { + return Math.min(MAX_LEVEL, Math.floor(Math.sqrt(Math.max(0, Number(xp) || 0) / 100))); +} + +export function clampCommunityXp(xp) { + return Math.min(MAX_COMMUNITY_XP, Math.max(0, Number(xp) || 0)); +} diff --git a/src/utils/musicPanel.js b/src/utils/musicPanel.js new file mode 100644 index 0000000000..083938b080 --- /dev/null +++ b/src/utils/musicPanel.js @@ -0,0 +1,176 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js'; + +export function fmtDuration(ms) { + const s = Math.floor(ms / 1000); + const m = Math.floor(s / 60); + const h = Math.floor(m / 60); + return h > 0 + ? `${h}:${String(m % 60).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` + : `${m}:${String(s % 60).padStart(2, '0')}`; +} + +export function getVoteRequired(player, client) { + const vc = client.channels.cache.get(player.voiceChannelId); + const humanCount = vc?.members?.filter(m => !m.user.bot)?.size ?? 1; + return Math.min(3, Math.max(1, humanCount)); +} + +const FX_META = { + null: { emoji: '🎵', label: 'FX: Off', style: ButtonStyle.Secondary }, + bassboost: { emoji: '🎸', label: 'Bass Boost', style: ButtonStyle.Success }, + nightcore: { emoji: '⚡', label: 'Nightcore', style: ButtonStyle.Success }, + vaporwave: { emoji: '🌊', label: 'Vaporwave', style: ButtonStyle.Success }, + '8d': { emoji: '🔄', label: '8D Audio', style: ButtonStyle.Success }, +}; + +export const FILTER_CYCLE = [null, 'bassboost', 'nightcore', 'vaporwave', '8d']; + +export async function applyFilter(player, filterName) { + await player.filterManager.resetFilters(); + if (filterName === 'nightcore') await player.filterManager.toggleNightcore(); + else if (filterName === 'vaporwave') await player.filterManager.toggleVaporwave(); + else if (filterName === '8d') await player.filterManager.toggleRotation().catch(() => {}); + else if (filterName === 'bassboost') await player.filterManager.setEQPreset('BassboostMedium'); +} + +export function buildPanel(player, voteCount = 0, required = 3, isPaused = false, activeFilter = null) { + const track = player.queue.current; + if (!track) return buildQueueEmptyPanel(); + const pos = player.position ?? 0; + const dur = track.info.duration; + const repeatMode = player.repeatMode ?? 'off'; + const queueLen = player.queue.tracks.length; + const vol = player.volume ?? 100; + + // 13-segment progress bar + const pct = dur > 0 ? Math.min(pos / dur, 1) : 0; + const filled = Math.round(pct * 13); + const bar = '▬'.repeat(filled) + '🔘' + '▬'.repeat(13 - filled); + + // Requester + const requester = track.requester; + const requesterName = requester?.displayName ?? requester?.globalName ?? requester?.username ?? null; + + // Next track + const nextTrack = player.queue.tracks[0] ?? null; + + let desc = + `**[${track.info.title}](${track.info.uri})**\n` + + `📺 ${track.info.author}`; + if (requesterName) desc += `\n👤 Requested by **${requesterName}**`; + desc += `\n\n${bar}\n\`${fmtDuration(pos)}\` / \`${fmtDuration(dur)}\``; + desc += nextTrack + ? `\n\n⏭️ **Up Next:** [${nextTrack.info.title}](${nextTrack.info.uri})` + : '\n\n⏭️ *Queue is empty*'; + + const footerParts = [ + '⚠️ Beta', + isPaused ? '⏸️ Paused' : '▶️ Playing', + `🔊 ${vol}%`, + queueLen ? `${queueLen} in queue` : 'No queue', + ]; + if (repeatMode === 'track') footerParts.push('🔂 Track loop'); + else if (repeatMode === 'queue') footerParts.push('🔁 Queue loop'); + if (activeFilter) footerParts.push(FX_META[activeFilter]?.label ?? ''); + + const embed = new EmbedBuilder() + .setColor(0x5865F2) + .setTitle('🎵 Now Playing') + .setDescription(desc) + .setFooter({ text: footerParts.join(' • ') }); + + if (track.info.artworkUrl) embed.setThumbnail(track.info.artworkUrl); + + const fxMeta = FX_META[activeFilter] ?? FX_META['null']; + + const row1 = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('music_pause') + .setEmoji(isPaused ? '▶️' : '⏸️') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('music_skip') + .setLabel(`Skip ${voteCount}/${required}`) + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId('music_stop') + .setEmoji('⏹️') + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId('music_addtrack') + .setLabel('Add Track') + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId('music_queue') + .setEmoji('📋') + .setStyle(ButtonStyle.Secondary), + ); + + const row2 = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('music_loop') + .setEmoji(repeatMode === 'track' ? '🔂' : '🔁') + .setStyle(repeatMode !== 'off' ? ButtonStyle.Success : ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('music_shuffle') + .setEmoji('🔀') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('music_restart') + .setEmoji('⏮️') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('music_volume') + .setEmoji('🔊') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('music_filter') + .setEmoji(fxMeta.emoji) + .setLabel(fxMeta.label) + .setStyle(fxMeta.style), + ); + + const row3 = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('music_seek') + .setEmoji('⏩') + .setLabel('Seek') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('music_lyrics') + .setEmoji('📝') + .setLabel('Lyrics') + .setStyle(ButtonStyle.Secondary), + ); + + return { embeds: [embed], components: [row1, row2, row3] }; +} + +export function buildQueueEmptyPanel() { + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('music_addtrack') + .setLabel('Add Track') + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId('music_retry') + .setLabel('Retry Last') + .setEmoji('🔄') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('music_stop') + .setEmoji('⏹️') + .setStyle(ButtonStyle.Danger), + ); + return { + embeds: [new EmbedBuilder().setColor(0x99AAB5).setDescription('⏸️ Queue empty — add a track to keep going!\n-# ⚠️ Music is in beta — may be unstable')], + components: [row], + }; +} + +export function buildEndedPanel() { + return { + embeds: [new EmbedBuilder().setColor(0x99AAB5).setDescription('⏹️ Stopped — see you next time!')], + components: [], + }; +} diff --git a/src/utils/nukeSnapshots.js b/src/utils/nukeSnapshots.js new file mode 100644 index 0000000000..bd095e9a2f --- /dev/null +++ b/src/utils/nukeSnapshots.js @@ -0,0 +1,58 @@ +// Map<guildId, snapshotData> — stored in memory until bot restarts +const snapshots = new Map(); + +export function storeNukeSnapshot(guildId, snapshot) { + snapshots.set(guildId, snapshot); +} + +export function getNukeSnapshot(guildId) { + return snapshots.get(guildId) ?? null; +} + +export async function saveServerSnapshot(guild) { + await guild.members.fetch().catch(() => {}); + await guild.roles.fetch().catch(() => {}); + await guild.channels.fetch().catch(() => {}); + + return { + savedAt: new Date().toISOString(), + server: { + id: guild.id, + name: guild.name, + icon: guild.iconURL(), + ownerId: guild.ownerId, + memberCount: guild.memberCount, + }, + roles: [...guild.roles.cache.values()] + .filter(r => !r.managed && r.id !== guild.id) + .sort((a, b) => a.position - b.position) + .map(r => ({ + id: r.id, + name: r.name, + color: r.hexColor, + permissions: r.permissions.toArray(), + position: r.position, + hoist: r.hoist, + mentionable: r.mentionable, + })), + channels: [...guild.channels.cache.values()] + .sort((a, b) => (a.rawPosition ?? 0) - (b.rawPosition ?? 0)) + .map(c => ({ + id: c.id, + name: c.name, + type: c.type, + parentId: c.parentId ?? null, + position: c.rawPosition ?? 0, + topic: c.topic ?? null, + nsfw: c.nsfw ?? false, + slowmode: c.rateLimitPerUser ?? 0, + })), + members: [...guild.members.cache.values()].map(m => ({ + id: m.id, + tag: m.user.tag, + nickname: m.nickname ?? null, + roles: m.roles.cache.filter(r => r.id !== guild.id).map(r => r.id), + joinedAt: m.joinedAt?.toISOString() ?? null, + })), + }; +} diff --git a/src/utils/presenceSync.js b/src/utils/presenceSync.js new file mode 100644 index 0000000000..df0c051e83 --- /dev/null +++ b/src/utils/presenceSync.js @@ -0,0 +1,9 @@ +import { botConfig } from '../config/botConfig.js'; + +export function applyPresence(client) { + client.user.setPresence(botConfig.presence); +} + +export function syncFromGuild(client) { + applyPresence(client); +} diff --git a/src/utils/snipeCache.js b/src/utils/snipeCache.js new file mode 100644 index 0000000000..5ee1100044 --- /dev/null +++ b/src/utils/snipeCache.js @@ -0,0 +1,2 @@ +// channelId → { content, author, attachmentUrl, timestamp } +export const snipeCache = new Map(); diff --git a/tests/botUpdates.test.js b/tests/botUpdates.test.js new file mode 100644 index 0000000000..fec28fa1c8 --- /dev/null +++ b/tests/botUpdates.test.js @@ -0,0 +1,13 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { PermissionFlagsBits } from 'discord.js'; +import { getUpdateSettings, parseLines, postUpdate, runStartupUpdateCheck } from '../src/services/botUpdateService.js'; + +const makeClient = () => { const data=new Map(); return { user:{displayAvatarURL:()=>null},db:{get:async(k,d)=>data.has(k)?data.get(k):d,set:async(k,v)=>{data.set(k,v);return true;},delete:async k=>data.delete(k)},guilds:{cache:new Map()} }; }; +function makeGuild(client,{sendFails=false}={}){let sends=0;const message={id:'message-1'};const channel={id:'1527004410536923357',guildId:'guild-1',type:0,permissionsFor:()=>({has:bits=>bits.includes(PermissionFlagsBits.EmbedLinks)}),send:async payload=>{sends++;if(sendFails)throw new Error('send failed');channel.payload=payload;return message;}};const guild={id:'guild-1',members:{me:{}},roles:{cache:new Map()},channels:{cache:new Map([[channel.id,channel]]),fetch:async()=>channel}};client.guilds.cache.set(guild.id,guild);return{guild,channel,get sends(){return sends;}};} + +test('multi-line changelog values become clean bullet entries',()=>assert.deepEqual(parseLines('one\n• two\n- three'),['one','two','three'])); +test('successful post persists version and restricts role mentions',async()=>{const client=makeClient(),ctx=makeGuild(client);await client.db.set('guild:guild-1:bot_updates',{roleId:'role-1'});ctx.guild.roles.cache.set('role-1',{id:'role-1'});await postUpdate(client,ctx.guild,{version:'9.0.0',title:'עדכון',newFeatures:[],fixes:[],improvements:[]});const s=await getUpdateSettings(client,ctx.guild.id);assert.equal(s.lastAnnouncedVersion,'9.0.0');assert.deepEqual(ctx.channel.payload.allowedMentions,{parse:[],roles:['role-1']});}); +test('failed post never marks the version announced',async()=>{const client=makeClient(),ctx=makeGuild(client,{sendFails:true});await assert.rejects(()=>postUpdate(client,ctx.guild,{version:'9.0.0',title:'עדכון',newFeatures:[],fixes:[],improvements:[]}));assert.equal((await getUpdateSettings(client,ctx.guild.id)).lastAnnouncedVersion,null);}); +test('startup check posts exactly once and reconnect-style calls do nothing',async()=>{const client=makeClient(),ctx=makeGuild(client);await runStartupUpdateCheck(client);await runStartupUpdateCheck(client);assert.equal(ctx.sends,1);}); +test('startup check never posts while database is in degraded memory mode',async()=>{const client=makeClient(),ctx=makeGuild(client);client.db.isAvailable=()=>false;await runStartupUpdateCheck(client);assert.equal(ctx.sends,0);}); diff --git a/tests/communityCommands.test.js b/tests/communityCommands.test.js new file mode 100644 index 0000000000..39c5c7acf7 --- /dev/null +++ b/tests/communityCommands.test.js @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Collection } from 'discord.js'; +import { communityCommand, createModal, parseDuration } from '../src/commands/community/factory.js'; +import settings from '../src/commands/admin/settings.js'; +import communityActions from '../src/modules/interactions/buttons/community_actions.js'; +import communityModal from '../src/modules/interactions/modals/community_form.js'; + +const names = ['suggest', 'feedback', 'report', 'poll', 'selfpromo', 'lookingforeditor', 'lookingforteam', 'editingtype']; + +test('all community commands build as unique guild-only slash commands', () => { + const commands = names.map(name => communityCommand(name).data.toJSON()); + assert.equal(new Set(commands.map(command => command.name)).size, names.length); + for (const command of commands) { + assert.equal(command.dm_permission, false); + assert.match(command.description, /[\u0590-\u05ff]/); + } + assert.equal(commands.find(command => command.name === 'poll').options.length, 8); +}); + +test('community modal definitions stay within Discord limits', () => { + for (const name of names.filter(name => !['poll', 'editingtype'].includes(name))) { + const modal = createModal(name).toJSON(); + assert.ok(modal.components.length >= 2 && modal.components.length <= 5); + assert.ok(modal.components.every(row => row.components.length === 1)); + } + assert.equal(communityModal.name, 'community_form'); + assert.equal(communityActions.length, 5); + assert.deepEqual(communityActions.map(handler => handler.name), ['community_vote', 'community_status', 'community_interest', 'community_contact', 'community_close']); +}); + +test('poll duration validation enforces ten minutes through seven days', () => { + assert.equal(parseDuration('10m'), 600000); + assert.equal(parseDuration('2h'), 7200000); + assert.equal(parseDuration('7d'), 604800000); + for (const invalid of ['9m', '8d', '1 hour', '', 'abc']) assert.equal(parseDuration(invalid), null); +}); + +test('settings exposes the complete community configuration group', () => { + const json = settings.data.toJSON(); + const group = json.options.find(option => option.name === 'community'); + assert.ok(group); + assert.deepEqual(group.options.map(option => option.name), ['view', 'channel', 'cooldown', 'toggles', 'roles']); + const channel = group.options.find(option => option.name === 'channel'); + assert.equal(channel.options.find(option => option.name === 'type').choices.length, 6); +}); + +test('duplicate suggestion votes are rejected and persisted votes remain addressable after reload', async () => { + const records = new Map([['community:g:suggest:1', { id: '1', status: 'open', votes: { u: 'up' } }]]); + const client = { db: { get: async key => records.get(key), set: async (key, value) => records.set(key, value) } }; + let reply; + const interaction = { guildId: 'g', user: { id: 'u' }, reply: async payload => { reply = payload; } }; + await communityActions[0].execute(interaction, client, ['suggest', '1', 'up']); + assert.match(reply.content, /כבר הצבעת/); + assert.equal(records.get('community:g:suggest:1').votes.u, 'up'); +}); + +test('missing configured channel keeps report submission private and sends no message', async () => { + const client = { db: { get: async key => key.endsWith(':config') ? {} : 0 } }; + let reply; + const interaction = { + guildId: 'g', guild: { channels: { cache: new Collection() } }, + reply: async payload => { reply = payload; } + }; + await communityModal.execute(interaction, client, ['report']); + assert.equal(reply.content, 'ערוץ המערכת עדיין לא הוגדר.'); + assert.ok(reply.flags); +}); diff --git a/tests/generalCommands.test.js b/tests/generalCommands.test.js new file mode 100644 index 0000000000..6946f0d29d --- /dev/null +++ b/tests/generalCommands.test.js @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Collection, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { createHelpView, formatUptime, generalCommand, getVisibleHelpCommands, HELP_CATEGORIES } from '../src/commands/general/factory.js'; + +test('all requested general slash commands have Hebrew descriptions and valid options', () => { + for (const name of ['help', 'ping', 'botinfo', 'serverinfo', 'userinfo', 'avatar']) { + const json = generalCommand(name).data.toJSON(); + assert.equal(json.name, name); + assert.match(json.description, /[\u0590-\u05ff]/); + assert.equal(json.dm_permission, false); + assert.equal(json.options?.length || 0, ['userinfo', 'avatar'].includes(name) ? 1 : 0); + } +}); + +test('help view contains all six categories and a select menu bound to the requesting user', () => { + const command = generalCommand('ping'); + command.category = 'general'; + const payload = createHelpView([command], null, '123'); + const embed = payload.embeds[0].toJSON(); + const menu = payload.components[0].toJSON().components[0]; + assert.equal(embed.fields.length, 6); + assert.equal(menu.custom_id, 'general_help:123'); + assert.deepEqual(menu.options.map(option => option.value), Object.keys(HELP_CATEGORIES)); +}); + +test('help visibility respects access levels, disabled commands and Discord permissions', async () => { + const make = (name, category, permission = null) => { + const data = new SlashCommandBuilder().setName(name).setDescription('test command'); + if (permission) data.setDefaultMemberPermissions(permission); + return { data, category }; + }; + const client = { + commands: new Collection([ + ['ping', make('ping', 'general')], + ['rank', make('rank', 'levels')], + ['ban', make('ban', 'moderation')], + ['settings', make('settings', 'admin', PermissionFlagsBits.ManageGuild)], + ['avatar', make('avatar', 'general')] + ]), + db: { get: async () => ({ commandSettings: { avatar: { enabled: false } }, commandPermissions: {} }) } + }; + const interaction = { + guildId: 'guild', user: { id: 'user' }, guild: { ownerId: 'owner' }, + inGuild: () => true, + member: { roles: { cache: { has: () => false } }, permissions: { has: () => false } } + }; + const visible = await getVisibleHelpCommands(interaction, client); + assert.deepEqual(visible.map(command => command.data.name), ['ping']); +}); + +test('uptime formatter produces stable Hebrew output', () => { + assert.equal(formatUptime(90060), '1 ימים, 1 שעות, 1 דקות'); + assert.equal(formatUptime(0), '0 דקות'); +}); diff --git a/tests/levelCap.test.js b/tests/levelCap.test.js new file mode 100644 index 0000000000..285a46bbd6 --- /dev/null +++ b/tests/levelCap.test.js @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { clampCommunityXp, communityLevelFromXp, MAX_COMMUNITY_XP, MAX_LEVEL } from '../src/utils/levelLimits.js'; +import { levelCommand } from '../src/commands/levels/factory.js'; +import { getLevelFromXp, getXpForLevel } from '../src/services/leveling.js'; + +test('community leveling has a hard level 50 and XP cap', () => { + assert.equal(MAX_LEVEL, 50); + assert.equal(MAX_COMMUNITY_XP, 250000); + assert.equal(communityLevelFromXp(MAX_COMMUNITY_XP), 50); + assert.equal(communityLevelFromXp(Number.MAX_SAFE_INTEGER), 50); + assert.equal(clampCommunityXp(Number.MAX_SAFE_INTEGER), MAX_COMMUNITY_XP); +}); + +test('/setxp cannot accept XP beyond the level-50 threshold', () => { + const option=levelCommand('setxp').data.toJSON().options.find(value=>value.name==='xp'); + assert.equal(option.max_value,MAX_COMMUNITY_XP); +}); + +test('legacy leveling service also caps calculations at level 50', () => { + assert.equal(getLevelFromXp(Number.MAX_SAFE_INTEGER).level,50); + assert.throws(()=>getXpForLevel(51)); +}); diff --git a/tests/ownerInbox.test.js b/tests/ownerInbox.test.js new file mode 100644 index 0000000000..7e507179f4 --- /dev/null +++ b/tests/ownerInbox.test.js @@ -0,0 +1,64 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Collection, MessageFlags } from 'discord.js'; +import communityModal from '../src/modules/interactions/modals/community_form.js'; +import { handleOwnerInboxReply, OWNER_INBOX_GUILD_ID, OWNER_INBOX_USER_ID } from '../src/services/ownerInboxService.js'; +import { communityCommand } from '../src/commands/community/factory.js'; +import replyCommand from '../src/commands/owner/reply.js'; + +function memoryClient({ dmFails = false } = {}) { + const records = new Map(); let sequence = 0; const sent = []; + const client = { db: { get: async (key, fallback = null) => records.has(key) ? records.get(key) : fallback, + set: async (key, value) => { records.set(key, value); return true; }, increment: async () => ++sequence }, + users: { fetch: async id => ({ id, send: async payload => { if (dmFails) throw new Error('DM closed'); sent.push({ id, payload }); return { id: 'owner-message' }; } }) } }; + return { client, records, sent }; +} +function modalInteraction(kind) { + const form = kind === 'suggest' ? { title: 'רעיון חדש', description: 'פירוט עם https://example.com' } + : { type: 'הטרדה', reported_user: '123', description: 'תיאור הדיווח', evidence: 'https://example.com/message' }; + let reply; + return { interaction: { guildId: OWNER_INBOX_GUILD_ID, channelId: 'channel-1', + guild: { name: 'EditIL', channels: { cache: new Collection() } }, channel: { name: 'general' }, + user: { id: 'user-1', username: 'sender', globalName: 'Sender' }, member: { displayName: 'Display' }, + fields: { getTextInputValue: id => form[id] || '' }, reply: async payload => { reply = payload; } }, get reply() { return reply; } }; +} + +for (const kind of ['suggest', 'report']) test(`${kind} in target guild is DMed and never posted publicly`, async () => { + const ctx = memoryClient(), input = modalInteraction(kind); + await communityModal.execute(input.interaction, ctx.client, [kind]); + assert.equal(ctx.sent.length, 1); assert.equal(ctx.sent[0].id, OWNER_INBOX_USER_ID); + assert.equal(input.reply.content, '✅ ההודעה שלך נשלחה בהצלחה לצוות השרת.'); + assert.equal(input.reply.flags, MessageFlags.Ephemeral); + const caseId = kind === 'suggest' ? 'SUG-000001' : 'REP-000001'; + assert.equal(ctx.records.get(`owner_inbox:case:${caseId}`).authorId, 'user-1'); +}); + +test('DM failure keeps the complete case pending in storage', async () => { + const ctx = memoryClient({ dmFails: true }), input = modalInteraction('report'); + await communityModal.execute(input.interaction, ctx.client, ['report']); + assert.equal(ctx.records.get('owner_inbox:case:REP-000001').deliveryStatus, 'pending'); + assert.match(input.reply.content, /תקלה זמנית/); +}); + +test('only the configured owner can reply to a stored case in DMs', async () => { + const ctx = memoryClient(); ctx.records.set('owner_inbox:case:SUG-000001', { caseId:'SUG-000001', authorId:'user-1', replies:[] }); + let response; const message={ guild:null, content:'/reply SUG-000001 תודה על ההצעה', author:{id:OWNER_INBOX_USER_ID}, client:ctx.client, reply:async text=>{response=text;} }; + assert.equal(await handleOwnerInboxReply(message), true); assert.equal(ctx.sent[0].id, 'user-1'); + assert.equal(ctx.records.get('owner_inbox:case:SUG-000001').replies.length, 1); assert.match(response,/נשלחה בהצלחה/); + assert.equal(await handleOwnerInboxReply({ ...message, author:{id:'someone-else'} }), false); +}); + +for (const name of ['suggest', 'report']) test(`${name} can open its modal without a verified role in the target guild`, async () => { + const ctx=memoryClient();let modal;const interaction={guildId:OWNER_INBOX_GUILD_ID,commandName:name, + inGuild:()=>true,guild:{ownerId:'owner'},user:{id:'unverified'},member:{permissions:{has:()=>false},roles:{cache:new Collection()}}, + options:{},showModal:async value=>{modal=value;},reply:async()=>{throw new Error('access was denied');}}; + await communityCommand(name).execute(interaction,ctx.client); + assert.equal(modal.data.custom_id,`community_form:${name}`); +}); + +test('/reply is a real slash command and delivers the owner response', async () => { + const json=replyCommand.data.toJSON();assert.equal(json.name,'reply');assert.deepEqual(json.options.map(option=>option.name),['case_id','message']); + const ctx=memoryClient();ctx.records.set('owner_inbox:case:REP-000001',{caseId:'REP-000001',authorId:'user-1',replies:[]});let response; + const interaction={user:{id:OWNER_INBOX_USER_ID},options:{getString:name=>name==='case_id'?'REP-000001':'הדיווח טופל'},reply:async payload=>{response=payload;}}; + await replyCommand.execute(interaction,ctx.client);assert.equal(ctx.sent[0].id,'user-1');assert.match(response.content,/נשלחה בהצלחה/);assert.equal(ctx.records.get('owner_inbox:case:REP-000001').replies.length,1); +}); diff --git a/tests/roleSystem.test.js b/tests/roleSystem.test.js new file mode 100644 index 0000000000..42675b1cdd --- /dev/null +++ b/tests/roleSystem.test.js @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Collection, PermissionFlagsBits, PermissionsBitField } from 'discord.js'; +import role from '../src/commands/roles/role.js'; +import roles from '../src/commands/roles/roles.js'; +import rolepanel from '../src/commands/admin/rolepanel.js'; +import settings from '../src/commands/admin/settings.js'; +import { panelPayload, roleTemplates, validateRoleAction } from '../src/services/roleSystemService.js'; +import buttonHandlers from '../src/modules/interactions/buttons/role_system.js'; +import selectHandlers from '../src/modules/interactions/selectMenus/role_system.js'; + +test('role commands expose the complete slash-command surface without duplicates', () => { + assert.equal(roles.data.name, 'roles'); + assert.deepEqual(role.data.toJSON().options.map(option=>option.name), ['add','remove','info','create','delete']); + assert.deepEqual(rolepanel.data.toJSON().options.map(option=>option.name), ['create','edit','delete','list','refresh']); + assert.equal(new Set(['roles',role.data.name,rolepanel.data.name]).size,3); +}); + +test('role templates never contain Administrator, ban or kick permissions', () => { + for (const permissions of Object.values(roleTemplates)) { + const bits=new PermissionsBitField(permissions); + assert.equal(bits.has(PermissionFlagsBits.Administrator),false); + assert.equal(bits.has(PermissionFlagsBits.BanMembers),false); + assert.equal(bits.has(PermissionFlagsBits.KickMembers),false); + } +}); + +test('hierarchy validator rejects managed, administrator and above-bot roles', async () => { + const guild={id:'g',ownerId:'owner',client:null,members:{me:{permissions:new PermissionsBitField(PermissionFlagsBits.ManageRoles),roles:{highest:{position:10}}}}}; + const actor={id:'staff',roles:{highest:{position:9}}}; + const make=(overrides={})=>({id:'r',position:1,managed:false,tags:{},permissions:new PermissionsBitField(),...overrides}); + assert.match(await validateRoleAction(guild,actor,make({managed:true})),/Discord/); + assert.match(await validateRoleAction(guild,actor,make({position:10})),/הבוט/); + assert.match(await validateRoleAction(guild,actor,make({permissions:new PermissionsBitField(PermissionFlagsBits.Administrator)})),/Administrator/); +}); + +test('panel rendering builds persistent controls for buttons and select menus', () => { + const roleCache=new Collection([['1',{id:'1',name:'Premiere'}],['2',{id:'2',name:'After Effects'}]]); + const guild={roles:{cache:roleCache}};const base={id:'7',title:'תוכנות',description:'בחרו',category:'software',maxSelections:2,roleIds:['1','2']}; + const buttons=panelPayload({...base,selectionType:'buttons'},guild); + const menu=panelPayload({...base,selectionType:'select_menu'},guild); + assert.equal(buttons.components[0].components[0].data.custom_id,'role_panel_button:7:1'); + assert.equal(menu.components[0].components[0].data.custom_id,'role_panel_select:7'); +}); + +test('settings and persistent interaction handlers are registered structurally', () => { + const group=settings.data.toJSON().options.find(option=>option.name==='roles'); + assert.deepEqual(group.options.map(option=>option.name),['view','set','category','permissions']); + assert.deepEqual(buttonHandlers.map(handler=>handler.name),['role_panel_button','role_panel_delete','role_confirm','role_cancel']); + assert.deepEqual(selectHandlers.map(handler=>handler.name),['self_roles','role_panel_select']); +}); diff --git a/tests/serverLogging.test.js b/tests/serverLogging.test.js new file mode 100644 index 0000000000..aebbabcb16 --- /dev/null +++ b/tests/serverLogging.test.js @@ -0,0 +1,17 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { Events } from 'discord.js'; +import registerServerLogging from '../src/handlers/serverLogging.js'; + +test('registers the logging listeners exactly once and without undefined event names', () => { + const client = new EventEmitter(); + registerServerLogging(client); + const firstNames = client.eventNames(); + assert.ok(firstNames.length >= 20); + assert.ok(!firstNames.includes(undefined)); + for (const required of [Events.MessageUpdate, Events.MessageDelete, Events.GuildMemberRemove, Events.ChannelUpdate, Events.GuildRoleUpdate, Events.VoiceStateUpdate, Events.InviteCreate, Events.GuildEmojiCreate, Events.GuildStickerUpdate, Events.GuildUpdate]) assert.ok(firstNames.includes(required)); + const counts = new Map(firstNames.map(name => [name, client.listenerCount(name)])); + registerServerLogging(client); + for (const [name, count] of counts) assert.equal(client.listenerCount(name), count); +}); diff --git a/tests/settingsOverview.test.js b/tests/settingsOverview.test.js new file mode 100644 index 0000000000..a86ab9f36a --- /dev/null +++ b/tests/settingsOverview.test.js @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { defaults } from '../src/modules/community/store.js'; +import { createSettingsOverview, createSettingsPage, createSettingsComponents } from '../src/services/settingsOverview.js'; + +test('settings overview includes every configurable server area within embed limits', () => { + const json = createSettingsOverview(structuredClone(defaults)).toJSON(); + assert.equal(json.fields.length, 10); + assert.ok(json.fields.every(field => field.name.length <= 256 && field.value.length <= 1024)); + for (const heading of ['קבלת פנים', 'אימות', 'פניות', 'לוגים', 'רמות', 'ערוצי פקודות', 'תפקידי גישה', 'מודולים', 'פאנלים ותחרויות', 'הגדרות פקודות']) { + assert.ok(json.fields.some(field => field.name.includes(heading))); + } +}); + +test('settings dashboard provides valid navigable pages and button rows', () => { + const config = structuredClone(defaults); + for (const page of ['overview', 'systems', 'access', 'commands', 'logging']) { + assert.ok(createSettingsPage(config, page).toJSON().title); + const rows = createSettingsComponents('123456789', page).map(row => row.toJSON()); + assert.equal(rows.length, 2); + assert.ok(rows.every(row => row.components.length <= 5)); + assert.ok(rows.flatMap(row => row.components).every(button => button.custom_id.length <= 100)); + } +}); diff --git a/tests/ticketSystem.test.js b/tests/ticketSystem.test.js new file mode 100644 index 0000000000..79a541f434 --- /dev/null +++ b/tests/ticketSystem.test.js @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Collection, PermissionFlagsBits } from 'discord.js'; +import { ticketCommand } from '../src/commands/tickets/factory.js'; +import ticket from '../src/commands/admin/ticket.js'; +import ticketpanel from '../src/commands/admin/ticketpanel.js'; +import settings from '../src/commands/admin/settings.js'; +import ticketType from '../src/modules/interactions/selectMenus/ticket_type.js'; +import ticketButtons from '../src/modules/interactions/buttons/ticket_actions.js'; +import { normalizeTicket, safeChannelName, ticketMessagePayload, ticketPanelPayload, TICKET_TYPES } from '../src/services/ticketSystemService.js'; + +test('all ticket commands and subcommands build without duplicate names',()=>{const standalone=['add','remove','close','transcript','claim','unclaim','rename','ticketinfo','ticketstatus','ticketpriority'].map(name=>ticketCommand(name).data.toJSON());assert.equal(new Set(standalone.map(v=>v.name)).size,10);assert.deepEqual(ticket.data.toJSON().options.map(v=>v.name),['open','setup','disable']);assert.deepEqual(ticketpanel.data.toJSON().options.map(v=>v.name),['create','edit','delete']);}); +test('legacy ticket records are preserved and safely normalized',()=>{const legacy={ownerId:'u',supportRoleId:'s',subject:'Legacy issue',createdAt:100};const value=normalizeTicket(legacy,{id:'42',guildId:'g',channelId:'c'});assert.equal(value.creatorId,'u');assert.equal(value.description,'Legacy issue');assert.equal(value.id,'42');assert.equal(value.status,'open');assert.deepEqual(value.addedMemberIds,[]);assert.equal(value.supportRoleId,'s');}); +test('ticket panels expose every enabled ticket type through persistent select controls',()=>{const config={tickets:{enabledTypes:Object.keys(TICKET_TYPES)}};const payload=ticketPanelPayload(config);const menu=payload.components[0].toJSON().components[0];assert.equal(menu.options.length,8);assert.equal(menu.custom_id,'ticket_type:default');}); +test('contextual ticket modals ask only relevant fields and respect Discord five-field limit',async()=>{const client={db:{get:async()=>({tickets:{enabledTypes:Object.keys(TICKET_TYPES)}})}};for(const type of Object.keys(TICKET_TYPES)){let modal;const interaction={guildId:'g',values:[type],showModal:async value=>{modal=value.toJSON();}};await ticketType.execute(interaction,client,['default']);assert.ok(modal.components.length>=2&&modal.components.length<=5);const ids=modal.components.map(row=>row.components[0].custom_id);assert.ok(ids.includes('title')&&ids.includes('description'));if(type==='editing')assert.ok(ids.includes('software'));if(type==='paid_work')assert.ok(ids.includes('budget'));}}); +test('ticket channel names are sanitized and retain the ticket id',()=>{assert.equal(safeChannelName('Itay שלום !!!','42'),'itay-0042');assert.match(safeChannelName('','7','report'),/^report-0007$/);assert.ok(safeChannelName('a'.repeat(200),'99').length<=100);}); +test('ticket opening message contains persistent management controls',()=>{const payload=ticketMessagePayload(normalizeTicket({id:'1',guildId:'g',channelId:'c',creatorId:'u',title:'Title',description:'Details'}));assert.equal(payload.components.length,2);assert.equal(payload.components.flatMap(row=>row.components).length,7);assert.equal(ticketButtons.length,4);}); +test('settings exposes all required ticket configuration areas',()=>{const group=settings.data.toJSON().options.find(option=>option.name==='tickets');assert.deepEqual(group.options.map(option=>option.name),['view','channel','role','limits','types','toggles']);});